mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
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:
co-authored by
hzrd149, redshift <213178690+1ftredsh@users.noreply.github.com>
parent
2220e12c31
commit
d3749d4638
@@ -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}"
|
||||||
@@ -19,16 +19,29 @@ For team-based routing, see [routstrd-auth](https://github.com/Routstr/routstrd-
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- [Bun](https://bun.sh) runtime
|
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.
|
||||||
```sh
|
|
||||||
curl -fsSL https://bun.com/install | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Step 1: Install
|
### 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:**
|
**Global with bun:**
|
||||||
```sh
|
```sh
|
||||||
bun i -g routstrd
|
bun i -g routstrd
|
||||||
@@ -246,6 +259,33 @@ Run daemon:
|
|||||||
bun run start
|
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:
|
Typecheck:
|
||||||
```sh
|
```sh
|
||||||
bun run lint
|
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
|
makes live provider requests that may spend wallet funds, so it is intentionally
|
||||||
not part of `bun test`.
|
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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+8
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "routstrd",
|
"name": "routstrd",
|
||||||
"version": "0.4.8",
|
"version": "0.4.9",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
@@ -8,10 +8,14 @@
|
|||||||
"routstrd": "./dist/index.js"
|
"routstrd": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist/index.js",
|
||||||
|
"dist/daemon/index.js",
|
||||||
"src",
|
"src",
|
||||||
|
"!src/**/*.test.ts",
|
||||||
|
"!src/daemon/wallet/fixtures",
|
||||||
"README.md",
|
"README.md",
|
||||||
"SECURITY.md"
|
"SECURITY.md",
|
||||||
|
"SKILL.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun src/index.ts start",
|
"start": "bun src/index.ts start",
|
||||||
@@ -20,6 +24,7 @@
|
|||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"test": "bun test",
|
"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": "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"
|
"prepublishOnly": "bun run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tmpdir } from "os";
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import {
|
import {
|
||||||
collectRecentRequestsFromLines,
|
collectRecentRequestsFromLines,
|
||||||
|
getLivePidFileOwner,
|
||||||
initializeWallet,
|
initializeWallet,
|
||||||
parseStructuredLogLine,
|
parseStructuredLogLine,
|
||||||
} from "./cli";
|
} 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", () => {
|
describe("parseStructuredLogLine", () => {
|
||||||
test("extracts timestamp, level, request id, model, and routing flag", () => {
|
test("extracts timestamp, level, request id, model, and routing flag", () => {
|
||||||
const line =
|
const line =
|
||||||
|
|||||||
+88
-32
@@ -56,12 +56,17 @@ import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils
|
|||||||
import { generateSecretKey, nip19 } from "nostr-tools";
|
import { generateSecretKey, nip19 } from "nostr-tools";
|
||||||
import { generateMnemonic } from "@scure/bip39";
|
import { generateMnemonic } from "@scure/bip39";
|
||||||
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
||||||
import packageJson from "../package.json" with { type: "json" };
|
|
||||||
import {
|
import {
|
||||||
compareVersions,
|
compareVersions,
|
||||||
getGlobalPackageVersion,
|
getGlobalPackageVersion,
|
||||||
getLatestNpmVersion,
|
getLatestNpmVersion,
|
||||||
} from "./utils/update-checker.ts";
|
} from "./utils/update-checker.ts";
|
||||||
|
import { isStandaloneExecutable, pm2DaemonArgs } from "./runtime";
|
||||||
|
import { VERSION } from "./version";
|
||||||
|
import {
|
||||||
|
getLatestStandaloneRelease,
|
||||||
|
installStandaloneRelease,
|
||||||
|
} from "./utils/standalone-update";
|
||||||
|
|
||||||
type RoutstrModel = {
|
type RoutstrModel = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -170,9 +175,38 @@ export function initializeWallet(walletDir = defaultWalletDir()): void {
|
|||||||
console.log("IMPORTANT: Write down this mnemonic and keep it safe!");
|
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
|
* 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.
|
* roll back the update itself.
|
||||||
*
|
*
|
||||||
* Note: cocod is no longer a separate process — the wallet now runs
|
* Note: cocod is no longer a separate process — the wallet now runs
|
||||||
@@ -192,14 +226,22 @@ async function restartDaemonsAfterUpdate(): Promise<void> {
|
|||||||
if (!wasRunning) {
|
if (!wasRunning) {
|
||||||
console.log("\nroutstrd daemon was not running — skipping restart.");
|
console.log("\nroutstrd daemon was not running — skipping restart.");
|
||||||
} else {
|
} 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" });
|
await callDaemon("/stop", { method: "POST" });
|
||||||
|
|
||||||
// Wait for the old daemon to fully exit: it keeps serving ongoing
|
// Wait for the old daemon to fully exit: it keeps serving ongoing
|
||||||
// requests before it releases the wallet lock and the new daemon can
|
// requests before it releases the wallet lock and the new daemon can
|
||||||
// safely claim it.
|
// safely claim it.
|
||||||
await waitForDaemonToExit({ pidFilePath: walletPidPath() });
|
await waitForDaemonToExit({ pidFilePath });
|
||||||
console.log("routstrd daemon stopped.");
|
console.log("routstrd daemon stopped.");
|
||||||
|
|
||||||
await stopLegacyCocod();
|
await stopLegacyCocod();
|
||||||
@@ -318,12 +360,26 @@ async function initDaemon(integrationKey?: IntegrationKey): Promise<void> {
|
|||||||
program
|
program
|
||||||
.name("routstrd")
|
.name("routstrd")
|
||||||
.description("Routstr daemon - Manage routstr processes")
|
.description("Routstr daemon - Manage routstr processes")
|
||||||
.version(packageJson.version, "--version", "output the version number");
|
.version(VERSION, "--version", "output the version number");
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("update")
|
.command("update")
|
||||||
.description("Update routstrd to the latest version")
|
.description("Update routstrd to the latest version")
|
||||||
.action(async () => {
|
.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" }];
|
const packages = [{ name: "routstrd", label: "routstrd" }];
|
||||||
|
|
||||||
let updatedAny = false;
|
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
|
// Start - start the background daemon
|
||||||
program
|
program
|
||||||
.command("start")
|
.command("start")
|
||||||
@@ -2221,6 +2293,12 @@ serviceCmd
|
|||||||
try {
|
try {
|
||||||
execSync("pm2 -v", { stdio: "ignore" });
|
execSync("pm2 -v", { stdio: "ignore" });
|
||||||
} catch (e) {
|
} 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...");
|
console.log("PM2 not found. Installing PM2 globally with bun...");
|
||||||
try {
|
try {
|
||||||
execSync("bun install -g pm2", { stdio: "inherit" });
|
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...");
|
console.log("Starting routstrd via PM2...");
|
||||||
try {
|
try {
|
||||||
await stopLegacyCocod();
|
await stopLegacyCocod();
|
||||||
|
|
||||||
// Use --interpreter bun to ensure it runs with bun
|
const proc = Bun.spawn(["pm2", ...pm2DaemonArgs()], {
|
||||||
execSync(`pm2 start "${daemonPath}" --name routstrd --interpreter bun`, {
|
stdout: "inherit",
|
||||||
stdio: "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("\n✅ routstrd is now managed by PM2.");
|
||||||
console.log("\nTo ensure it starts on system reboot, run:");
|
console.log("\nTo ensure it starts on system reboot, run:");
|
||||||
@@ -2702,5 +2758,5 @@ program
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function cli(args: string[]) {
|
export function cli(args: string[]) {
|
||||||
program.parse(args);
|
return program.parseAsync(args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
export function parseArgs(argv: string[]): {
|
export function parseArgs(argv: string[]): {
|
||||||
port: number;
|
port: number | null;
|
||||||
host: string | null;
|
host: string | null;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
} {
|
} {
|
||||||
@@ -9,10 +9,9 @@ export function parseArgs(argv: string[]): {
|
|||||||
(arg) => arg === "--provider" || arg === "-p",
|
(arg) => arg === "--provider" || arg === "-p",
|
||||||
);
|
);
|
||||||
|
|
||||||
const port =
|
const portValue = portFlagIndex !== -1 ? argv[portFlagIndex + 1] : undefined;
|
||||||
portFlagIndex !== -1
|
const parsedPort = portValue ? Number.parseInt(portValue, 10) : Number.NaN;
|
||||||
? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10)
|
const port = Number.isInteger(parsedPort) ? parsedPort : null;
|
||||||
: 8008;
|
|
||||||
|
|
||||||
const hostValue =
|
const hostValue =
|
||||||
hostFlagIndex !== -1 ? argv[hostFlagIndex + 1] : undefined;
|
hostFlagIndex !== -1 ? argv[hostFlagIndex + 1] : undefined;
|
||||||
|
|||||||
+5
-6
@@ -74,9 +74,8 @@ import { installGlobalErrorHandlers } from "./fatal-error";
|
|||||||
// the process silently. Uncaught exceptions are fatal: the process state can
|
// 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
|
// no longer be trusted, so the daemon logs and exits for its supervisor to
|
||||||
// restart (see fatal-error.ts).
|
// restart (see fatal-error.ts).
|
||||||
installGlobalErrorHandlers();
|
export async function runDaemon(argv: string[] = process.argv): Promise<void> {
|
||||||
|
installGlobalErrorHandlers();
|
||||||
async function main(): Promise<void> {
|
|
||||||
// Install signal handlers before migration and wallet startup. If a signal
|
// Install signal handlers before migration and wallet startup. If a signal
|
||||||
// arrives before the full shutdown path is wired, process.exit() still runs
|
// arrives before the full shutdown path is wired, process.exit() still runs
|
||||||
// the synchronous PID-lock exit hooks registered by claimPidFile.
|
// the synchronous PID-lock exit hooks registered by claimPidFile.
|
||||||
@@ -89,10 +88,10 @@ async function main(): Promise<void> {
|
|||||||
process.once("SIGTERM", shutdownForSignal);
|
process.once("SIGTERM", shutdownForSignal);
|
||||||
|
|
||||||
startupProgress("Loading configuration...");
|
startupProgress("Loading configuration...");
|
||||||
const args = parseArgs(process.argv);
|
const args = parseArgs(argv);
|
||||||
const config = await loadDaemonConfig();
|
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 host = args.host || config.host || "127.0.0.1";
|
||||||
const provider = args.provider || config.provider;
|
const provider = args.provider || config.provider;
|
||||||
const requestResponseLogDir =
|
const requestResponseLogDir =
|
||||||
@@ -457,7 +456,7 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
main().catch((error) => {
|
runDaemon().catch((error) => {
|
||||||
logger.error("Failed to start Routstr daemon:", error);
|
logger.error("Failed to start Routstr daemon:", error);
|
||||||
// Also write to stderr so the spawning CLI can surface the real 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).
|
// (stdout/stderr are redirected to debug.log by start-daemon.ts).
|
||||||
|
|||||||
+9
-1
@@ -1,4 +1,12 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { cli } from "./cli";
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -9,7 +9,7 @@ import { logger } from "./utils/logger";
|
|||||||
import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
|
import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
|
||||||
import { withCrossProcessLock } from "./utils/process-lock";
|
import { withCrossProcessLock } from "./utils/process-lock";
|
||||||
import { urlHosts } from "./utils/daemon-client";
|
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 DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
|
||||||
const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`;
|
const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`;
|
||||||
@@ -152,17 +152,12 @@ async function startDaemonUnlocked(
|
|||||||
args.push("--provider", options.provider);
|
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)
|
const debugLogOffset = existsSync(DEBUG_LOG_PATH)
|
||||||
? fileSize(DEBUG_LOG_PATH)
|
? fileSize(DEBUG_LOG_PATH)
|
||||||
: 0;
|
: 0;
|
||||||
const debugLogFd = openSync(DEBUG_LOG_PATH, "a");
|
const debugLogFd = openSync(DEBUG_LOG_PATH, "a");
|
||||||
|
|
||||||
const proc = Bun.spawn(["bun", daemonScript, ...args], {
|
const proc = Bun.spawn(daemonSpawnCommand(args), {
|
||||||
stdout: debugLogFd,
|
stdout: debugLogFd,
|
||||||
stderr: debugLogFd,
|
stderr: debugLogFd,
|
||||||
stdin: "ignore",
|
stdin: "ignore",
|
||||||
|
|||||||
@@ -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
@@ -1,3 +1,7 @@
|
|||||||
|
import { isStandaloneExecutable } from "../runtime";
|
||||||
|
import { VERSION } from "../version";
|
||||||
|
import { getLatestStandaloneRelease } from "./standalone-update";
|
||||||
|
|
||||||
const NPM_REGISTRY = "https://registry.npmjs.org";
|
const NPM_REGISTRY = "https://registry.npmjs.org";
|
||||||
|
|
||||||
/** Packages that `routstrd update` manages. */
|
/** 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.
|
* Compare two semver version strings.
|
||||||
* Returns a positive number if `a` is newer, negative if `b` is newer,
|
* 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.
|
* 0 if equal, or null if either value is not parseable semver.
|
||||||
*/
|
*/
|
||||||
export function compareVersions(a: string, b: string): number | null {
|
export function compareVersions(a: string, b: string): number | null {
|
||||||
const parse = (v: string): [number, number, number] | null => {
|
const left = parseVersion(a);
|
||||||
const match = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
const right = parseVersion(b);
|
||||||
if (!match?.[1] || !match[2] || !match[3]) return null;
|
if (!left || !right) return null;
|
||||||
return [
|
|
||||||
parseInt(match[1], 10),
|
for (let index = 0; index < left.core.length; index++) {
|
||||||
parseInt(match[2], 10),
|
const difference = left.core[index]! - right.core[index]!;
|
||||||
parseInt(match[3], 10),
|
if (difference !== 0) return difference;
|
||||||
];
|
}
|
||||||
};
|
return comparePrerelease(left.prerelease, right.prerelease);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackageUpdate {
|
export interface PackageUpdate {
|
||||||
@@ -97,6 +136,30 @@ export interface UpdateCheckResult {
|
|||||||
* Returns a result with per-package details and an overall `hasUpdate` flag.
|
* Returns a result with per-package details and an overall `hasUpdate` flag.
|
||||||
*/
|
*/
|
||||||
export async function checkForUpdates(): Promise<UpdateCheckResult> {
|
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(
|
const packages = await Promise.all(
|
||||||
UPDATE_PACKAGES.map(async ({ name, label }) => {
|
UPDATE_PACKAGES.map(async ({ name, label }) => {
|
||||||
const [current, latest] = await Promise.all([
|
const [current, latest] = await Promise.all([
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import packageJson from "../package.json" with { type: "json" };
|
||||||
|
|
||||||
|
export const VERSION = packageJson.version;
|
||||||
@@ -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:");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user