v3.5.0 8345e76

install.sh RawHistoryFolder

1#!/bin/sh
2set -e
3
4# Gordon installer script
5# Usage: curl -fsSL https://bnema.dev/gordon/install | sh
6# Usage with pre-release: curl -fsSL https://bnema.dev/gordon/install | GORDON_PRERELEASE=1 sh
7# Usage with next source build: curl -fsSL https://bnema.dev/gordon/install | GORDON_CHANNEL=next sh
8
9REPO="bnema/gordon"
10# Releases are hosted on the ermite forge; only the `next` source build uses GitHub.
11FORGE="https://git.bnema.dev"
12FORGE_REPO="gordon"
13VERSION="${GORDON_VERSION:-latest}"
14CHANNEL="${GORDON_CHANNEL:-stable}"
15
16case "${GORDON_UPDATE_PATH:-}" in
17    ''|0|1) ;;
18    *) echo "Error: GORDON_UPDATE_PATH must be 0 or 1"; exit 1 ;;
19esac
20
21if [ -n "${GORDON_INSTALL_DIR+x}" ]; then
22    INSTALL_DIR=$GORDON_INSTALL_DIR
23    if [ -z "$INSTALL_DIR" ]; then
24        echo "Error: GORDON_INSTALL_DIR must not be empty"
25        exit 1
26    fi
27else
28    if [ -n "${SUDO_USER:-}" ] || [ "$(id -u)" -eq 0 ]; then
29        echo "Error: Refusing a default user-local install while running as root or through sudo."
30        echo "Run the installer as the target user, or set GORDON_INSTALL_DIR explicitly for a global install."
31        exit 1
32    fi
33    if [ -z "${HOME:-}" ]; then
34        echo "Error: HOME is required for the default installation directory"
35        exit 1
36    fi
37    INSTALL_DIR=$HOME/.local/bin
38fi
39
40case "$INSTALL_DIR" in
41    /*) ;;
42    *) echo "Error: GORDON_INSTALL_DIR must be an absolute path"; exit 1 ;;
43esac
44if [ "$INSTALL_DIR" != "/" ]; then
45    while [ "${INSTALL_DIR%/}" != "$INSTALL_DIR" ]; do
46        INSTALL_DIR=${INSTALL_DIR%/}
47    done
48fi
49INSTALL_DIR_WITHOUT_CONTROLS=$(LC_ALL=C printf '%sX' "$INSTALL_DIR" | LC_ALL=C tr -d '\000-\037\177')
50if [ "$INSTALL_DIR" = "/" ] || [ "$INSTALL_DIR_WITHOUT_CONTROLS" != "${INSTALL_DIR}X" ]; then
51    echo "Error: GORDON_INSTALL_DIR contains an unsafe control character or destination"
52    exit 1
53fi
54case "$INSTALL_DIR" in
55    *:*) echo "Error: GORDON_INSTALL_DIR must not contain ':' because it separates PATH entries"; exit 1 ;;
56esac
57
58echo "Installing Gordon..."
59
60# Detect OS
61OS=$(uname -s | tr '[:upper:]' '[:lower:]')
62case "$OS" in
63    linux)
64        OS="linux"
65        ;;
66    darwin)
67        OS="darwin"
68        ;;
69    *)
70        echo "Error: Unsupported operating system: $OS"
71        exit 1
72        ;;
73esac
74
75# Detect architecture
76ARCH=$(uname -m)
77case "$ARCH" in
78    x86_64|amd64)
79        ARCH="amd64"
80        ;;
81    aarch64|arm64)
82        ARCH="arm64"
83        ;;
84    *)
85        echo "Error: Unsupported architecture: $ARCH"
86        exit 1
87        ;;
88esac
89
90echo "Detected: ${OS}/${ARCH}"
91
92install_binary_atomically() {
93    binary=$1
94    destination=$2
95    destination_dir=${destination%/*}
96    staging="${destination}.install.$$"
97
98    if ! mkdir -p "$destination_dir" 2>/dev/null; then
99        if [ -z "${GORDON_INSTALL_DIR+x}" ]; then
100            echo "Error: Could not create user installation directory ${destination_dir}"
101            return 1
102        fi
103        echo "sudo required to create ${destination_dir}"
104        sudo mkdir -p "$destination_dir"
105    fi
106
107    if [ -w "$destination_dir" ]; then
108        trap 'rm -f "$staging"; rm -rf "${TMP_DIR:-}"' EXIT HUP INT TERM
109        install -m 0755 "$binary" "$staging"
110        mv -f "$staging" "$destination"
111    else
112        if [ -z "${GORDON_INSTALL_DIR+x}" ]; then
113            echo "Error: User installation directory is not writable: ${destination_dir}"
114            return 1
115        fi
116        echo "sudo required to install to ${destination_dir}"
117        sudo install -m 0755 "$binary" "$staging"
118        if ! sudo mv -f "$staging" "$destination"; then
119            sudo rm -f "$staging"
120            return 1
121        fi
122    fi
123}
124
125path_contains_install_dir() {
126    case ":${PATH:-}:" in
127        *:"$INSTALL_DIR":*) return 0 ;;
128        *) return 1 ;;
129    esac
130}
131
132single_quote() {
133    printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
134}
135
136print_path_instructions() {
137    quoted_dir=$(single_quote "$INSTALL_DIR")
138    echo "Add Gordon to the current shell with:"
139    case "${SHELL##*/}" in
140        fish) echo "  fish_add_path ${quoted_dir}" ;;
141        *) echo "  export PATH=${quoted_dir}:\$PATH" ;;
142    esac
143}
144
145update_shell_path() {
146    shell_name=${SHELL##*/}
147    marker_start="# >>> Gordon installer PATH >>>"
148    marker_end="# <<< Gordon installer PATH <<<"
149
150    case "$shell_name" in
151        fish)
152            config_file=$HOME/.config/fish/config.fish
153            config_dir=${config_file%/*}
154            command_line="fish_add_path $(single_quote "$INSTALL_DIR")"
155            ;;
156        bash)
157            config_file=$HOME/.bashrc
158            config_dir=$HOME
159            command_line="export PATH=$(single_quote "$INSTALL_DIR"):\$PATH"
160            ;;
161        zsh)
162            config_file=$HOME/.zshrc
163            config_dir=$HOME
164            command_line="export PATH=$(single_quote "$INSTALL_DIR"):\$PATH"
165            ;;
166        *)
167            echo "Could not update PATH automatically: unsupported or missing SHELL (${SHELL:-unset})."
168            print_path_instructions
169            return 0
170            ;;
171    esac
172
173    if ! mkdir -p "$config_dir" 2>/dev/null ||
174        { [ -e "$config_file" ] && [ ! -w "$config_file" ]; } ||
175        { [ ! -e "$config_file" ] && [ ! -w "$config_dir" ]; }; then
176        echo "Could not update PATH automatically: ${config_file} is not writable."
177        print_path_instructions
178        return 0
179    fi
180
181    if [ -f "$config_file" ] && grep -F "$marker_start" "$config_file" >/dev/null 2>&1; then
182        echo "PATH configuration already exists in ${config_file}."
183    else
184        {
185            printf '\n%s\n' "$marker_start"
186            printf '%s\n' "$command_line"
187            printf '%s\n' "$marker_end"
188        } >>"$config_file"
189        echo "Added ${INSTALL_DIR} to PATH in ${config_file}."
190    fi
191    print_path_instructions
192}
193
194post_install() {
195    echo ""
196    echo "Gordon installed successfully at ${INSTALL_DIR}/gordon."
197
198    if path_contains_install_dir; then
199        "$INSTALL_DIR/gordon" version
200        return
201    fi
202
203    case "${GORDON_UPDATE_PATH:-}" in
204        1) update_shell_path ;;
205        0) print_path_instructions ;;
206        '')
207            if [ -r /dev/tty ] && [ -w /dev/tty ]; then
208                printf 'Add %s to your PATH configuration? [y/N] ' "$INSTALL_DIR" >/dev/tty
209                if IFS= read -r answer </dev/tty; then
210                    case "$answer" in
211                        y|Y|yes|YES|Yes) update_shell_path ;;
212                        *) print_path_instructions ;;
213                    esac
214                else
215                    print_path_instructions
216                fi
217            else
218                print_path_instructions
219            fi
220            ;;
221    esac
222}
223
224version_at_least() {
225    actual=$1
226    required=$2
227    actual_major=${actual%%.*}
228    actual_minor=${actual#*.}
229    actual_minor=${actual_minor%%.*}
230    required_major=${required%%.*}
231    required_minor=${required#*.}
232    required_minor=${required_minor%%.*}
233
234    [ "$actual_major" -gt "$required_major" ] || {
235        [ "$actual_major" -eq "$required_major" ] && [ "$actual_minor" -ge "$required_minor" ]
236    }
237}
238
239install_next() {
240    if [ -n "${GORDON_VERSION:-}" ]; then
241        echo "Error: GORDON_VERSION cannot be used with GORDON_CHANNEL=next"
242        exit 1
243    fi
244    if [ -n "${GORDON_PRERELEASE:-}" ]; then
245        echo "Error: GORDON_PRERELEASE cannot be used with GORDON_CHANNEL=next"
246        exit 1
247    fi
248    if ! command -v go >/dev/null 2>&1; then
249        echo "Error: GORDON_CHANNEL=next requires Go"
250        exit 1
251    fi
252
253    echo "WARNING: UNVERIFIED DEVELOPMENT BUILD"
254    echo "The next channel builds source locally from the current next branch commit."
255    echo "It is not covered by the signed release checksum path and may be unstable."
256    echo "Resolving the current next commit..."
257    COMMIT_DATA=$(curl -fsSL -H "Accept: application/vnd.github+json" \
258        "https://api.github.com/repos/${REPO}/commits/next" 2>/dev/null || echo "")
259    COMMIT=$(printf '%s\n' "$COMMIT_DATA" | sed -n 's/.*"sha"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F]*\)".*/\1/p' | head -n 1)
260    case "$COMMIT" in
261        *[!0-9a-fA-F]*|'') COMMIT="" ;;
262    esac
263    if [ "${#COMMIT}" -ne 40 ]; then
264        echo "Error: Could not resolve an exact next commit SHA from the GitHub API"
265        exit 1
266    fi
267
268    TMP_DIR=$(mktemp -d)
269    trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
270    SOURCE_TARBALL="$TMP_DIR/source.tar.gz"
271    SOURCE_URL="https://codeload.github.com/${REPO}/tar.gz/${COMMIT}"
272    echo "Downloading source pinned to ${COMMIT}..."
273    if ! curl -fsSL "$SOURCE_URL" -o "$SOURCE_TARBALL"; then
274        echo "Error: Failed to download source for ${COMMIT}"
275        exit 1
276    fi
277
278    SOURCE_ROOT="gordon-${COMMIT}"
279    if ! tar -tzf "$SOURCE_TARBALL" >"$TMP_DIR/archive.list"; then
280        echo "Error: Invalid source archive"
281        exit 1
282    fi
283    if [ ! -s "$TMP_DIR/archive.list" ] ||
284        grep -Ev "^${SOURCE_ROOT}(/.*)?$" "$TMP_DIR/archive.list" >/dev/null ||
285        grep -E '(^|/)\.\.?(/|$)' "$TMP_DIR/archive.list" >/dev/null ||
286        tar -tvzf "$SOURCE_TARBALL" | grep -Ev '^[-d]' >/dev/null; then
287        echo "Error: Source archive contains an unsafe path or entry type"
288        exit 1
289    fi
290    tar -xzf "$SOURCE_TARBALL" -C "$TMP_DIR"
291    SOURCE_DIR="$TMP_DIR/$SOURCE_ROOT"
292    if [ ! -f "$SOURCE_DIR/go.mod" ] || [ ! -f "$SOURCE_DIR/main.go" ]; then
293        echo "Error: Source archive is missing go.mod or the root command"
294        exit 1
295    fi
296
297    REQUIRED_GO=$(awk '$1 == "go" { print $2; exit }' "$SOURCE_DIR/go.mod")
298    case "$REQUIRED_GO" in
299        [0-9]*.[0-9]*) ;;
300        *) echo "Error: Source go.mod has no valid Go version"; exit 1 ;;
301    esac
302    INSTALLED_GO=$(go env GOVERSION 2>/dev/null || true)
303    INSTALLED_GO=${INSTALLED_GO#go}
304    case "$INSTALLED_GO" in
305        [0-9]*.[0-9]*) ;;
306        *) echo "Error: Could not determine installed Go version"; exit 1 ;;
307    esac
308    if ! version_at_least "$INSTALLED_GO" "$REQUIRED_GO"; then
309        echo "Error: Source requires Go ${REQUIRED_GO} or newer; found ${INSTALLED_GO}"
310        exit 1
311    fi
312
313    BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
314    NEXT_VERSION="next-${COMMIT}"
315    BINARY="$TMP_DIR/gordon"
316    echo "Building ${NEXT_VERSION} for ${OS}/${ARCH}..."
317    # The source is a pristine tarball of the pinned commit, so it can
318    # never be a dirty checkout: dirty is reported as false.
319    if ! (cd "$SOURCE_DIR" && CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" GOTOOLCHAIN=local \
320        go build -trimpath -ldflags "-s -w -X main.version=${NEXT_VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE} -X main.dirty=false" \
321        -o "$BINARY" .); then
322        echo "Error: Failed to build Gordon from source"
323        exit 1
324    fi
325    if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then
326        echo "Error: Build did not produce a regular Gordon binary"
327        exit 1
328    fi
329
330    echo "Installing to ${INSTALL_DIR}..."
331    install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon"
332    echo "Installed unverified next build ${COMMIT}."
333}
334
335case "$CHANNEL" in
336    next)
337        install_next
338        post_install
339        exit 0
340        ;;
341    stable|'') ;;
342    *) echo "Error: Unsupported GORDON_CHANNEL: $CHANNEL"; exit 1 ;;
343esac
344
345# Construct download URL
346TARBALL="gordon_${OS}_${ARCH}.tar.gz"
347
348# Determine version to download
349if [ "$VERSION" = "latest" ] && [ -n "$GORDON_PRERELEASE" ]; then
350    echo "Finding latest pre-release..."
351    # Get latest release (including pre-releases) from the forge's GitHub-compatible API
352    RELEASE_DATA=$(curl -fsSL "${FORGE}/-/github/api/repos/${REPO}/releases?per_page=1" 2>/dev/null || echo "")
353    if [ -z "$RELEASE_DATA" ]; then
354        echo "Error: Failed to fetch release information from ${FORGE}/-/github/api/repos/${REPO}/releases"
355        exit 1
356    fi
357    VERSION=$(printf '%s\n' "$RELEASE_DATA" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 | cut -d'"' -f4)
358    case "$VERSION" in
359        *[!0-9A-Za-z._+-]*) VERSION="" ;;
360    esac
361    if [ -z "$VERSION" ]; then
362        echo "Error: Could not determine latest pre-release version"
363        exit 1
364    fi
365    echo "Using pre-release version: ${VERSION}"
366elif [ "$VERSION" = "latest" ]; then
367    echo "Using latest stable release"
368    # Resolve "latest" to an exact tag by following the web redirect
369    # (releases/latest -> releases/tag/<tag>). Unlike the REST API, this is
370    # not rate-limited per IP for anonymous clients.
371    LATEST_URL=$(curl -fsSL -o /dev/null -w '%{url_effective}' "${FORGE}/${FORGE_REPO}/releases/latest" 2>/dev/null || echo "")
372    VERSION=${LATEST_URL#"${FORGE}/${FORGE_REPO}/releases/tag/"}
373    case "$VERSION" in
374        "$LATEST_URL"|''|*[!0-9A-Za-z._+-]*) VERSION="" ;;
375    esac
376    if [ -z "$VERSION" ]; then
377        echo "Error: Could not determine latest stable version from ${FORGE}/${FORGE_REPO}/releases/latest"
378        echo "Set GORDON_VERSION (e.g. GORDON_VERSION=v3.4.0) to install a specific release."
379        exit 1
380    fi
381    echo "Resolved version: ${VERSION}"
382else
383    echo "Using version: ${VERSION}"
384fi
385
386DOWNLOAD_URL="${FORGE}/${FORGE_REPO}/releases/download/${VERSION}/${TARBALL}"
387
388# Create temporary directory
389TMP_DIR=$(mktemp -d)
390trap 'rm -rf "$TMP_DIR"' EXIT
391
392# Download checksums file
393CHECKSUMS_URL="${FORGE}/${FORGE_REPO}/releases/download/${VERSION}/checksums.txt"
394echo "Downloading checksums..."
395if ! curl -fsSL "$CHECKSUMS_URL" -o "$TMP_DIR/checksums.txt"; then
396    echo "Error: Failed to download checksums file"
397    exit 1
398fi
399
400echo "Downloading ${TARBALL}..."
401if ! curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/$TARBALL"; then
402    echo "Error: Failed to download ${TARBALL}"
403    exit 1
404fi
405
406# Verify checksum
407echo "Verifying checksum..."
408# The sed expression is intentionally literal; shell expansion would corrupt it.
409# shellcheck disable=SC2016
410TARBALL_ESCAPED=$(printf '%s\n' "$TARBALL" | sed 's/[.[\*^$()+?{|]/\\&/g')
411CHECKSUM_LINES=$(grep -E "^[0-9a-fA-F]{64}[[:space:]]+\*?${TARBALL_ESCAPED}\$" "$TMP_DIR/checksums.txt" || true)
412CHECKSUM_COUNT=$(printf '%s\n' "$CHECKSUM_LINES" | sed '/^$/d' | wc -l | tr -d ' ')
413if [ "$CHECKSUM_COUNT" != "1" ]; then
414    echo "Error: Expected exactly one checksum for ${TARBALL} in checksums.txt"
415    exit 1
416fi
417EXPECTED_CHECKSUM=$(printf '%s\n' "$CHECKSUM_LINES" | awk '{print $1}')
418
419# Calculate actual checksum (works on both Linux and macOS)
420if command -v sha256sum >/dev/null 2>&1; then
421    ACTUAL_CHECKSUM=$(sha256sum "$TMP_DIR/$TARBALL" | awk '{print $1}')
422elif command -v shasum >/dev/null 2>&1; then
423    ACTUAL_CHECKSUM=$(shasum -a 256 "$TMP_DIR/$TARBALL" | awk '{print $1}')
424else
425    echo "Error: Neither 'sha256sum' nor 'shasum' was found. Cannot verify checksum."
426    echo ""
427    echo "Please install one of these tools:"
428    echo "  Debian/Ubuntu: sudo apt-get install coreutils"
429    echo "  Fedora/RHEL:   sudo dnf install coreutils"
430    echo "  Alpine:        apk add coreutils"
431    echo "  macOS:         shasum should be pre-installed"
432    exit 1
433fi
434
435if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
436    echo "Error: Checksum verification failed!"
437    echo "Expected: $EXPECTED_CHECKSUM"
438    echo "Actual:   $ACTUAL_CHECKSUM"
439    echo "The downloaded file may be corrupted or tampered with."
440    exit 1
441fi
442echo "Checksum verified successfully."
443
444echo "Extracting Gordon binary..."
445EXTRACT_DIR="$TMP_DIR/extract"
446mkdir -p "$EXTRACT_DIR"
447tar -xzf "$TMP_DIR/$TARBALL" -C "$EXTRACT_DIR" gordon
448
449BINARY="$EXTRACT_DIR/gordon"
450if [ ! -f "$BINARY" ] || [ -L "$BINARY" ]; then
451    echo "Error: Archive did not contain a regular Gordon binary"
452    exit 1
453fi
454
455echo "Installing to ${INSTALL_DIR}..."
456install_binary_atomically "$BINARY" "$INSTALL_DIR/gordon"
457
458post_install

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close