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>
This commit is contained in:
hzrd149
2026-09-08 15:23:36 +00:00
committed by GitHub
co-authored by hzrd149, redshift <213178690+1ftredsh@users.noreply.github.com>
parent 2220e12c31
commit d3749d4638
18 changed files with 1039 additions and 77 deletions
+110
View File
@@ -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}"
+62 -5
View File
@@ -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
```
+8 -3
View File
@@ -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": {
+42
View File
@@ -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 =
+87 -31
View File
@@ -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,6 +175,35 @@ 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
@@ -192,14 +226,22 @@ async function restartDaemonsAfterUpdate(): Promise<void> {
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<void> {
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>", "Port to listen on")
.option("--host <host>", "Bind address")
.option("-p, --provider <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);
}
+12
View File
@@ -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);
});
});
+4 -5
View File
@@ -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;
+5 -6
View File
@@ -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<void> {
export async function runDaemon(argv: string[] = process.argv): Promise<void> {
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<void> {
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<void> {
}
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).
+9 -1
View File
@@ -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);
}
+55
View File
@@ -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,
];
}
+2 -7
View File
@@ -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",
+214
View File
@@ -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<Response> {
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<number>; kill(): void },
label: string,
): Promise<number> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
proc.exited,
new Promise<never>((_, 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<StandaloneRelease> {
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<void> {
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<void> {
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 });
}
}
+81 -18
View File
@@ -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<UpdateCheckResult> {
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([
+3
View File
@@ -0,0 +1,3 @@
import packageJson from "../package.json" with { type: "json" };
export const VERSION = packageJson.version;
+38
View File
@@ -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:");
});
});
+99
View File
@@ -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,
]);
});
});
+181
View File
@@ -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);
});
});
+26
View File
@@ -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();
});
});