From f5c257674f267d0c9115f9de88f45baea0e21d4a Mon Sep 17 00:00:00 2001
From: redshift <213178690+1ftredsh@users.noreply.github.com>
Date: Thu, 10 Sep 2026 17:03:33 +0200
Subject: [PATCH] feat(release): add install.sh for standalone binaries
Standalone installs previously required downloading a release tarball,
checking SHA256SUMS, extracting and installing by hand. Add a POSIX shell
installer, published as a release asset so `releases/latest/download/`
always serves the current one:
curl -fsSL https://github.com/Routstr/routstrd/releases/latest/download/install.sh | sh
The installer detects platform and architecture, resolves the latest
version from the GitHub API (or takes --version), verifies the archive
against the release SHA256SUMS, and only replaces the installed executable
after the checksum matches. It needs only sh, tar, curl/wget and a SHA256
tool -- no Bun, Node.js or npm.
The release job now checks out the repo, smoke tests install.sh against the
artifacts it just built by serving them locally, and attaches install.sh to
the GitHub Release.
Tests cover asset-name parity with releaseArchiveName, version/platform
validation, and end-to-end installs against a fake release server including
checksum mismatch, missing asset and missing SHA256SUMS failure paths.
---
.github/workflows/release.yml | 24 ++-
README.md | 38 ++++-
install.sh | 241 ++++++++++++++++++++++++++++
tests/install-script.test.ts | 292 ++++++++++++++++++++++++++++++++++
4 files changed, 589 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..4d8e92a 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,28 @@ 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 `routstrd` after the checksum matches.
+
+
+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 +63,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 +337,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..3612cac
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,241 @@
+#!/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
+MAX_ARCHIVE_BYTES=262144000
+
+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)
+ --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 "$INSTALL_DIR" ]; then
+ [ -n "${HOME:-}" ] || die "HOME is not set; pass --dir or set ROUTSTRD_INSTALL_DIR."
+ INSTALL_DIR="$HOME/.local/bin"
+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
+fi
+
+if [ -z "$ARCH" ]; then
+ case "$(uname -m)" in
+ x86_64|amd64) ARCH=x64 ;;
+ arm64|aarch64) ARCH=arm64 ;;
+ *) die "unsupported architecture '$(uname -m)'; releases cover x64 and arm64." ;;
+ esac
+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
+
+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() {
+ if [ "$HTTP_CLIENT" = curl ]; then
+ curl -fsSL -H "Accept: application/vnd.github+json" \
+ ${GITHUB_TOKEN:+-H "Authorization: Bearer ${GITHUB_TOKEN}"} "$1"
+ else
+ wget -qO- --header="Accept: application/vnd.github+json" \
+ ${GITHUB_TOKEN:+--header="Authorization: Bearer ${GITHUB_TOKEN}"} "$1"
+ fi
+}
+
+fetch_file() {
+ if [ "$HTTP_CLIENT" = curl ]; then
+ curl -fsSL -o "$2" "$1"
+ else
+ wget -qO "$2" "$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 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 'A-Z' 'a-z'
+ else
+ # shellcheck disable=SC2086
+ $SHA256_CMD "$1" | awk '{print $1}' | tr 'A-Z' 'a-z'
+ 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 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)."
+
+expected="$(grep -F "$ASSET" "$CHECKSUMS" 2>/dev/null \
+ | awk -v name="$ASSET" '$2 == name || $2 == "*" name { print $1 }' \
+ | head -n 1 \
+ | tr 'A-Z' 'a-z')"
+[ -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"
+# Rename over the target so a running daemon keeps its old inode.
+mv -f "$STAGED" "$TARGET" || die "could not install to ${TARGET}."
+STAGED=""
+
+installed_version="$("$TARGET" --version 2>/dev/null || true)"
+if [ "$installed_version" != "$VERSION" ]; then
+ say "warning: ${TARGET} reported version '${installed_version:-unknown}' instead of '${VERSION}'."
+fi
+
+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..221b84f
--- /dev/null
+++ b/tests/install-script.test.ts
@@ -0,0 +1,292 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { createHash } from "crypto";
+import { chmodSync, mkdtempSync, 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;
+};
+
+/**
+ * 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,
+ 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 });
+ 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",
+ });
+}
+
+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", () => {
+ 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 = runInstaller([
+ "--dir",
+ installDir,
+ "--api-base-url",
+ origin,
+ "--download-base-url",
+ `${origin}/dl`,
+ ]);
+
+ expect(result.stderr.toString()).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", () => {
+ 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 = runInstaller([
+ "--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", () => {
+ 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 = runInstaller([
+ "--dir",
+ installDir,
+ "--api-base-url",
+ origin,
+ "--download-base-url",
+ `${origin}/dl`,
+ ]);
+
+ expect(result.exitCode).not.toBe(0);
+ expect(result.stderr.toString()).toContain("checksum mismatch");
+ expect(() => statSync(join(installDir, "routstrd"))).toThrow();
+ });
+
+ test("reports a missing asset for the current platform", () => {
+ const dir = tempDir("routstrd-install-missing-");
+ const origin = serveFakeRelease({
+ asset: releaseArchiveName(VERSION, process.platform, process.arch),
+ omitAsset: true,
+ });
+
+ const result = runInstaller([
+ "--dir",
+ join(dir, "bin"),
+ "--api-base-url",
+ origin,
+ "--download-base-url",
+ `${origin}/dl`,
+ ]);
+
+ expect(result.exitCode).not.toBe(0);
+ expect(result.stderr.toString()).toContain("may not include a build");
+ });
+
+ test("reports a release without SHA256SUMS", () => {
+ 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 = runInstaller([
+ "--dir",
+ join(dir, "bin"),
+ "--api-base-url",
+ origin,
+ "--download-base-url",
+ `${origin}/dl`,
+ ]);
+
+ expect(result.exitCode).not.toBe(0);
+ expect(result.stderr.toString()).toContain("does not contain");
+ });
+
+ test("honours ROUTSTRD_INSTALL_DIR when --dir is absent", () => {
+ 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 = runInstaller(
+ [
+ "--api-base-url",
+ origin,
+ "--download-base-url",
+ `${origin}/dl`,
+ ],
+ { ROUTSTRD_INSTALL_DIR: installDir },
+ );
+
+ expect(result.exitCode).toBe(0);
+ const installed = join(installDir, "routstrd");
+ chmodSync(installed, 0o755);
+ expect(statSync(installed).isFile()).toBe(true);
+ });
+});