v3.2.0 4fc01fa

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