From d3749d46383f98d4435ff26e09c4e836f4f66170 Mon Sep 17 00:00:00 2001 From: hzrd149 <8001706+hzrd149@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:23:36 -0500 Subject: [PATCH] feat: add standalone binary releases (#89) * feat: add standalone binary releases * fix: propagate async CLI failures * fix: detect older Bun standalone builds * fix: constrain npm package contents * fix: preserve npm daemon entrypoint * fix: compare prerelease versions correctly * fix: verify daemon ownership before update restart * docs: add release and PM2 migration guidance * ci: validate standalone builds before release * chore: prepare v0.4.9 release --------- Co-authored-by: hzrd149, redshift <213178690+1ftredsh@users.noreply.github.com> --- .github/workflows/release.yml | 110 +++++++++++++ README.md | 67 +++++++- package.json | 11 +- src/cli.test.ts | 42 +++++ src/cli.ts | 120 +++++++++++---- src/daemon/args.test.ts | 12 ++ src/daemon/args.ts | 9 +- src/daemon/index.ts | 11 +- src/index.ts | 10 +- src/runtime.ts | 55 +++++++ src/start-daemon.ts | 9 +- src/utils/standalone-update.ts | 214 ++++++++++++++++++++++++++ src/utils/update-checker.ts | 99 +++++++++--- src/version.ts | 3 + tests/daemon-command.test.ts | 38 +++++ tests/runtime.test.ts | 99 ++++++++++++ tests/utils/standalone-update.test.ts | 181 ++++++++++++++++++++++ tests/utils/update-checker.test.ts | 26 ++++ 18 files changed, 1039 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 src/daemon/args.test.ts create mode 100644 src/runtime.ts create mode 100644 src/utils/standalone-update.ts create mode 100644 src/version.ts create mode 100644 tests/daemon-command.test.ts create mode 100644 tests/runtime.test.ts create mode 100644 tests/utils/standalone-update.test.ts create mode 100644 tests/utils/update-checker.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..31259a5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,110 @@ +name: Release + +on: + pull_request: + workflow_dispatch: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - run: bun install --frozen-lockfile + - name: Verify tag version + if: startsWith(github.ref, 'refs/tags/v') + run: | + package_version="$(bun -p "require('./package.json').version")" + test "${GITHUB_REF_NAME}" = "v${package_version}" + - run: bun run lint + - run: bun test + + build: + needs: validate + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: bun-linux-x64 + platform: linux + arch: x64 + - runner: ubuntu-24.04-arm + target: bun-linux-arm64 + platform: linux + arch: arm64 + - runner: macos-15-intel + target: bun-darwin-x64 + platform: darwin + arch: x64 + - runner: macos-15 + target: bun-darwin-arm64 + platform: darwin + arch: arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - run: bun install --frozen-lockfile + - name: Build standalone executable + run: >- + bun build --compile + --target=${{ matrix.target }} + --no-compile-autoload-dotenv + --no-compile-autoload-bunfig + src/index.ts + --outfile=build/routstrd + - name: Smoke test executable + run: | + package_version="$(bun -p "require('./package.json').version")" + test "$(build/routstrd --version)" = "${package_version}" + build/routstrd --help >/dev/null + - name: Package executable + run: | + package_version="$(bun -p "require('./package.json').version")" + tar -C build -czf \ + "routstrd-v${package_version}-${{ matrix.platform }}-${{ matrix.arch }}.tar.gz" \ + routstrd + - uses: actions/upload-artifact@v4 + with: + name: routstrd-${{ matrix.platform }}-${{ matrix.arch }} + path: routstrd-*.tar.gz + if-no-files-found: error + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: routstrd-* + merge-multiple: true + - name: Create checksums + run: shasum -a 256 routstrd-*.tar.gz > SHA256SUMS + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create "${GITHUB_REF_NAME}" + routstrd-*.tar.gz SHA256SUMS + --repo "${GITHUB_REPOSITORY}" + --verify-tag + --generate-notes + --title "${GITHUB_REF_NAME}" diff --git a/README.md b/README.md index 85a08c0..0d490cb 100644 --- a/README.md +++ b/README.md @@ -19,16 +19,29 @@ For team-based routing, see [routstrd-auth](https://github.com/Routstr/routstrd- ## Requirements -- [Bun](https://bun.sh) runtime - -```sh -curl -fsSL https://bun.com/install | bash -``` +The standalone release does not require Bun, Node.js, or npm. Installing from +npm or running from source requires the [Bun](https://bun.sh) runtime. ## Installation ### Step 1: Install +**Standalone binary:** + +Download the archive for your operating system and architecture from the +[latest GitHub Release](https://github.com/Routstr/routstrd/releases/latest). +Release archives are available for Linux and macOS on x64 and arm64. + +```sh +grep "routstrd-v0.4.9-linux-x64.tar.gz" SHA256SUMS | shasum -a 256 -c - +tar -xzf routstrd-v0.4.9-linux-x64.tar.gz +mkdir -p "$HOME/.local/bin" +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`. + **Global with bun:** ```sh bun i -g routstrd @@ -246,6 +259,33 @@ Run daemon: bun run start ``` +Build a standalone executable for the current platform: + +```sh +bun run build:binary +./dist/routstrd --version +``` + +Standalone installations update directly from GitHub Releases with +`routstrd update`. npm installations continue to update through Bun. PM2 is an +optional external dependency used only by `routstrd service`; normal daemon +operation does not require it. + +When an update finds a process on the configured daemon port, it only stops that +process if the wallet PID file confirms a live daemon owned by the same routstrd +configuration. Otherwise the update remains installed, but automatic restart is +refused so an unrelated daemon is not interrupted. + +Existing PM2 registrations created by routstrd 0.4.x continue to work through a +compatibility daemon entrypoint. Recreate the registration to use the unified +CLI entrypoint and remove its legacy path dependency: + +```sh +routstrd service uninstall +routstrd service install +pm2 save +``` + Typecheck: ```sh bun run lint @@ -265,6 +305,23 @@ Set `ROUTSTRD_BASE_URL` to test a daemon at a different address. The script makes live provider requests that may spend wallet funds, so it is intentionally not part of `bun test`. +### Publishing a standalone release + +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. +4. In disposable environments for each platform, test `--version`, `--help`, + foreground startup failure, and background `start`, `status`, and `stop` + without Bun on `PATH`. +5. Test `routstrd service install` and restart behavior with PM2 in a disposable + environment. Never run release/update lifecycle tests against a production + daemon. When isolation is needed, use both a separate `ROUTSTRD_DIR` and a + non-production port in that configuration. + ## Project Structure ``` diff --git a/package.json b/package.json index 86801f3..2c9d130 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "routstrd", - "version": "0.4.8", + "version": "0.4.9", "module": "src/index.ts", "type": "module", "private": false, @@ -8,10 +8,14 @@ "routstrd": "./dist/index.js" }, "files": [ - "dist", + "dist/index.js", + "dist/daemon/index.js", "src", + "!src/**/*.test.ts", + "!src/daemon/wallet/fixtures", "README.md", - "SECURITY.md" + "SECURITY.md", + "SKILL.md" ], "scripts": { "start": "bun src/index.ts start", @@ -20,6 +24,7 @@ "lint": "tsc --noEmit", "test": "bun test", "build": "bun build src/index.ts --target=bun --outfile=dist/index.js --external better-sqlite3 && bun build src/daemon/index.ts --target=bun --outfile=dist/daemon/index.js --external better-sqlite3", + "build:binary": "bun build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig src/index.ts --outfile=dist/routstrd", "prepublishOnly": "bun run build" }, "devDependencies": { diff --git a/src/cli.test.ts b/src/cli.test.ts index 04eb309..9c194a4 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "os"; import { join } from "path"; import { collectRecentRequestsFromLines, + getLivePidFileOwner, initializeWallet, parseStructuredLogLine, } from "./cli"; @@ -26,6 +27,47 @@ afterEach(() => { } }); +describe("getLivePidFileOwner", () => { + test("returns a live owner from a valid PID file", () => { + expect( + getLivePidFileOwner("/wallet/wallet.pid", { + readFile: () => "4242\n", + isProcessRunning: (pid) => pid === 4242, + }), + ).toBe(4242); + }); + + test("rejects missing, malformed, dead, and non-positive owners", () => { + const running = () => true; + expect( + getLivePidFileOwner("/missing", { + readFile: () => { + throw new Error("ENOENT"); + }, + isProcessRunning: running, + }), + ).toBeNull(); + expect( + getLivePidFileOwner("/malformed", { + readFile: () => "not-a-pid", + isProcessRunning: running, + }), + ).toBeNull(); + expect( + getLivePidFileOwner("/zero", { + readFile: () => "0", + isProcessRunning: running, + }), + ).toBeNull(); + expect( + getLivePidFileOwner("/dead", { + readFile: () => "4242", + isProcessRunning: () => false, + }), + ).toBeNull(); + }); +}); + describe("parseStructuredLogLine", () => { test("extracts timestamp, level, request id, model, and routing flag", () => { const line = diff --git a/src/cli.ts b/src/cli.ts index 234c975..907accf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -56,12 +56,17 @@ import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils import { generateSecretKey, nip19 } from "nostr-tools"; import { generateMnemonic } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; -import packageJson from "../package.json" with { type: "json" }; import { compareVersions, getGlobalPackageVersion, getLatestNpmVersion, } from "./utils/update-checker.ts"; +import { isStandaloneExecutable, pm2DaemonArgs } from "./runtime"; +import { VERSION } from "./version"; +import { + getLatestStandaloneRelease, + installStandaloneRelease, +} from "./utils/standalone-update"; type RoutstrModel = { id: string; @@ -170,9 +175,38 @@ export function initializeWallet(walletDir = defaultWalletDir()): void { console.log("IMPORTANT: Write down this mnemonic and keep it safe!"); } +type PidFileDeps = { + readFile(path: string): string; + isProcessRunning(pid: number): boolean; +}; + +export function getLivePidFileOwner( + path: string, + deps: PidFileDeps = { + readFile: (pidPath) => readFileSync(pidPath, "utf8"), + isProcessRunning: (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } + }, + }, +): number | null { + try { + const contents = deps.readFile(path).trim(); + if (!/^\d+$/.test(contents)) return null; + const pid = Number.parseInt(contents, 10); + return pid > 0 && deps.isProcessRunning(pid) ? pid : null; + } catch { + return null; + } +} + /** * Restart the routstrd daemon after an update so the new binary takes - * effect immediately. Failures are collected and reported but never + * effect immediately. Failures are collected and reported but never * roll back the update itself. * * Note: cocod is no longer a separate process — the wallet now runs @@ -192,14 +226,22 @@ async function restartDaemonsAfterUpdate(): Promise { if (!wasRunning) { console.log("\nroutstrd daemon was not running — skipping restart."); } else { - console.log("\nRestarting routstrd daemon..."); + const pidFilePath = walletPidPath(); + const ownerPid = getLivePidFileOwner(pidFilePath); + if (ownerPid === null) { + throw new Error( + `Refusing to stop the process at ${getDaemonBaseUrl(config)} because ` + + `no live routstrd owner was found in ${pidFilePath}`, + ); + } + console.log(`\nRestarting routstrd daemon (PID: ${ownerPid})...`); await callDaemon("/stop", { method: "POST" }); // Wait for the old daemon to fully exit: it keeps serving ongoing // requests before it releases the wallet lock and the new daemon can // safely claim it. - await waitForDaemonToExit({ pidFilePath: walletPidPath() }); + await waitForDaemonToExit({ pidFilePath }); console.log("routstrd daemon stopped."); await stopLegacyCocod(); @@ -318,12 +360,26 @@ async function initDaemon(integrationKey?: IntegrationKey): Promise { program .name("routstrd") .description("Routstr daemon - Manage routstr processes") - .version(packageJson.version, "--version", "output the version number"); + .version(VERSION, "--version", "output the version number"); program .command("update") .description("Update routstrd to the latest version") .action(async () => { + if (isStandaloneExecutable()) { + const release = await getLatestStandaloneRelease(); + if ((compareVersions(VERSION, release.version) ?? -1) >= 0) { + console.log(`routstrd is already up to date (v${VERSION}).`); + return; + } + + console.log(`Updating routstrd from v${VERSION} to v${release.version}...`); + await installStandaloneRelease(release); + console.log("routstrd updated successfully.\n"); + await restartDaemonsAfterUpdate(); + return; + } + const packages = [{ name: "routstrd", label: "routstrd" }]; let updatedAny = false; @@ -665,6 +721,22 @@ program }, ); +program + .command("daemon") + .description("Run the daemon in the foreground") + .option("--port ", "Port to listen on") + .option("--host ", "Bind address") + .option("-p, --provider ", "Default provider to use") + .action(async (options: { port?: string; host?: string; provider?: string }) => { + await requireLocalDaemon(); + const argv = ["routstrd", "daemon"]; + if (options.port) argv.push("--port", options.port); + if (options.host) argv.push("--host", options.host); + if (options.provider) argv.push("--provider", options.provider); + const { runDaemon } = await import("./daemon/index"); + await runDaemon(argv); + }); + // Start - start the background daemon program .command("start") @@ -2221,6 +2293,12 @@ serviceCmd try { execSync("pm2 -v", { stdio: "ignore" }); } catch (e) { + if (isStandaloneExecutable()) { + console.error( + "PM2 is optional and is not bundled with routstrd. Install PM2 separately before using 'routstrd service install'.", + ); + process.exit(1); + } console.log("PM2 not found. Installing PM2 globally with bun..."); try { execSync("bun install -g pm2", { stdio: "inherit" }); @@ -2232,37 +2310,15 @@ serviceCmd } } - // 2. Resolve the path to the daemon - // In a global install, we want the bundled daemon in dist/daemon/index.js - let daemonPath: string; - try { - // Try to resolve relative to this file first (works in dev and global) - daemonPath = Bun.resolveSync("./daemon/index.js", import.meta.url); - } catch (e) { - // Fallback for some bundling scenarios - const path = require("path"); - daemonPath = path.join( - path.dirname(import.meta.url).replace("file://", ""), - "daemon", - "index.js", - ); - } - - if (!existsSync(daemonPath)) { - console.error( - `Could not find daemon at ${daemonPath}. Did you run 'bun run build'?`, - ); - process.exit(1); - } - console.log("Starting routstrd via PM2..."); try { await stopLegacyCocod(); - // Use --interpreter bun to ensure it runs with bun - execSync(`pm2 start "${daemonPath}" --name routstrd --interpreter bun`, { - stdio: "inherit", + const proc = Bun.spawn(["pm2", ...pm2DaemonArgs()], { + stdout: "inherit", + stderr: "inherit", }); + if ((await proc.exited) !== 0) throw new Error("PM2 exited with an error"); console.log("\nāœ… routstrd is now managed by PM2."); console.log("\nTo ensure it starts on system reboot, run:"); @@ -2702,5 +2758,5 @@ program ); export function cli(args: string[]) { - program.parse(args); + return program.parseAsync(args); } diff --git a/src/daemon/args.test.ts b/src/daemon/args.test.ts new file mode 100644 index 0000000..e8d6e8a --- /dev/null +++ b/src/daemon/args.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test"; +import { parseArgs } from "./args"; + +describe("parseArgs", () => { + test("leaves port unset when no CLI override is provided", () => { + expect(parseArgs(["routstrd", "daemon"]).port).toBeNull(); + }); + + test("parses an explicit port", () => { + expect(parseArgs(["routstrd", "daemon", "--port", "9000"]).port).toBe(9000); + }); +}); diff --git a/src/daemon/args.ts b/src/daemon/args.ts index 79edeb4..895eae9 100644 --- a/src/daemon/args.ts +++ b/src/daemon/args.ts @@ -1,5 +1,5 @@ export function parseArgs(argv: string[]): { - port: number; + port: number | null; host: string | null; provider: string | null; } { @@ -9,10 +9,9 @@ export function parseArgs(argv: string[]): { (arg) => arg === "--provider" || arg === "-p", ); - const port = - portFlagIndex !== -1 - ? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10) - : 8008; + const portValue = portFlagIndex !== -1 ? argv[portFlagIndex + 1] : undefined; + const parsedPort = portValue ? Number.parseInt(portValue, 10) : Number.NaN; + const port = Number.isInteger(parsedPort) ? parsedPort : null; const hostValue = hostFlagIndex !== -1 ? argv[hostFlagIndex + 1] : undefined; diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 81b77c7..410c48c 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -74,9 +74,8 @@ import { installGlobalErrorHandlers } from "./fatal-error"; // the process silently. Uncaught exceptions are fatal: the process state can // no longer be trusted, so the daemon logs and exits for its supervisor to // restart (see fatal-error.ts). -installGlobalErrorHandlers(); - -async function main(): Promise { +export async function runDaemon(argv: string[] = process.argv): Promise { + installGlobalErrorHandlers(); // Install signal handlers before migration and wallet startup. If a signal // arrives before the full shutdown path is wired, process.exit() still runs // the synchronous PID-lock exit hooks registered by claimPidFile. @@ -89,10 +88,10 @@ async function main(): Promise { process.once("SIGTERM", shutdownForSignal); startupProgress("Loading configuration..."); - const args = parseArgs(process.argv); + const args = parseArgs(argv); const config = await loadDaemonConfig(); - const port = args.port; + const port = args.port ?? config.port ?? 8008; const host = args.host || config.host || "127.0.0.1"; const provider = args.provider || config.provider; const requestResponseLogDir = @@ -457,7 +456,7 @@ async function main(): Promise { } if (import.meta.main) { - main().catch((error) => { + runDaemon().catch((error) => { logger.error("Failed to start Routstr daemon:", error); // Also write to stderr so the spawning CLI can surface the real error // (stdout/stderr are redirected to debug.log by start-daemon.ts). diff --git a/src/index.ts b/src/index.ts index 011b571..bb889a0 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,12 @@ #!/usr/bin/env bun import { cli } from "./cli"; -cli(process.argv); +try { + await cli(process.argv); +} catch (error) { + console.error( + "routstrd command failed:", + error instanceof Error ? error.message : error, + ); + process.exit(1); +} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..499987c --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,55 @@ +export const DAEMON_COMMAND = "daemon"; + +type BunRuntime = { + isStandaloneExecutable?: boolean; + main: string; +}; + +export function isStandaloneExecutable( + runtime: BunRuntime = Bun as unknown as BunRuntime, +): boolean { + return ( + runtime.isStandaloneExecutable === true || + runtime.main.startsWith("/$bunfs/") + ); +} + +type RuntimeExecutable = { + standalone: boolean; + execPath: string; + main: string; +}; + +export function daemonSpawnCommand( + args: string[], + runtime: RuntimeExecutable = { + standalone: isStandaloneExecutable(), + execPath: process.execPath, + main: Bun.main, + }, +): string[] { + return runtime.standalone + ? [runtime.execPath, DAEMON_COMMAND, ...args] + : [runtime.execPath, runtime.main, DAEMON_COMMAND, ...args]; +} + +export function pm2DaemonArgs( + runtime: RuntimeExecutable = { + standalone: isStandaloneExecutable(), + execPath: process.execPath, + main: Bun.main, + }, +): string[] { + const entrypoint = runtime.standalone ? runtime.execPath : runtime.main; + const interpreter = runtime.standalone ? "none" : runtime.execPath; + return [ + "start", + entrypoint, + "--name", + "routstrd", + "--interpreter", + interpreter, + "--", + DAEMON_COMMAND, + ]; +} diff --git a/src/start-daemon.ts b/src/start-daemon.ts index 662dd89..3416df7 100644 --- a/src/start-daemon.ts +++ b/src/start-daemon.ts @@ -9,7 +9,7 @@ import { logger } from "./utils/logger"; import { CONFIG_DIR, LOGS_DIR } from "./utils/config"; import { withCrossProcessLock } from "./utils/process-lock"; import { urlHosts } from "./utils/daemon-client"; -import { fileURLToPath } from "url"; +import { daemonSpawnCommand } from "./runtime"; const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`; const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`; @@ -152,17 +152,12 @@ async function startDaemonUnlocked( args.push("--provider", options.provider); } - let daemonScript = fileURLToPath(new URL("./daemon/index.js", import.meta.url)); - if (!existsSync(daemonScript)) { - daemonScript = fileURLToPath(new URL("./daemon/index.ts", import.meta.url)); - } - const debugLogOffset = existsSync(DEBUG_LOG_PATH) ? fileSize(DEBUG_LOG_PATH) : 0; const debugLogFd = openSync(DEBUG_LOG_PATH, "a"); - const proc = Bun.spawn(["bun", daemonScript, ...args], { + const proc = Bun.spawn(daemonSpawnCommand(args), { stdout: debugLogFd, stderr: debugLogFd, stdin: "ignore", diff --git a/src/utils/standalone-update.ts b/src/utils/standalone-update.ts new file mode 100644 index 0000000..bd4b494 --- /dev/null +++ b/src/utils/standalone-update.ts @@ -0,0 +1,214 @@ +import { createHash, randomBytes } from "crypto"; +import { + chmodSync, + constants, + copyFileSync, + mkdtempSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { basename, join } from "path"; + +const RELEASES_API = "https://api.github.com/repos/Routstr/routstrd/releases/latest"; +const FETCH_TIMEOUT_MS = 30_000; +const PROCESS_TIMEOUT_MS = 30_000; +const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024; +const MAX_CHECKSUM_BYTES = 1024 * 1024; + +type ReleaseAsset = { + name: string; + browser_download_url: string; +}; + +type GithubRelease = { + tag_name: string; + assets: ReleaseAsset[]; +}; + +export type StandaloneRelease = { + version: string; + archive: ReleaseAsset; + checksums: ReleaseAsset; +}; + +function normalizeVersion(version: string): string { + return version.replace(/^v/, ""); +} + +export function releaseArchiveName( + version: string, + platform: NodeJS.Platform, + arch: string, +): string { + if (platform !== "linux" && platform !== "darwin") { + throw new Error(`Standalone updates are not supported on ${platform}.`); + } + if (arch !== "x64" && arch !== "arm64") { + throw new Error(`Standalone updates are not supported on ${platform}-${arch}.`); + } + return `routstrd-v${normalizeVersion(version)}-${platform}-${arch}.tar.gz`; +} + +async function fetchOrThrow( + url: string, + fetchImpl: typeof fetch, + maxBytes = MAX_CHECKSUM_BYTES, +): Promise { + const response = await fetchImpl(url, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "routstrd", + }, + redirect: "follow", + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`Download failed (${response.status}) for ${url}`); + } + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + throw new Error(`Download is too large (${contentLength} bytes) for ${url}`); + } + return response; +} + +async function waitForExit( + proc: { exited: Promise; kill(): void }, + label: string, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + proc.exited, + new Promise((_, reject) => { + timeout = setTimeout(() => { + proc.kill(); + reject(new Error(`${label} timed out.`)); + }, PROCESS_TIMEOUT_MS); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +export async function getLatestStandaloneRelease( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, + fetchImpl: typeof fetch = fetch, +): Promise { + const response = await fetchOrThrow(RELEASES_API, fetchImpl); + const release = (await response.json()) as GithubRelease; + const version = normalizeVersion(release.tag_name || ""); + if (!/^\d+\.\d+\.\d+/.test(version)) { + throw new Error("The latest GitHub Release has an invalid version tag."); + } + + const archiveName = releaseArchiveName(version, platform, arch); + const archive = release.assets.find((asset) => asset.name === archiveName); + const checksums = release.assets.find((asset) => asset.name === "SHA256SUMS"); + if (!archive || !checksums) { + throw new Error(`GitHub Release v${version} is missing ${archiveName} or SHA256SUMS.`); + } + return { version, archive, checksums }; +} + +export function expectedChecksum(contents: string, filename: string): string { + for (const line of contents.split("\n")) { + const match = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (match?.[2] === filename) return match[1]!.toLowerCase(); + } + throw new Error(`SHA256SUMS does not contain ${filename}.`); +} + +export function sha256(bytes: ArrayBuffer | Uint8Array): string { + return createHash("sha256").update(new Uint8Array(bytes)).digest("hex"); +} + +async function verifyCandidate(path: string, version: string): Promise { + const proc = Bun.spawn([path, "--version"], { + stdout: "pipe", + stderr: "pipe", + }); + const [code, outputText, errorText] = await Promise.all([ + waitForExit(proc, "Downloaded binary validation"), + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const output = outputText.trim(); + const error = errorText.trim(); + if (code !== 0 || normalizeVersion(output) !== normalizeVersion(version)) { + throw new Error( + `Downloaded binary failed version validation.${error ? ` ${error}` : ""}`, + ); + } +} + +export async function installStandaloneRelease( + release: StandaloneRelease, + executablePath: string = process.execPath, + fetchImpl: typeof fetch = fetch, +): Promise { + const tempDir = mkdtempSync(join(tmpdir(), "routstrd-update-")); + const archivePath = join(tempDir, release.archive.name); + const stagedPath = `${executablePath}.update-${randomBytes(12).toString("hex")}`; + + try { + const [archiveResponse, checksumsResponse] = await Promise.all([ + fetchOrThrow( + release.archive.browser_download_url, + fetchImpl, + MAX_ARCHIVE_BYTES, + ), + fetchOrThrow(release.checksums.browser_download_url, fetchImpl), + ]); + const archiveBytes = await archiveResponse.arrayBuffer(); + if (archiveBytes.byteLength > MAX_ARCHIVE_BYTES) { + throw new Error(`Download is too large (${archiveBytes.byteLength} bytes).`); + } + const checksums = await checksumsResponse.text(); + if (Buffer.byteLength(checksums) > MAX_CHECKSUM_BYTES) { + throw new Error("SHA256SUMS is too large."); + } + const expected = expectedChecksum(checksums, release.archive.name); + const actual = sha256(archiveBytes); + if (actual !== expected) { + throw new Error( + `Checksum mismatch for ${release.archive.name}: expected ${expected}, got ${actual}.`, + ); + } + writeFileSync(archivePath, new Uint8Array(archiveBytes)); + + const extract = Bun.spawn(["tar", "-xzf", archivePath, "-C", tempDir], { + stdout: "ignore", + stderr: "pipe", + }); + const [extractCode, extractErrorText] = await Promise.all([ + waitForExit(extract, "Archive extraction"), + new Response(extract.stderr).text(), + ]); + const extractError = extractErrorText.trim(); + if (extractCode !== 0) { + throw new Error(`Could not extract ${release.archive.name}. ${extractError}`.trim()); + } + + const extractedPath = join(tempDir, "routstrd"); + const mode = statSync(executablePath).mode & 0o777; + copyFileSync(extractedPath, stagedPath, constants.COPYFILE_EXCL); + chmodSync(stagedPath, mode || 0o755); + await verifyCandidate(stagedPath, release.version); + renameSync(stagedPath, executablePath); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Could not update ${basename(executablePath)} in place. ${detail}`, + { cause: error }, + ); + } finally { + rmSync(stagedPath, { force: true }); + rmSync(tempDir, { recursive: true, force: true }); + } +} diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index 465415d..5ae0387 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -1,3 +1,7 @@ +import { isStandaloneExecutable } from "../runtime"; +import { VERSION } from "../version"; +import { getLatestStandaloneRelease } from "./standalone-update"; + const NPM_REGISTRY = "https://registry.npmjs.org"; /** Packages that `routstrd update` manages. */ @@ -53,30 +57,65 @@ export async function getGlobalPackageVersion( } } +type ParsedVersion = { + core: [number, number, number]; + prerelease: string[] | null; +}; + +function parseVersion(version: string): ParsedVersion | null { + const match = version.match( + /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/, + ); + if (!match?.[1] || !match[2] || !match[3]) return null; + return { + core: [ + Number.parseInt(match[1], 10), + Number.parseInt(match[2], 10), + Number.parseInt(match[3], 10), + ], + prerelease: match[4]?.split(".") ?? null, + }; +} + +function comparePrerelease(a: string[] | null, b: string[] | null): number { + if (!a && !b) return 0; + if (!a) return 1; + if (!b) return -1; + + for (let index = 0; index < Math.max(a.length, b.length); index++) { + const left = a[index]; + const right = b[index]; + if (left === undefined) return -1; + if (right === undefined) return 1; + if (left === right) continue; + + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + if (leftNumeric && rightNumeric) { + return Number.parseInt(left, 10) - Number.parseInt(right, 10); + } + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return left < right ? -1 : 1; + } + return 0; +} + /** * Compare two semver version strings. * Returns a positive number if `a` is newer, negative if `b` is newer, * 0 if equal, or null if either value is not parseable semver. */ export function compareVersions(a: string, b: string): number | null { - const parse = (v: string): [number, number, number] | null => { - const match = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/); - if (!match?.[1] || !match[2] || !match[3]) return null; - return [ - parseInt(match[1], 10), - parseInt(match[2], 10), - parseInt(match[3], 10), - ]; - }; - const va = parse(a); - const vb = parse(b); - if (!va || !vb) return null; - const [a1, a2, a3] = va; - const [b1, b2, b3] = vb; - if (a1 !== b1) return a1 - b1; - if (a2 !== b2) return a2 - b2; - if (a3 !== b3) return a3 - b3; - return 0; + const left = parseVersion(a); + const right = parseVersion(b); + if (!left || !right) return null; + + for (let index = 0; index < left.core.length; index++) { + const difference = left.core[index]! - right.core[index]!; + if (difference !== 0) return difference; + } + return comparePrerelease(left.prerelease, right.prerelease); } export interface PackageUpdate { @@ -97,6 +136,30 @@ export interface UpdateCheckResult { * Returns a result with per-package details and an overall `hasUpdate` flag. */ export async function checkForUpdates(): Promise { + if (isStandaloneExecutable()) { + let latest: string | null = null; + try { + latest = (await getLatestStandaloneRelease()).version; + } catch { + // Update checks are best-effort and must not disrupt the TUI. + } + const hasUpdate = !!( + latest && (compareVersions(VERSION, latest) ?? -1) < 0 + ); + return { + hasUpdate, + packages: [ + { + name: "routstrd", + label: "routstrd", + current: VERSION, + latest, + hasUpdate, + }, + ], + }; + } + const packages = await Promise.all( UPDATE_PACKAGES.map(async ({ name, label }) => { const [current, latest] = await Promise.all([ diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..1a27327 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,3 @@ +import packageJson from "../package.json" with { type: "json" }; + +export const VERSION = packageJson.version; diff --git a/tests/daemon-command.test.ts b/tests/daemon-command.test.ts new file mode 100644 index 0000000..16bbde5 --- /dev/null +++ b/tests/daemon-command.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const tempDirs: string[] = []; + +function freshConfigPath(): string { + const path = mkdtempSync(join(tmpdir(), "routstrd-daemon-command-test-")); + rmSync(path, { recursive: true }); + tempDirs.push(path); + return path; +} + +afterEach(() => { + for (const path of tempDirs.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("daemon command", () => { + test("reports startup failures on stderr and exits nonzero", async () => { + const entrypoint = join(import.meta.dir, "../src/index.ts"); + const proc = Bun.spawn([process.execPath, entrypoint, "daemon", "--port", "9999"], { + env: { ...process.env, ROUTSTRD_DIR: freshConfigPath() }, + stdout: "pipe", + stderr: "pipe", + }); + + const [exitCode, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stderr).text(), + ]); + + expect(exitCode).toBe(1); + expect(stderr).toContain("routstrd command failed:"); + }); +}); diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts new file mode 100644 index 0000000..a935ca8 --- /dev/null +++ b/tests/runtime.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { + DAEMON_COMMAND, + daemonSpawnCommand, + isStandaloneExecutable, + pm2DaemonArgs, +} from "../src/runtime"; + +describe("isStandaloneExecutable", () => { + test("uses Bun's explicit standalone flag when available", () => { + expect( + isStandaloneExecutable({ + isStandaloneExecutable: true, + main: "/workspace/index.ts", + }), + ).toBe(true); + }); + + test("recognizes the virtual bunfs entrypoint used by older Bun releases", () => { + expect(isStandaloneExecutable({ main: "/$bunfs/root/routstrd" })).toBe(true); + }); + + test("does not classify a normal Bun entrypoint as standalone", () => { + expect(isStandaloneExecutable({ main: "/workspace/src/index.ts" })).toBe(false); + }); +}); + +describe("daemonSpawnCommand", () => { + test("relaunches a standalone executable directly", () => { + expect( + daemonSpawnCommand(["--port", "9000"], { + standalone: true, + execPath: "/usr/local/bin/routstrd", + main: "/unused/index.ts", + }), + ).toEqual([ + "/usr/local/bin/routstrd", + DAEMON_COMMAND, + "--port", + "9000", + ]); + }); + + test("runs the entry script through Bun during development", () => { + expect( + daemonSpawnCommand(["--host", "127.0.0.1"], { + standalone: false, + execPath: "/opt/bun/bin/bun", + main: "/workspace/src/index.ts", + }), + ).toEqual([ + "/opt/bun/bin/bun", + "/workspace/src/index.ts", + DAEMON_COMMAND, + "--host", + "127.0.0.1", + ]); + }); +}); + +describe("pm2DaemonArgs", () => { + test("runs a standalone executable without an interpreter", () => { + expect( + pm2DaemonArgs({ + standalone: true, + execPath: "/usr/local/bin/routstrd", + main: "/unused/index.ts", + }), + ).toEqual([ + "start", + "/usr/local/bin/routstrd", + "--name", + "routstrd", + "--interpreter", + "none", + "--", + DAEMON_COMMAND, + ]); + }); + + test("uses Bun as the interpreter for a script install", () => { + expect( + pm2DaemonArgs({ + standalone: false, + execPath: "/opt/bun/bin/bun", + main: "/workspace/dist/index.js", + }), + ).toEqual([ + "start", + "/workspace/dist/index.js", + "--name", + "routstrd", + "--interpreter", + "/opt/bun/bin/bun", + "--", + DAEMON_COMMAND, + ]); + }); +}); diff --git a/tests/utils/standalone-update.test.ts b/tests/utils/standalone-update.test.ts new file mode 100644 index 0000000..31063f7 --- /dev/null +++ b/tests/utils/standalone-update.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + expectedChecksum, + getLatestStandaloneRelease, + installStandaloneRelease, + releaseArchiveName, + sha256, +} from "../../src/utils/standalone-update"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("releaseArchiveName", () => { + test("maps supported platforms and architectures", () => { + expect(releaseArchiveName("0.5.0", "linux", "x64")).toBe( + "routstrd-v0.5.0-linux-x64.tar.gz", + ); + expect(releaseArchiveName("v0.5.0", "darwin", "arm64")).toBe( + "routstrd-v0.5.0-darwin-arm64.tar.gz", + ); + }); + + test("rejects unsupported targets", () => { + expect(() => releaseArchiveName("0.5.0", "win32", "x64")).toThrow( + "not supported", + ); + expect(() => releaseArchiveName("0.5.0", "linux", "riscv64")).toThrow( + "not supported", + ); + }); +}); + +describe("release metadata", () => { + test("selects the matching archive and checksum asset", async () => { + const fetchImpl = (async () => + Response.json({ + tag_name: "v0.5.0", + assets: [ + { + name: "routstrd-v0.5.0-linux-x64.tar.gz", + browser_download_url: "https://example.com/routstrd.tar.gz", + }, + { + name: "SHA256SUMS", + browser_download_url: "https://example.com/SHA256SUMS", + }, + ], + })) as typeof fetch; + + const release = await getLatestStandaloneRelease("linux", "x64", fetchImpl); + + expect(release.version).toBe("0.5.0"); + expect(release.archive.name).toBe("routstrd-v0.5.0-linux-x64.tar.gz"); + expect(release.checksums.name).toBe("SHA256SUMS"); + }); + + test("rejects releases without the target artifact", async () => { + const fetchImpl = (async () => + Response.json({ tag_name: "v0.5.0", assets: [] })) as typeof fetch; + + expect( + getLatestStandaloneRelease("linux", "arm64", fetchImpl), + ).rejects.toThrow("missing"); + }); +}); + +describe("checksum verification", () => { + test("finds a named checksum", () => { + const hash = "a".repeat(64); + expect(expectedChecksum(`${hash} routstrd.tar.gz\n`, "routstrd.tar.gz")).toBe( + hash, + ); + }); + + test("hashes downloaded bytes", () => { + expect(sha256(new TextEncoder().encode("routstrd"))).toBe( + "328e1349fc24bab876a37f7eef3e7c95e9d06877867e21c6a9d5564b2cb3be05", + ); + }); +}); + +describe("installStandaloneRelease", () => { + test("validates and atomically replaces the current executable", async () => { + const dir = mkdtempSync(join(tmpdir(), "routstrd-updater-test-")); + tempDirs.push(dir); + const current = join(dir, "installed-routstrd"); + const archiveRoot = join(dir, "archive"); + const candidate = join(archiveRoot, "routstrd"); + const archivePath = join(dir, "routstrd-v0.5.0-linux-x64.tar.gz"); + mkdirSync(archiveRoot); + writeFileSync(current, "#!/bin/sh\necho 0.4.4\n"); + writeFileSync(candidate, "#!/bin/sh\necho 0.5.0\n"); + chmodSync(current, 0o755); + chmodSync(candidate, 0o755); + const tar = Bun.spawnSync([ + "tar", + "-C", + archiveRoot, + "-czf", + archivePath, + "routstrd", + ]); + expect(tar.exitCode).toBe(0); + + const archiveBytes = readFileSync(archivePath); + const checksum = sha256(archiveBytes); + const fetchImpl = (async (input: string | URL | Request) => { + const url = String(input); + if (url.endsWith("SHA256SUMS")) { + return new Response(`${checksum} ${releaseName}\n`); + } + return new Response(archiveBytes); + }) as typeof fetch; + const releaseName = "routstrd-v0.5.0-linux-x64.tar.gz"; + + await installStandaloneRelease( + { + version: "0.5.0", + archive: { name: releaseName, browser_download_url: "https://example/archive" }, + checksums: { + name: "SHA256SUMS", + browser_download_url: "https://example/SHA256SUMS", + }, + }, + current, + fetchImpl, + ); + + const version = Bun.spawnSync([current, "--version"]); + expect(version.exitCode).toBe(0); + expect(version.stdout.toString().trim()).toBe("0.5.0"); + }); + + test("leaves the installed executable untouched on checksum failure", async () => { + const dir = mkdtempSync(join(tmpdir(), "routstrd-updater-test-")); + tempDirs.push(dir); + const current = join(dir, "installed-routstrd"); + writeFileSync(current, "#!/bin/sh\necho 0.4.4\n"); + chmodSync(current, 0o755); + const original = readFileSync(current, "utf8"); + const fetchImpl = (async (input: string | URL | Request) => { + return String(input).endsWith("SHA256SUMS") + ? new Response(`${"0".repeat(64)} routstrd-v0.5.0-linux-x64.tar.gz\n`) + : new Response("not the expected archive"); + }) as typeof fetch; + + await expect( + installStandaloneRelease( + { + version: "0.5.0", + archive: { + name: "routstrd-v0.5.0-linux-x64.tar.gz", + browser_download_url: "https://example/archive", + }, + checksums: { + name: "SHA256SUMS", + browser_download_url: "https://example/SHA256SUMS", + }, + }, + current, + fetchImpl, + ), + ).rejects.toThrow("Checksum mismatch"); + expect(readFileSync(current, "utf8")).toBe(original); + }); +}); diff --git a/tests/utils/update-checker.test.ts b/tests/utils/update-checker.test.ts new file mode 100644 index 0000000..3ade556 --- /dev/null +++ b/tests/utils/update-checker.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { compareVersions } from "../../src/utils/update-checker"; + +describe("compareVersions", () => { + test("compares stable semantic versions", () => { + expect(compareVersions("0.5.1", "0.5.0")).toBeGreaterThan(0); + expect(compareVersions("v0.5.0", "0.5.0")).toBe(0); + expect(compareVersions("0.4.9", "0.5.0")).toBeLessThan(0); + }); + + test("orders prereleases before the corresponding stable release", () => { + expect(compareVersions("0.5.0-beta.1", "0.5.0")).toBeLessThan(0); + expect(compareVersions("0.5.0", "0.5.0-rc.1")).toBeGreaterThan(0); + }); + + test("uses semantic-version precedence for prerelease identifiers", () => { + expect(compareVersions("0.5.0-beta.2", "0.5.0-beta.11")).toBeLessThan(0); + expect(compareVersions("0.5.0-beta.11", "0.5.0-rc.1")).toBeLessThan(0); + expect(compareVersions("0.5.0-rc.1", "0.5.0-rc.1.1")).toBeLessThan(0); + }); + + test("ignores build metadata and rejects malformed versions", () => { + expect(compareVersions("0.5.0+darwin", "0.5.0+linux")).toBe(0); + expect(compareVersions("0.5.0garbage", "0.5.0")).toBeNull(); + }); +});