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