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);
+ });
+});