routstrd 0.4.8 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +124 -5
  2. package/SKILL.md +341 -0
  3. package/dist/daemon/index.js +7995 -14602
  4. package/dist/index.js +155916 -14225
  5. package/package.json +9 -4
  6. package/src/cli.ts +279 -110
  7. package/src/daemon/args.ts +4 -5
  8. package/src/daemon/http/index.ts +37 -2
  9. package/src/daemon/index.ts +94 -33
  10. package/src/daemon/models.ts +13 -10
  11. package/src/daemon/wallet/auto-refill.ts +1 -1
  12. package/src/daemon/wallet/coco-client.ts +205 -3
  13. package/src/daemon/wallet/cocod-client.ts +19 -3
  14. package/src/daemon/wallet/index.ts +1 -1
  15. package/src/index.ts +9 -1
  16. package/src/runtime.ts +55 -0
  17. package/src/start-daemon.ts +4 -8
  18. package/src/tui/usage/app.ts +16 -2
  19. package/src/tui/usage/data.ts +110 -0
  20. package/src/tui/usage/render.ts +45 -20
  21. package/src/utils/clients.ts +74 -1
  22. package/src/utils/config.ts +14 -0
  23. package/src/utils/daemon-stop.ts +125 -0
  24. package/src/utils/standalone-update.ts +214 -0
  25. package/src/utils/update-checker.ts +81 -18
  26. package/src/version.ts +3 -0
  27. package/src/cli.test.ts +0 -192
  28. package/src/daemon/fatal-error.test.ts +0 -104
  29. package/src/daemon/http/usage-summary.test.ts +0 -272
  30. package/src/daemon/models.test.ts +0 -81
  31. package/src/daemon/wallet/cleanup.test.ts +0 -168
  32. package/src/daemon/wallet/coco-client.npc.test.ts +0 -252
  33. package/src/daemon/wallet/coco-client.test.ts +0 -669
  34. package/src/daemon/wallet/diagnostics.test.ts +0 -378
  35. package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
  36. package/src/daemon/wallet/migration.test.ts +0 -171
  37. package/src/daemon/wallet/receive-dedup.test.ts +0 -238
@@ -0,0 +1,214 @@
1
+ import { createHash, randomBytes } from "crypto";
2
+ import {
3
+ chmodSync,
4
+ constants,
5
+ copyFileSync,
6
+ mkdtempSync,
7
+ renameSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "fs";
12
+ import { tmpdir } from "os";
13
+ import { basename, join } from "path";
14
+
15
+ const RELEASES_API = "https://api.github.com/repos/Routstr/routstrd/releases/latest";
16
+ const FETCH_TIMEOUT_MS = 30_000;
17
+ const PROCESS_TIMEOUT_MS = 30_000;
18
+ const MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
19
+ const MAX_CHECKSUM_BYTES = 1024 * 1024;
20
+
21
+ type ReleaseAsset = {
22
+ name: string;
23
+ browser_download_url: string;
24
+ };
25
+
26
+ type GithubRelease = {
27
+ tag_name: string;
28
+ assets: ReleaseAsset[];
29
+ };
30
+
31
+ export type StandaloneRelease = {
32
+ version: string;
33
+ archive: ReleaseAsset;
34
+ checksums: ReleaseAsset;
35
+ };
36
+
37
+ function normalizeVersion(version: string): string {
38
+ return version.replace(/^v/, "");
39
+ }
40
+
41
+ export function releaseArchiveName(
42
+ version: string,
43
+ platform: NodeJS.Platform,
44
+ arch: string,
45
+ ): string {
46
+ if (platform !== "linux" && platform !== "darwin") {
47
+ throw new Error(`Standalone updates are not supported on ${platform}.`);
48
+ }
49
+ if (arch !== "x64" && arch !== "arm64") {
50
+ throw new Error(`Standalone updates are not supported on ${platform}-${arch}.`);
51
+ }
52
+ return `routstrd-v${normalizeVersion(version)}-${platform}-${arch}.tar.gz`;
53
+ }
54
+
55
+ async function fetchOrThrow(
56
+ url: string,
57
+ fetchImpl: typeof fetch,
58
+ maxBytes = MAX_CHECKSUM_BYTES,
59
+ ): Promise<Response> {
60
+ const response = await fetchImpl(url, {
61
+ headers: {
62
+ Accept: "application/vnd.github+json",
63
+ "User-Agent": "routstrd",
64
+ },
65
+ redirect: "follow",
66
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
67
+ });
68
+ if (!response.ok) {
69
+ throw new Error(`Download failed (${response.status}) for ${url}`);
70
+ }
71
+ const contentLength = Number(response.headers.get("content-length"));
72
+ if (Number.isFinite(contentLength) && contentLength > maxBytes) {
73
+ throw new Error(`Download is too large (${contentLength} bytes) for ${url}`);
74
+ }
75
+ return response;
76
+ }
77
+
78
+ async function waitForExit(
79
+ proc: { exited: Promise<number>; kill(): void },
80
+ label: string,
81
+ ): Promise<number> {
82
+ let timeout: ReturnType<typeof setTimeout> | undefined;
83
+ try {
84
+ return await Promise.race([
85
+ proc.exited,
86
+ new Promise<never>((_, reject) => {
87
+ timeout = setTimeout(() => {
88
+ proc.kill();
89
+ reject(new Error(`${label} timed out.`));
90
+ }, PROCESS_TIMEOUT_MS);
91
+ }),
92
+ ]);
93
+ } finally {
94
+ if (timeout) clearTimeout(timeout);
95
+ }
96
+ }
97
+
98
+ export async function getLatestStandaloneRelease(
99
+ platform: NodeJS.Platform = process.platform,
100
+ arch: string = process.arch,
101
+ fetchImpl: typeof fetch = fetch,
102
+ ): Promise<StandaloneRelease> {
103
+ const response = await fetchOrThrow(RELEASES_API, fetchImpl);
104
+ const release = (await response.json()) as GithubRelease;
105
+ const version = normalizeVersion(release.tag_name || "");
106
+ if (!/^\d+\.\d+\.\d+/.test(version)) {
107
+ throw new Error("The latest GitHub Release has an invalid version tag.");
108
+ }
109
+
110
+ const archiveName = releaseArchiveName(version, platform, arch);
111
+ const archive = release.assets.find((asset) => asset.name === archiveName);
112
+ const checksums = release.assets.find((asset) => asset.name === "SHA256SUMS");
113
+ if (!archive || !checksums) {
114
+ throw new Error(`GitHub Release v${version} is missing ${archiveName} or SHA256SUMS.`);
115
+ }
116
+ return { version, archive, checksums };
117
+ }
118
+
119
+ export function expectedChecksum(contents: string, filename: string): string {
120
+ for (const line of contents.split("\n")) {
121
+ const match = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
122
+ if (match?.[2] === filename) return match[1]!.toLowerCase();
123
+ }
124
+ throw new Error(`SHA256SUMS does not contain ${filename}.`);
125
+ }
126
+
127
+ export function sha256(bytes: ArrayBuffer | Uint8Array): string {
128
+ return createHash("sha256").update(new Uint8Array(bytes)).digest("hex");
129
+ }
130
+
131
+ async function verifyCandidate(path: string, version: string): Promise<void> {
132
+ const proc = Bun.spawn([path, "--version"], {
133
+ stdout: "pipe",
134
+ stderr: "pipe",
135
+ });
136
+ const [code, outputText, errorText] = await Promise.all([
137
+ waitForExit(proc, "Downloaded binary validation"),
138
+ new Response(proc.stdout).text(),
139
+ new Response(proc.stderr).text(),
140
+ ]);
141
+ const output = outputText.trim();
142
+ const error = errorText.trim();
143
+ if (code !== 0 || normalizeVersion(output) !== normalizeVersion(version)) {
144
+ throw new Error(
145
+ `Downloaded binary failed version validation.${error ? ` ${error}` : ""}`,
146
+ );
147
+ }
148
+ }
149
+
150
+ export async function installStandaloneRelease(
151
+ release: StandaloneRelease,
152
+ executablePath: string = process.execPath,
153
+ fetchImpl: typeof fetch = fetch,
154
+ ): Promise<void> {
155
+ const tempDir = mkdtempSync(join(tmpdir(), "routstrd-update-"));
156
+ const archivePath = join(tempDir, release.archive.name);
157
+ const stagedPath = `${executablePath}.update-${randomBytes(12).toString("hex")}`;
158
+
159
+ try {
160
+ const [archiveResponse, checksumsResponse] = await Promise.all([
161
+ fetchOrThrow(
162
+ release.archive.browser_download_url,
163
+ fetchImpl,
164
+ MAX_ARCHIVE_BYTES,
165
+ ),
166
+ fetchOrThrow(release.checksums.browser_download_url, fetchImpl),
167
+ ]);
168
+ const archiveBytes = await archiveResponse.arrayBuffer();
169
+ if (archiveBytes.byteLength > MAX_ARCHIVE_BYTES) {
170
+ throw new Error(`Download is too large (${archiveBytes.byteLength} bytes).`);
171
+ }
172
+ const checksums = await checksumsResponse.text();
173
+ if (Buffer.byteLength(checksums) > MAX_CHECKSUM_BYTES) {
174
+ throw new Error("SHA256SUMS is too large.");
175
+ }
176
+ const expected = expectedChecksum(checksums, release.archive.name);
177
+ const actual = sha256(archiveBytes);
178
+ if (actual !== expected) {
179
+ throw new Error(
180
+ `Checksum mismatch for ${release.archive.name}: expected ${expected}, got ${actual}.`,
181
+ );
182
+ }
183
+ writeFileSync(archivePath, new Uint8Array(archiveBytes));
184
+
185
+ const extract = Bun.spawn(["tar", "-xzf", archivePath, "-C", tempDir], {
186
+ stdout: "ignore",
187
+ stderr: "pipe",
188
+ });
189
+ const [extractCode, extractErrorText] = await Promise.all([
190
+ waitForExit(extract, "Archive extraction"),
191
+ new Response(extract.stderr).text(),
192
+ ]);
193
+ const extractError = extractErrorText.trim();
194
+ if (extractCode !== 0) {
195
+ throw new Error(`Could not extract ${release.archive.name}. ${extractError}`.trim());
196
+ }
197
+
198
+ const extractedPath = join(tempDir, "routstrd");
199
+ const mode = statSync(executablePath).mode & 0o777;
200
+ copyFileSync(extractedPath, stagedPath, constants.COPYFILE_EXCL);
201
+ chmodSync(stagedPath, mode || 0o755);
202
+ await verifyCandidate(stagedPath, release.version);
203
+ renameSync(stagedPath, executablePath);
204
+ } catch (error) {
205
+ const detail = error instanceof Error ? error.message : String(error);
206
+ throw new Error(
207
+ `Could not update ${basename(executablePath)} in place. ${detail}`,
208
+ { cause: error },
209
+ );
210
+ } finally {
211
+ rmSync(stagedPath, { force: true });
212
+ rmSync(tempDir, { recursive: true, force: true });
213
+ }
214
+ }
@@ -1,3 +1,7 @@
1
+ import { isStandaloneExecutable } from "../runtime";
2
+ import { VERSION } from "../version";
3
+ import { getLatestStandaloneRelease } from "./standalone-update";
4
+
1
5
  const NPM_REGISTRY = "https://registry.npmjs.org";
2
6
 
3
7
  /** Packages that `routstrd update` manages. */
@@ -53,30 +57,65 @@ export async function getGlobalPackageVersion(
53
57
  }
54
58
  }
55
59
 
60
+ type ParsedVersion = {
61
+ core: [number, number, number];
62
+ prerelease: string[] | null;
63
+ };
64
+
65
+ function parseVersion(version: string): ParsedVersion | null {
66
+ const match = version.match(
67
+ /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/,
68
+ );
69
+ if (!match?.[1] || !match[2] || !match[3]) return null;
70
+ return {
71
+ core: [
72
+ Number.parseInt(match[1], 10),
73
+ Number.parseInt(match[2], 10),
74
+ Number.parseInt(match[3], 10),
75
+ ],
76
+ prerelease: match[4]?.split(".") ?? null,
77
+ };
78
+ }
79
+
80
+ function comparePrerelease(a: string[] | null, b: string[] | null): number {
81
+ if (!a && !b) return 0;
82
+ if (!a) return 1;
83
+ if (!b) return -1;
84
+
85
+ for (let index = 0; index < Math.max(a.length, b.length); index++) {
86
+ const left = a[index];
87
+ const right = b[index];
88
+ if (left === undefined) return -1;
89
+ if (right === undefined) return 1;
90
+ if (left === right) continue;
91
+
92
+ const leftNumeric = /^\d+$/.test(left);
93
+ const rightNumeric = /^\d+$/.test(right);
94
+ if (leftNumeric && rightNumeric) {
95
+ return Number.parseInt(left, 10) - Number.parseInt(right, 10);
96
+ }
97
+ if (leftNumeric) return -1;
98
+ if (rightNumeric) return 1;
99
+ return left < right ? -1 : 1;
100
+ }
101
+ return 0;
102
+ }
103
+
56
104
  /**
57
105
  * Compare two semver version strings.
58
106
  * Returns a positive number if `a` is newer, negative if `b` is newer,
59
107
  * 0 if equal, or null if either value is not parseable semver.
60
108
  */
61
109
  export function compareVersions(a: string, b: string): number | null {
62
- const parse = (v: string): [number, number, number] | null => {
63
- const match = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
64
- if (!match?.[1] || !match[2] || !match[3]) return null;
65
- return [
66
- parseInt(match[1], 10),
67
- parseInt(match[2], 10),
68
- parseInt(match[3], 10),
69
- ];
70
- };
71
- const va = parse(a);
72
- const vb = parse(b);
73
- if (!va || !vb) return null;
74
- const [a1, a2, a3] = va;
75
- const [b1, b2, b3] = vb;
76
- if (a1 !== b1) return a1 - b1;
77
- if (a2 !== b2) return a2 - b2;
78
- if (a3 !== b3) return a3 - b3;
79
- return 0;
110
+ const left = parseVersion(a);
111
+ const right = parseVersion(b);
112
+ if (!left || !right) return null;
113
+
114
+ for (let index = 0; index < left.core.length; index++) {
115
+ const difference = left.core[index]! - right.core[index]!;
116
+ if (difference !== 0) return difference;
117
+ }
118
+ return comparePrerelease(left.prerelease, right.prerelease);
80
119
  }
81
120
 
82
121
  export interface PackageUpdate {
@@ -97,6 +136,30 @@ export interface UpdateCheckResult {
97
136
  * Returns a result with per-package details and an overall `hasUpdate` flag.
98
137
  */
99
138
  export async function checkForUpdates(): Promise<UpdateCheckResult> {
139
+ if (isStandaloneExecutable()) {
140
+ let latest: string | null = null;
141
+ try {
142
+ latest = (await getLatestStandaloneRelease()).version;
143
+ } catch {
144
+ // Update checks are best-effort and must not disrupt the TUI.
145
+ }
146
+ const hasUpdate = !!(
147
+ latest && (compareVersions(VERSION, latest) ?? -1) < 0
148
+ );
149
+ return {
150
+ hasUpdate,
151
+ packages: [
152
+ {
153
+ name: "routstrd",
154
+ label: "routstrd",
155
+ current: VERSION,
156
+ latest,
157
+ hasUpdate,
158
+ },
159
+ ],
160
+ };
161
+ }
162
+
100
163
  const packages = await Promise.all(
101
164
  UPDATE_PACKAGES.map(async ({ name, label }) => {
102
165
  const [current, latest] = await Promise.all([
package/src/version.ts ADDED
@@ -0,0 +1,3 @@
1
+ import packageJson from "../package.json" with { type: "json" };
2
+
3
+ export const VERSION = packageJson.version;
package/src/cli.test.ts DELETED
@@ -1,192 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
3
- import { tmpdir } from "os";
4
- import { join } from "path";
5
- import {
6
- collectRecentRequestsFromLines,
7
- initializeWallet,
8
- parseStructuredLogLine,
9
- } from "./cli";
10
-
11
- const tempDirs: string[] = [];
12
-
13
- function makeTempDir(): string {
14
- const dir = mkdtempSync(join(tmpdir(), "routstrd-wallet-test-"));
15
- tempDirs.push(dir);
16
- return dir;
17
- }
18
-
19
- function permissions(path: string): number {
20
- return statSync(path).mode & 0o777;
21
- }
22
-
23
- afterEach(() => {
24
- for (const dir of tempDirs.splice(0)) {
25
- rmSync(dir, { recursive: true, force: true });
26
- }
27
- });
28
-
29
- describe("parseStructuredLogLine", () => {
30
- test("extracts timestamp, level, request id, model, and routing flag", () => {
31
- const line =
32
- "[2026-08-27T13:00:00.000Z] [INFO] [req:a1b2c3d4] [model:gpt-5] Routing request with path: /v1/chat/completions";
33
-
34
- expect(parseStructuredLogLine(line)).toEqual({
35
- timestamp: "2026-08-27T13:00:00.000Z",
36
- level: "INFO",
37
- requestId: "a1b2c3d4",
38
- modelId: "gpt-5",
39
- isRouting: true,
40
- });
41
- });
42
-
43
- test("parses SDK child lines without marking them as routing lines", () => {
44
- const line =
45
- "[2026-08-27T13:00:01.000Z] [DEBUG] [req:a1b2c3d4] [model:gpt-5] [BalanceManager] token stuff";
46
-
47
- expect(parseStructuredLogLine(line)).toEqual({
48
- timestamp: "2026-08-27T13:00:01.000Z",
49
- level: "DEBUG",
50
- requestId: "a1b2c3d4",
51
- modelId: "gpt-5",
52
- isRouting: false,
53
- });
54
- });
55
-
56
- test("handles legacy lines that have a request id but no model tag", () => {
57
- const line =
58
- "[2026-08-27T13:00:02.000Z] [INFO] [req:deadbeef] Routing request with path: /v1/responses";
59
-
60
- expect(parseStructuredLogLine(line)).toEqual({
61
- timestamp: "2026-08-27T13:00:02.000Z",
62
- level: "INFO",
63
- requestId: "deadbeef",
64
- modelId: undefined,
65
- isRouting: true,
66
- });
67
- });
68
-
69
- test("extracts the bare request id from colon-joined legacy child tags", () => {
70
- const line =
71
- "[2026-08-27T13:00:04.000Z] [INFO] [req:deadbeef:RoutstrClient] [RoutstrClient] generic request pricing input {\"modelId\":\"deepseek-v4-flash-0731\"}";
72
-
73
- const parsed = parseStructuredLogLine(line);
74
- expect(parsed.requestId).toBe("deadbeef");
75
- expect(parsed.modelId).toBe("deepseek-v4-flash-0731");
76
- expect(parsed.isRouting).toBe(false);
77
- });
78
-
79
- test("falls back to the JSON modelId when no model tag is present", () => {
80
- const line =
81
- "[2026-08-27T13:00:05.000Z] [INFO] [req:deadbeef:RoutstrClient] [RoutstrClient] generic request pricing input {\"modelId\":\"deepseek-v4-pro-0813\",\"messageCount\":121}";
82
-
83
- expect(parseStructuredLogLine(line).modelId).toBe("deepseek-v4-pro-0813");
84
- });
85
-
86
- test("returns an empty record for plain non-request lines", () => {
87
- const line = "[2026-08-27T13:00:03.000Z] [WARN] Some startup message";
88
-
89
- expect(parseStructuredLogLine(line)).toEqual({
90
- timestamp: "2026-08-27T13:00:03.000Z",
91
- level: "WARN",
92
- requestId: undefined,
93
- modelId: undefined,
94
- isRouting: false,
95
- });
96
- });
97
- });
98
-
99
- describe("collectRecentRequestsFromLines", () => {
100
- const lines = [
101
- "[2026-08-27T13:00:00.000Z] [INFO] [req:11111111] [model:gpt-5] Routing request with path: /a",
102
- "[2026-08-27T13:00:01.000Z] [DEBUG] [req:11111111] [model:gpt-5] [BalanceManager] token",
103
- "[2026-08-27T13:00:02.000Z] [INFO] [req:22222222] [model:claude-sonnet-4.5] Routing request with path: /b",
104
- "[2026-08-27T13:00:03.000Z] [DEBUG] [req:22222222] [model:claude-sonnet-4.5] [ModelManager] refresh",
105
- ];
106
-
107
- test("returns one record per request, newest first", () => {
108
- expect(collectRecentRequestsFromLines(lines, 10)).toEqual([
109
- {
110
- requestId: "22222222",
111
- modelId: "claude-sonnet-4.5",
112
- timestamp: "2026-08-27T13:00:02.000Z",
113
- },
114
- {
115
- requestId: "11111111",
116
- modelId: "gpt-5",
117
- timestamp: "2026-08-27T13:00:00.000Z",
118
- },
119
- ]);
120
- });
121
-
122
- test("respects the limit", () => {
123
- expect(collectRecentRequestsFromLines(lines, 1)).toEqual([
124
- {
125
- requestId: "22222222",
126
- modelId: "claude-sonnet-4.5",
127
- timestamp: "2026-08-27T13:00:02.000Z",
128
- },
129
- ]);
130
- });
131
-
132
- test("defaults the model to unknown for legacy routing lines", () => {
133
- const legacy = [
134
- "[2026-08-27T13:00:00.000Z] [INFO] [req:deadbeef] Routing request with path: /a",
135
- ];
136
-
137
- expect(collectRecentRequestsFromLines(legacy, 10)).toEqual([
138
- {
139
- requestId: "deadbeef",
140
- modelId: "unknown",
141
- timestamp: "2026-08-27T13:00:00.000Z",
142
- },
143
- ]);
144
- });
145
-
146
- test("enriches the model from a sibling pricing line in legacy logs", () => {
147
- const legacy = [
148
- "[2026-08-27T13:00:00.000Z] [INFO] [req:deadbeef] Routing request with path: /a",
149
- "[2026-08-27T13:00:01.000Z] [INFO] [req:deadbeef:RoutstrClient] [RoutstrClient] generic request pricing input {\"modelId\":\"deepseek-v4-flash-0731\"}",
150
- ];
151
-
152
- expect(collectRecentRequestsFromLines(legacy, 10)).toEqual([
153
- {
154
- requestId: "deadbeef",
155
- modelId: "deepseek-v4-flash-0731",
156
- timestamp: "2026-08-27T13:00:00.000Z",
157
- },
158
- ]);
159
- });
160
- });
161
-
162
- describe("initializeWallet", () => {
163
- test("creates the wallet directory and config with restrictive permissions", () => {
164
- const walletDir = join(makeTempDir(), "wallet");
165
-
166
- initializeWallet(walletDir);
167
-
168
- const walletConfig = join(walletDir, "config.json");
169
- expect(permissions(walletDir)).toBe(0o700);
170
- expect(permissions(walletConfig)).toBe(0o600);
171
-
172
- const config = JSON.parse(readFileSync(walletConfig, "utf8"));
173
- expect(config.encrypted).toBe(false);
174
- expect(typeof config.mnemonic).toBe("string");
175
- expect(config.mnemonic.length).toBeGreaterThan(0);
176
- });
177
-
178
- test("repairs permissions without replacing an existing wallet", () => {
179
- const walletDir = join(makeTempDir(), "wallet");
180
- const walletConfig = join(walletDir, "config.json");
181
- const existingConfig = JSON.stringify({ mnemonic: "existing seed" });
182
-
183
- mkdirSync(walletDir, { mode: 0o755 });
184
- writeFileSync(walletConfig, existingConfig, { mode: 0o644 });
185
-
186
- initializeWallet(walletDir);
187
-
188
- expect(readFileSync(walletConfig, "utf8")).toBe(existingConfig);
189
- expect(permissions(walletDir)).toBe(0o700);
190
- expect(permissions(walletConfig)).toBe(0o600);
191
- });
192
- });
@@ -1,104 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { unlinkSync } from "fs";
3
- import { tmpdir } from "os";
4
- import { join } from "path";
5
- import { exitOnUncaughtException } from "./fatal-error";
6
-
7
- const fatalErrorModule = JSON.stringify(join(import.meta.dir, "fatal-error.ts"));
8
-
9
- const fixturePaths: string[] = [];
10
-
11
- afterEach(() => {
12
- while (fixturePaths.length > 0) {
13
- try {
14
- unlinkSync(fixturePaths.pop()!);
15
- } catch {
16
- // Best effort cleanup of temp fixtures.
17
- }
18
- }
19
- });
20
-
21
- /** Run a fixture script in a real subprocess so process.exit is for real. */
22
- async function runFixture(source: string): Promise<{
23
- exitCode: number;
24
- stdout: string;
25
- stderr: string;
26
- }> {
27
- const scriptPath = join(
28
- tmpdir(),
29
- `routstrd-fatal-error-fixture-${crypto.randomUUID()}.ts`,
30
- );
31
- fixturePaths.push(scriptPath);
32
- await Bun.write(scriptPath, source);
33
- const proc = Bun.spawn(["bun", scriptPath], {
34
- stdout: "pipe",
35
- stderr: "pipe",
36
- });
37
- const [stdout, stderr] = await Promise.all([
38
- new Response(proc.stdout).text(),
39
- new Response(proc.stderr).text(),
40
- ]);
41
- const exitCode = await proc.exited;
42
- return { exitCode, stdout, stderr };
43
- }
44
-
45
- describe("exitOnUncaughtException", () => {
46
- test("reports the error and exits with code 1", () => {
47
- let exitCode: number | undefined;
48
- const stderrLines: string[] = [];
49
- const originalConsoleError = console.error;
50
- console.error = (...args: unknown[]) => {
51
- stderrLines.push(args.map(String).join(" "));
52
- };
53
- try {
54
- exitOnUncaughtException(new Error("boom"), (code) => {
55
- exitCode = code;
56
- });
57
- } finally {
58
- console.error = originalConsoleError;
59
- }
60
- expect(exitCode).toBe(1);
61
- expect(stderrLines.join("\n")).toContain("boom");
62
- });
63
-
64
- test("handles non-Error thrown values", () => {
65
- let exitCode: number | undefined;
66
- const originalConsoleError = console.error;
67
- console.error = () => {};
68
- try {
69
- exitOnUncaughtException("string failure", (code) => {
70
- exitCode = code;
71
- });
72
- } finally {
73
- console.error = originalConsoleError;
74
- }
75
- expect(exitCode).toBe(1);
76
- });
77
- });
78
-
79
- describe("installGlobalErrorHandlers (subprocess)", () => {
80
- test("an uncaught exception exits the process with code 1", async () => {
81
- const { exitCode, stderr } = await runFixture(`
82
- import { installGlobalErrorHandlers } from ${fatalErrorModule};
83
- installGlobalErrorHandlers();
84
- setTimeout(() => {
85
- throw new Error("boom-uncaught");
86
- }, 10);
87
- `);
88
- expect(exitCode).toBe(1);
89
- expect(stderr).toContain("boom-uncaught");
90
- });
91
-
92
- test("an unhandled rejection is logged without exiting", async () => {
93
- const { exitCode, stdout } = await runFixture(`
94
- import { installGlobalErrorHandlers } from ${fatalErrorModule};
95
- installGlobalErrorHandlers();
96
- Promise.reject(new Error("boom-rejection"));
97
- setTimeout(() => {
98
- console.log("STILL-ALIVE");
99
- }, 50);
100
- `);
101
- expect(exitCode).toBe(0);
102
- expect(stdout).toContain("STILL-ALIVE");
103
- });
104
- });