From b4960ae23088c486dbcb0808c567fd19e5c4bffc Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:26:57 +0200 Subject: [PATCH] feat(release): add install.sh for standalone binaries (#101) * feat(release): add install.sh for standalone binaries * fix(tests): stop blocking the event loop in installer tests * fix(install): harden install.sh after review --- .github/workflows/release.yml | 24 +- README.md | 39 ++- install.sh | 284 +++++++++++++++++++++ tests/install-script.test.ts | 460 ++++++++++++++++++++++++++++++++++ 4 files changed, 801 insertions(+), 6 deletions(-) create mode 100755 install.sh create mode 100644 tests/install-script.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31259a5..666ae6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,18 +92,40 @@ jobs: permissions: contents: write steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/download-artifact@v4 with: pattern: routstrd-* merge-multiple: true - name: Create checksums run: shasum -a 256 routstrd-*.tar.gz > SHA256SUMS + - name: Serve artifacts for installer smoke test + run: | + version="${GITHUB_REF_NAME#v}" + mkdir -p "serve/v${version}" + cp "routstrd-v${version}-linux-x64.tar.gz" SHA256SUMS "serve/v${version}/" + - name: Smoke test install.sh against the built artifacts + run: | + version="${GITHUB_REF_NAME#v}" + python3 -m http.server 8137 --bind 127.0.0.1 --directory serve & + server_pid=$! + trap 'kill "${server_pid}"' EXIT + sleep 1 + sh install.sh \ + --version "${version}" \ + --platform linux \ + --arch x64 \ + --dir /tmp/routstrd-install-smoke \ + --download-base-url http://127.0.0.1:8137 + test "$(/tmp/routstrd-install-smoke/routstrd --version)" = "${version}" - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} run: >- gh release create "${GITHUB_REF_NAME}" - routstrd-*.tar.gz SHA256SUMS + routstrd-*.tar.gz SHA256SUMS install.sh --repo "${GITHUB_REPOSITORY}" --verify-tag --generate-notes diff --git a/README.md b/README.md index 0d490cb..9276eff 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,29 @@ npm or running from source requires the [Bun](https://bun.sh) runtime. ### Step 1: Install -**Standalone binary:** +**Standalone binary (recommended):** + +Installs the standalone executable for Linux or macOS (x64 or arm64) into +`$HOME/.local/bin`. No Bun, Node.js, or npm required. + +```sh +curl -fsSL https://github.com/Routstr/routstrd/releases/latest/download/install.sh | sh +``` + +Pin a version, change the install directory, or print the resolved asset without +installing anything: + +```sh +curl -fsSL https://github.com/Routstr/routstrd/releases/latest/download/install.sh \ + | sh -s -- --version 0.4.9 --dir /usr/local/bin +``` + +The installer downloads the release archive, verifies it against the release +`SHA256SUMS`, and only replaces an existing `routstrd` once the checksum matches +and the extracted binary reports the expected version. + +
+Manual install Download the archive for your operating system and architecture from the [latest GitHub Release](https://github.com/Routstr/routstrd/releases/latest). @@ -42,6 +64,12 @@ install -m 755 routstrd "$HOME/.local/bin/routstrd" Substitute the version, platform, and architecture for the archive you downloaded, and ensure `$HOME/.local/bin` is on `PATH`. +
+ +Installing the standalone binary is preferred over the npm package: the npm +package runs through the Bun runtime, while the standalone executable has no +runtime dependency. + **Global with bun:** ```sh bun i -g routstrd @@ -310,10 +338,11 @@ not part of `bun test`. 1. Set a new `package.json` version and commit it. The release tag must be the same version prefixed with `v`, and the tag must not already exist. 2. Push the tag. The release workflow runs lint and tests, builds Linux and - macOS executables for x64 and arm64, smoke-tests them, and publishes the - archives with `SHA256SUMS`. -3. Verify all four archives appear in the GitHub Release and validate each - checksum before announcing it. + macOS executables for x64 and arm64, smoke-tests them, verifies the archives + through `install.sh` itself, and publishes the archives with `SHA256SUMS` and + `install.sh`. +3. Verify all four archives and `install.sh` appear in the GitHub Release and + validate each checksum before announcing it. 4. In disposable environments for each platform, test `--version`, `--help`, foreground startup failure, and background `start`, `status`, and `stop` without Bun on `PATH`. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..cdf7df9 --- /dev/null +++ b/install.sh @@ -0,0 +1,284 @@ +#!/bin/sh +# routstrd installer for standalone releases. +# +# curl -fsSL https://github.com/Routstr/routstrd/releases/latest/download/install.sh | sh +# +# Downloads the standalone archive for this platform from the GitHub Release, +# verifies it against the release SHA256SUMS, and installs the `routstrd` +# executable. Needs only `sh`, `tar`, `curl` (or `wget`) and a SHA256 tool -- +# not Bun, Node.js, or npm. +# +# Options (also available as ROUTSTRD_* environment variables): run this file +# with --help, or see the usage text below, for the full list. + +set -eu + +REPO="${ROUTSTRD_REPO:-Routstr/routstrd}" +API_BASE_URL="${ROUTSTRD_API_BASE_URL:-https://api.github.com}" +EXPLICIT_DOWNLOAD_BASE_URL="${ROUTSTRD_DOWNLOAD_BASE_URL:-}" +VERSION="${ROUTSTRD_VERSION:-}" +PLATFORM="${ROUTSTRD_PLATFORM:-}" +ARCH="${ROUTSTRD_ARCH:-}" +INSTALL_DIR="${ROUTSTRD_INSTALL_DIR:-}" +PRINT_ASSET=0 +PLATFORM_DETECTED=0 +ARCH_DETECTED=0 +MAX_ARCHIVE_BYTES=262144000 +MAX_CHECKSUMS_BYTES=1048576 +CONNECT_TIMEOUT_SECS=10 +API_TIMEOUT_SECS=60 +DOWNLOAD_TIMEOUT_SECS=900 + +say() { printf '%s\n' "$*" >&2; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +usage() { + cat <<'EOF' +Install routstrd from a standalone GitHub Release. + +Usage: sh install.sh [options] + +Options: + --version Install a specific version (default: latest) + --dir Install directory (default: $HOME/.local/bin) + --install-dir Alias for --dir + --platform Override platform detection (linux|darwin) + --arch Override architecture detection (x64|arm64) + --repo GitHub repository (default: Routstr/routstrd) + --api-base-url GitHub API base URL (testing/mirrors) + --download-base-url Release download base URL (testing/mirrors) + --print-asset Print the resolved asset name and exit + --help Show this help + +Environment: ROUTSTRD_VERSION, ROUTSTRD_INSTALL_DIR, ROUTSTRD_PLATFORM, + ROUTSTRD_ARCH, ROUTSTRD_REPO, ROUTSTRD_API_BASE_URL, + ROUTSTRD_DOWNLOAD_BASE_URL, GITHUB_TOKEN (for API rate limits) +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --version) [ $# -ge 2 ] || die "--version requires a value"; VERSION="$2"; shift 2 ;; + --dir|--install-dir) [ $# -ge 2 ] || die "$1 requires a value"; INSTALL_DIR="$2"; shift 2 ;; + --platform) [ $# -ge 2 ] || die "--platform requires a value"; PLATFORM="$2"; shift 2 ;; + --arch) [ $# -ge 2 ] || die "--arch requires a value"; ARCH="$2"; shift 2 ;; + --repo) [ $# -ge 2 ] || die "--repo requires a value"; REPO="$2"; shift 2 ;; + --api-base-url) [ $# -ge 2 ] || die "--api-base-url requires a value"; API_BASE_URL="$2"; shift 2 ;; + --download-base-url) [ $# -ge 2 ] || die "--download-base-url requires a value"; EXPLICIT_DOWNLOAD_BASE_URL="$2"; shift 2 ;; + --print-asset) PRINT_ASSET=1; shift ;; + --help|-h) usage; exit 0 ;; + *) die "unknown option '$1' (try --help)" ;; + esac +done + +if [ -n "$EXPLICIT_DOWNLOAD_BASE_URL" ]; then + DOWNLOAD_BASE_URL="$EXPLICIT_DOWNLOAD_BASE_URL" +else + DOWNLOAD_BASE_URL="https://github.com/${REPO}/releases/download" +fi + +if [ -z "$PLATFORM" ]; then + case "$(uname -s)" in + Linux) PLATFORM=linux ;; + Darwin) PLATFORM=darwin ;; + *) die "unsupported operating system '$(uname -s)'; releases cover Linux and macOS." ;; + esac + PLATFORM_DETECTED=1 +fi + +if [ -z "$ARCH" ]; then + case "$(uname -m)" in + x86_64|amd64) + ARCH=x64 + # Under Rosetta, uname reports x86_64 on arm64 Macs; prefer the native build. + if [ "$PLATFORM" = darwin ] \ + && [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = "1" ]; then + ARCH=arm64 + fi + ;; + arm64|aarch64) ARCH=arm64 ;; + *) die "unsupported architecture '$(uname -m)'; releases cover x64 and arm64." ;; + esac + ARCH_DETECTED=1 +fi + +case "$PLATFORM" in + linux|darwin) ;; + *) die "unsupported platform '$PLATFORM'; expected linux or darwin." ;; +esac + +case "$ARCH" in + x64|arm64) ;; + *) die "unsupported architecture '$ARCH'; expected x64 or arm64." ;; +esac + +# Resolved lazily by require_http_client, so --print-asset does not need either. +HTTP_CLIENT="" +require_http_client() { + if [ -n "$HTTP_CLIENT" ]; then + return 0 + fi + if command -v curl >/dev/null 2>&1; then + HTTP_CLIENT=curl + elif command -v wget >/dev/null 2>&1; then + HTTP_CLIENT=wget + else + die "curl or wget is required to download routstrd." + fi +} + +fetch_stdout() { + require_http_client + if [ "$HTTP_CLIENT" = curl ]; then + curl -fsSL --connect-timeout "$CONNECT_TIMEOUT_SECS" \ + --max-time "$API_TIMEOUT_SECS" --retry 3 \ + -H "Accept: application/vnd.github+json" \ + ${GITHUB_TOKEN:+-H "Authorization: Bearer ${GITHUB_TOKEN}"} "$1" + else + wget -qO- --timeout="$API_TIMEOUT_SECS" --tries=3 \ + --header="Accept: application/vnd.github+json" \ + ${GITHUB_TOKEN:+--header="Authorization: Bearer ${GITHUB_TOKEN}"} "$1" + fi +} + +fetch_file() { + require_http_client + if [ "$HTTP_CLIENT" = curl ]; then + curl -fsSL --connect-timeout "$CONNECT_TIMEOUT_SECS" \ + --max-time "$DOWNLOAD_TIMEOUT_SECS" --retry 3 \ + -o "$2" "$1" + else + wget -qO "$2" --timeout="$DOWNLOAD_TIMEOUT_SECS" --tries=3 "$1" + fi +} + +if [ -z "$VERSION" ]; then + if ! release_json="$(fetch_stdout "${API_BASE_URL}/repos/${REPO}/releases/latest")"; then + die "could not query ${API_BASE_URL}/repos/${REPO}/releases/latest." + fi + tag="$(printf '%s\n' "$release_json" \ + | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ + | head -n 1)" + [ -n "$tag" ] || die "could not read the latest release tag from ${REPO}." + VERSION="${tag#v}" +else + VERSION="${VERSION#v}" +fi + +case "$VERSION" in + [0-9]*) ;; + *) die "invalid version '$VERSION'." ;; +esac +case "$VERSION" in + *[!0-9A-Za-z.+-]*) die "invalid version '$VERSION'." ;; +esac + +ASSET="routstrd-v${VERSION}-${PLATFORM}-${ARCH}.tar.gz" + +if [ "$PRINT_ASSET" = 1 ]; then + printf '%s\n' "$ASSET" + exit 0 +fi + +if [ -z "$INSTALL_DIR" ]; then + [ -n "${HOME:-}" ] || die "HOME is not set; pass --dir or set ROUTSTRD_INSTALL_DIR." + INSTALL_DIR="$HOME/.local/bin" +fi + +if command -v sha256sum >/dev/null 2>&1; then + SHA256_CMD="sha256sum" +elif command -v shasum >/dev/null 2>&1; then + SHA256_CMD="shasum -a 256" +elif command -v openssl >/dev/null 2>&1; then + SHA256_CMD="openssl_sha256" +else + die "no SHA256 tool found (need sha256sum, shasum, or openssl)." +fi + +hash_file() { + if [ "$SHA256_CMD" = openssl_sha256 ]; then + openssl dgst -sha256 -r "$1" | awk '{print $1}' | tr '[:upper:]' '[:lower:]' + else + # shellcheck disable=SC2086 + $SHA256_CMD "$1" | awk '{print $1}' | tr '[:upper:]' '[:lower:]' + fi +} + +WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/routstrd-install.XXXXXX")" \ + || die "could not create a temporary directory." +STAGED="" +cleanup() { + if [ -n "$STAGED" ]; then rm -f "$STAGED" 2>/dev/null || true; fi + if [ -n "$WORKDIR" ]; then rm -rf "$WORKDIR" 2>/dev/null || true; fi +} +trap cleanup EXIT +trap 'say "Interrupted."; exit 1' INT TERM + +RELEASE_BASE_URL="${DOWNLOAD_BASE_URL}/v${VERSION}" +ARCHIVE="${WORKDIR}/${ASSET}" +CHECKSUMS="${WORKDIR}/SHA256SUMS" + +say "Installing routstrd v${VERSION} (${PLATFORM}-${ARCH})." + +if ! fetch_file "${RELEASE_BASE_URL}/${ASSET}" "$ARCHIVE"; then + die "could not download ${ASSET} from ${RELEASE_BASE_URL}. Release v${VERSION} may not include a build for ${PLATFORM}-${ARCH}." +fi +if ! fetch_file "${RELEASE_BASE_URL}/SHA256SUMS" "$CHECKSUMS"; then + die "could not download SHA256SUMS from ${RELEASE_BASE_URL}." +fi + +archive_bytes="$(wc -c < "$ARCHIVE" | tr -d '[:space:]')" +[ "$archive_bytes" -le "$MAX_ARCHIVE_BYTES" ] \ + || die "${ASSET} is unexpectedly large (${archive_bytes} bytes)." + +checksums_bytes="$(wc -c < "$CHECKSUMS" | tr -d '[:space:]')" +[ "$checksums_bytes" -le "$MAX_CHECKSUMS_BYTES" ] \ + || die "SHA256SUMS is unexpectedly large (${checksums_bytes} bytes)." + +expected="$(grep -F "$ASSET" "$CHECKSUMS" 2>/dev/null \ + | awk -v name="$ASSET" '$2 == name || $2 == "*" name { print $1 }' \ + | head -n 1 \ + | tr '[:upper:]' '[:lower:]')" +[ -n "$expected" ] || die "SHA256SUMS does not contain ${ASSET}." + +actual="$(hash_file "$ARCHIVE")" +if [ "$actual" != "$expected" ]; then + die "checksum mismatch for ${ASSET}: expected ${expected}, got ${actual}." +fi + +tar -xzf "$ARCHIVE" -C "$WORKDIR" || die "could not extract ${ASSET}." +[ -f "${WORKDIR}/routstrd" ] || die "${ASSET} does not contain a routstrd executable." + +mkdir -p "$INSTALL_DIR" || die "could not create ${INSTALL_DIR}." +TARGET="${INSTALL_DIR}/routstrd" +STAGED="${INSTALL_DIR}/.routstrd.tmp.$$" + +cp "${WORKDIR}/routstrd" "$STAGED" || die "could not write to ${INSTALL_DIR}." +chmod 755 "$STAGED" + +# Validate the staged binary before replacing the target, mirroring +# installStandaloneRelease: refuse a release whose binary reports the wrong +# version rather than replacing a working install and warning about it. +# Skipped for cross-installs (--platform/--arch overrides), where the binary may +# not be able to run on this machine at all. +if [ "$PLATFORM_DETECTED" = 1 ] && [ "$ARCH_DETECTED" = 1 ]; then + staged_version="$("$STAGED" --version 2>/dev/null || true)" + if [ "$staged_version" != "$VERSION" ] && [ "$staged_version" != "v$VERSION" ]; then + die "staged routstrd reported version '${staged_version:-unknown}' instead of '${VERSION}'." + fi +fi + +# Rename over the target so a running daemon keeps its old inode. +mv -f "$STAGED" "$TARGET" || die "could not install to ${TARGET}." +STAGED="" + +say "Installed routstrd v${VERSION} to ${TARGET}" + +case ":${PATH:-}:" in + *":${INSTALL_DIR}:"*) ;; + *) + say "" + say "${INSTALL_DIR} is not in PATH. Add it to your shell profile:" + say " export PATH=\"${INSTALL_DIR}:\$PATH\"" + ;; +esac diff --git a/tests/install-script.test.ts b/tests/install-script.test.ts new file mode 100644 index 0000000..44dc187 --- /dev/null +++ b/tests/install-script.test.ts @@ -0,0 +1,460 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "crypto"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { releaseArchiveName } from "../src/utils/standalone-update"; + +const INSTALL_SCRIPT = join(import.meta.dir, "..", "install.sh"); +const REPO = "Routstr/routstrd"; +const VERSION = "9.9.9"; +const PLATFORMS = ["linux", "darwin"] as const; +const ARCHS = ["x64", "arm64"] as const; + +const tempDirs: string[] = []; +const servers: ReturnType[] = []; + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const server of servers.splice(0)) server.stop(true); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** Builds a tar.gz that mimics a real release archive: a single `routstrd` entry. */ +function buildArchive(dir: string, version: string): Uint8Array { + const stage = join(dir, "stage"); + const archivePath = join(dir, "archive.tar.gz"); + writeFileSync( + join(dir, "routstrd"), + `#!/bin/sh\necho ${version}\n`, + ); + Bun.spawnSync([ + "sh", + "-c", + `mkdir -p ${JSON.stringify(stage)} && cp ${JSON.stringify(join(dir, "routstrd"))} ${JSON.stringify(join(stage, "routstrd"))} && tar -czf ${JSON.stringify(archivePath)} -C ${JSON.stringify(stage)} routstrd`, + ]); + return new Uint8Array(readFileSync(archivePath)); +} + +type FakeReleaseOptions = { + version?: string; + asset?: string; + archive?: Uint8Array; + checksums?: string; + omitAsset?: boolean; + /** Delays the archive response so a test can signal while the download is in flight. */ + assetDelayMs?: number; +}; + +/** + * Serves the subset of the GitHub API and release download URLs that install.sh + * consumes, so the installer can be exercised end to end without network access. + */ +function serveFakeRelease(options: FakeReleaseOptions = {}): string { + const version = options.version ?? VERSION; + const asset = options.asset ?? releaseArchiveName(version, "linux", "x64"); + const server = Bun.serve({ + port: 0, + async fetch(request) { + const { pathname } = new URL(request.url); + if (pathname === `/repos/${REPO}/releases/latest`) { + return Response.json({ + tag_name: `v${version}`, + assets: [{ name: asset }, { name: "SHA256SUMS" }], + }); + } + if (pathname === `/dl/v${version}/SHA256SUMS`) { + return new Response(options.checksums ?? ""); + } + if (pathname === `/dl/v${version}/${asset}`) { + if (options.omitAsset) return new Response("not found", { status: 404 }); + if (options.assetDelayMs) await Bun.sleep(options.assetDelayMs); + return new Response(options.archive ?? new Uint8Array()); + } + return new Response("not found", { status: 404 }); + }, + }); + servers.push(server); + const origin = `http://127.0.0.1:${server.port}`; + return origin; +} + +function runInstaller(args: string[], env: Record = {}) { + return Bun.spawnSync(["sh", INSTALL_SCRIPT, ...args], { + env: { ...process.env, ...env }, + stdout: "pipe", + stderr: "pipe", + }); +} + +/** + * The fake release server lives in this process, so tests that download from it + * must not block the event loop: Bun.spawnSync would deadlock on the request the + * child makes back into this process. Always await the child instead. + */ +async function runInstallerAsync(args: string[], env: Record = {}) { + const proc = Bun.spawn(["sh", INSTALL_SCRIPT, ...args], { + env: { ...process.env, ...env }, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +describe("install.sh", () => { + test("is valid POSIX shell", () => { + const result = Bun.spawnSync(["sh", "-n", INSTALL_SCRIPT], { stderr: "pipe" }); + expect(result.stderr.toString()).toBe(""); + expect(result.exitCode).toBe(0); + }); + + test("resolves the same asset names as releaseArchiveName", () => { + for (const platform of PLATFORMS) { + for (const arch of ARCHS) { + const result = runInstaller([ + "--print-asset", + "--version", + VERSION, + "--platform", + platform, + "--arch", + arch, + ]); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().trim()).toBe( + releaseArchiveName(VERSION, platform, arch), + ); + } + } + }); + + test("accepts a v-prefixed version", () => { + const result = runInstaller([ + "--print-asset", + "--version", + `v${VERSION}`, + "--platform", + "linux", + "--arch", + "x64", + ]); + expect(result.stdout.toString().trim()).toBe(releaseArchiveName(VERSION, "linux", "x64")); + }); + + test("rejects unsupported platforms and architectures", () => { + for (const args of [ + ["--print-asset", "--version", VERSION, "--platform", "windows", "--arch", "x64"], + ["--print-asset", "--version", VERSION, "--platform", "linux", "--arch", "riscv64"], + ["--print-asset", "--version", "../../etc/passwd", "--platform", "linux", "--arch", "x64"], + ]) { + const result = runInstaller(args); + expect(result.exitCode).not.toBe(0); + } + }); + + test("installs the executable from a release archive", async () => { + const dir = tempDir("routstrd-install-e2e-"); + const installDir = join(dir, "bin"); + const asset = releaseArchiveName(VERSION, process.platform, process.arch); + const archive = buildArchive(dir, VERSION); + const origin = serveFakeRelease({ + asset, + archive, + checksums: `${sha256Hex(archive)} ${asset}\n`, + }); + + const result = await runInstallerAsync([ + "--dir", + installDir, + "--api-base-url", + origin, + "--download-base-url", + `${origin}/dl`, + ]); + + expect(result.stderr).toContain(`Installed routstrd v${VERSION}`); + expect(result.exitCode).toBe(0); + + const target = join(installDir, "routstrd"); + expect(statSync(target).mode & 0o111).not.toBe(0); + const version = Bun.spawnSync([target, "--version"]); + expect(version.stdout.toString().trim()).toBe(VERSION); + }); + + test("installs a specific version without querying the API", async () => { + const dir = tempDir("routstrd-install-pinned-"); + const installDir = join(dir, "bin"); + const asset = releaseArchiveName(VERSION, process.platform, process.arch); + const archive = buildArchive(dir, VERSION); + const origin = serveFakeRelease({ + asset, + archive, + checksums: `${sha256Hex(archive)} ${asset}\n`, + }); + + const result = await runInstallerAsync([ + "--version", + VERSION, + "--dir", + installDir, + "--api-base-url", + `${origin}/broken`, + "--download-base-url", + `${origin}/dl`, + ]); + + expect(result.exitCode).toBe(0); + expect(statSync(join(installDir, "routstrd")).isFile()).toBe(true); + }); + + test("refuses to install when the checksum does not match", async () => { + const dir = tempDir("routstrd-install-tampered-"); + const installDir = join(dir, "bin"); + const asset = releaseArchiveName(VERSION, process.platform, process.arch); + const archive = buildArchive(dir, VERSION); + const origin = serveFakeRelease({ + asset, + archive, + checksums: `${sha256Hex(new TextEncoder().encode("tampered"))} ${asset}\n`, + }); + + const result = await runInstallerAsync([ + "--dir", + installDir, + "--api-base-url", + origin, + "--download-base-url", + `${origin}/dl`, + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("checksum mismatch"); + expect(() => statSync(join(installDir, "routstrd"))).toThrow(); + }); + + test("reports a missing asset for the current platform", async () => { + const dir = tempDir("routstrd-install-missing-"); + const origin = serveFakeRelease({ + asset: releaseArchiveName(VERSION, process.platform, process.arch), + omitAsset: true, + }); + + const result = await runInstallerAsync([ + "--dir", + join(dir, "bin"), + "--api-base-url", + origin, + "--download-base-url", + `${origin}/dl`, + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("may not include a build"); + }); + + test("reports a release without SHA256SUMS", async () => { + const dir = tempDir("routstrd-install-nosums-"); + const asset = releaseArchiveName(VERSION, process.platform, process.arch); + const archive = buildArchive(dir, VERSION); + const origin = serveFakeRelease({ asset, archive, checksums: "" }); + + const result = await runInstallerAsync([ + "--dir", + join(dir, "bin"), + "--api-base-url", + origin, + "--download-base-url", + `${origin}/dl`, + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("does not contain"); + }); + + test("honours ROUTSTRD_INSTALL_DIR when --dir is absent", async () => { + const dir = tempDir("routstrd-install-env-"); + const installDir = join(dir, "bin"); + const asset = releaseArchiveName(VERSION, process.platform, process.arch); + const archive = buildArchive(dir, VERSION); + const origin = serveFakeRelease({ + asset, + archive, + checksums: `${sha256Hex(archive)} ${asset}\n`, + }); + + const result = await runInstallerAsync( + [ + "--api-base-url", + origin, + "--download-base-url", + `${origin}/dl`, + ], + { ROUTSTRD_INSTALL_DIR: installDir }, + ); + + expect(result.exitCode).toBe(0); + const installed = join(installDir, "routstrd"); + expect(statSync(installed).isFile()).toBe(true); + expect(statSync(installed).mode & 0o111).not.toBe(0); + }); + + test("prints the asset without curl, wget or HOME", () => { + // --print-asset performs no install and, with --version, no network access, + // so it must not require an HTTP client or an install directory. /bin/sh is + // addressed absolutely because PATH is deliberately stripped. + const result = Bun.spawnSync( + [ + "/bin/sh", + INSTALL_SCRIPT, + "--print-asset", + "--version", + VERSION, + "--platform", + "linux", + "--arch", + "x64", + ], + { env: { PATH: "/nonexistent" }, stdout: "pipe", stderr: "pipe" }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().trim()).toBe(releaseArchiveName(VERSION, "linux", "x64")); + }); + + test("refuses a release whose binary reports the wrong version", async () => { + const dir = tempDir("routstrd-install-wrong-version-"); + const installDir = join(dir, "bin"); + mkdirSync(installDir, { recursive: true }); + writeFileSync(join(installDir, "routstrd"), "#!/bin/sh\necho old\n"); + const asset = releaseArchiveName(VERSION, process.platform, process.arch); + const archive = buildArchive(dir, "0.0.1"); + const origin = serveFakeRelease({ + asset, + archive, + checksums: `${sha256Hex(archive)} ${asset}\n`, + }); + + const result = await runInstallerAsync([ + "--dir", + installDir, + "--api-base-url", + origin, + "--download-base-url", + `${origin}/dl`, + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("reported version"); + // The previous install must survive a rejected update. + expect(readFileSync(join(installDir, "routstrd"), "utf8")).toContain("old"); + }); + + test("accepts a staged binary that reports a v-prefixed version", async () => { + const dir = tempDir("routstrd-install-vprefixed-binary-"); + const installDir = join(dir, "bin"); + const asset = releaseArchiveName(VERSION, process.platform, process.arch); + const archive = buildArchive(dir, `v${VERSION}`); + const origin = serveFakeRelease({ + asset, + archive, + checksums: `${sha256Hex(archive)} ${asset}\n`, + }); + + const result = await runInstallerAsync([ + "--dir", + installDir, + "--api-base-url", + origin, + "--download-base-url", + `${origin}/dl`, + ]); + + expect(result.exitCode).toBe(0); + expect(statSync(join(installDir, "routstrd")).isFile()).toBe(true); + }); + + test("rejects an oversized SHA256SUMS", async () => { + const dir = tempDir("routstrd-install-big-sums-"); + const asset = releaseArchiveName(VERSION, process.platform, process.arch); + const archive = buildArchive(dir, VERSION); + const origin = serveFakeRelease({ + asset, + archive, + checksums: `${sha256Hex(archive)} ${asset}\n${"a".repeat(2 * 1024 * 1024)}\n`, + }); + + const result = await runInstallerAsync([ + "--dir", + join(dir, "bin"), + "--api-base-url", + origin, + "--download-base-url", + `${origin}/dl`, + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("SHA256SUMS is unexpectedly large"); + }); + + test( + "exits without installing when interrupted mid-download", + async () => { + const dir = tempDir("routstrd-install-interrupt-"); + const installDir = join(dir, "bin"); + const tmpParent = join(dir, "tmp"); + mkdirSync(tmpParent, { recursive: true }); + const asset = releaseArchiveName(VERSION, process.platform, process.arch); + const archive = buildArchive(dir, VERSION); + const origin = serveFakeRelease({ + asset, + archive, + checksums: `${sha256Hex(archive)} ${asset}\n`, + assetDelayMs: 2000, + }); + + const proc = Bun.spawn( + [ + "sh", + INSTALL_SCRIPT, + "--dir", + installDir, + "--api-base-url", + origin, + "--download-base-url", + `${origin}/dl`, + ], + { env: { ...process.env, TMPDIR: tmpParent }, stdout: "pipe", stderr: "pipe" }, + ); + + // Signal while the archive download is still in flight. The installer must + // report the interrupt, exit non-zero and clean up after itself, rather + // than resuming into a misleading follow-on failure (or an install). + await Bun.sleep(300); + proc.kill(); + + const [exitCode, stderr] = await Promise.all([ + Promise.race([proc.exited, Bun.sleep(8000).then(() => -1)]), + new Response(proc.stderr).text(), + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Interrupted."); + expect(readdirSync(tmpParent)).toEqual([]); + expect(() => statSync(join(installDir, "routstrd"))).toThrow(); + }, + 15000, + ); +});