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