routstrd 0.4.0 → 0.4.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routstrd",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
@@ -26,7 +26,7 @@
26
26
  "@cashu/cashu-ts": "^4.3.0",
27
27
  "@cashu/coco-core": "^1.0.1",
28
28
  "@cashu/coco-sqlite-bun": "^1.0.1",
29
- "@routstr/sdk": "^0.4.0",
29
+ "@routstr/sdk": "^0.4.1",
30
30
  "@scure/bip39": "^2.2.0",
31
31
  "applesauce-core": "^5.1.0",
32
32
  "applesauce-relay": "^5.1.0",
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { program } from "commander";
2
2
  import { startDaemon } from "./start-daemon";
3
+ import { ensureDirsSync, saveDaemonConfig } from "./daemon/config-store";
3
4
  import {
4
5
  handleDaemonCommand,
5
6
  callDaemon,
@@ -35,6 +36,13 @@ import {
35
36
  } from "./daemon/wallet/coco-client";
36
37
  import { migrateLegacyWallet } from "./daemon/wallet/migration";
37
38
  import {
39
+ diagnoseWallets,
40
+ renderWalletDoctor,
41
+ summarizeWalletDirectory,
42
+ WalletMigrationConflictError,
43
+ } from "./daemon/wallet/diagnostics";
44
+ import {
45
+ legacyCocodDir,
38
46
  legacyCocodPidPath,
39
47
  legacyCocodSocketPath,
40
48
  walletDir as defaultWalletDir,
@@ -210,19 +218,19 @@ async function requireLocalDaemon(): Promise<void> {
210
218
  async function initDaemon(): Promise<void> {
211
219
  console.log("Initializing routstrd...");
212
220
 
213
- // Create config directory
221
+ // Create config directory (0700, correcting existing installs too)
214
222
  if (!existsSync(CONFIG_DIR)) {
215
- mkdirSync(CONFIG_DIR, { recursive: true });
216
223
  console.log(`Created config directory: ${CONFIG_DIR}`);
217
224
  }
225
+ ensureDirsSync();
218
226
 
219
- // Create initial config
227
+ // Create initial config (0600, atomic write)
220
228
  if (!existsSync(CONFIG_FILE)) {
221
229
  const config: RoutstrdConfig = {
222
230
  ...DEFAULT_CONFIG,
223
231
  cocodPath: null,
224
232
  };
225
- await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
233
+ saveDaemonConfig(config);
226
234
  console.log(`Created config file: ${CONFIG_FILE}`);
227
235
  }
228
236
 
@@ -233,7 +241,7 @@ async function initDaemon(): Promise<void> {
233
241
  const nsec = nip19.nsecEncode(secretKey);
234
242
  const npub = npubFromSecretKey(secretKey);
235
243
  config.nsec = nsec;
236
- await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
244
+ saveDaemonConfig(config);
237
245
  console.log("\nA new Nostr identity has been generated for authentication.");
238
246
  console.log(`Your npub: ${npub}`);
239
247
  console.log(`You can view it in the config file at: ${CONFIG_FILE}\n`);
@@ -242,27 +250,19 @@ async function initDaemon(): Promise<void> {
242
250
  console.log(`Database will be stored at: ${DB_PATH}`);
243
251
  await stopLegacyCocod();
244
252
 
245
- let migrationLockOwner: number | undefined;
246
253
  const migration = await migrateLegacyWallet({
247
254
  assertLegacyStopped: () =>
248
255
  assertLegacyCocodNotRunning({
249
256
  socketPath: legacyCocodSocketPath(),
250
- pidFilePath: legacyCocodPidPath(),
251
- ignorePid: migrationLockOwner,
252
257
  }),
253
258
  acquireLegacyLock: () => {
254
259
  mkdirSync(dirname(legacyCocodPidPath()), {
255
260
  recursive: true,
256
261
  mode: 0o700,
257
262
  });
258
- const release = claimLegacyCocodPidFile({
263
+ return claimLegacyCocodPidFile({
259
264
  pidFilePath: legacyCocodPidPath(),
260
265
  });
261
- migrationLockOwner = process.pid;
262
- return () => {
263
- migrationLockOwner = undefined;
264
- release();
265
- };
266
266
  },
267
267
  });
268
268
  if (migration.status === "migrated") {
@@ -296,10 +296,7 @@ program
296
296
  .command("update")
297
297
  .description("Update routstrd to the latest version")
298
298
  .action(async () => {
299
- const packages = [
300
- { name: "routstrd", label: "routstrd" },
301
- { name: "@routstr/cocod", label: "cocod" },
302
- ];
299
+ const packages = [{ name: "routstrd", label: "routstrd" }];
303
300
 
304
301
  let updatedAny = false;
305
302
 
@@ -349,7 +346,7 @@ program
349
346
  .description("Refund pending tokens and API keys to a specified mint")
350
347
  .option(
351
348
  "-m, --mint-url <mintUrl>",
352
- "Mint URL to refund to (defaults to first mint in wallet)",
349
+ "Mint URL to refund to (defaults to active wallet mint)",
353
350
  )
354
351
  .option("-y, --yes", "Skip confirmation prompt", false)
355
352
  .option("--xcashu", "Refund xcashu tokens only (uses refundXcashuTokens)", false)
@@ -363,18 +360,19 @@ program
363
360
  console.log(balanceResult.error);
364
361
  process.exit(1);
365
362
  }
366
- const balances = (
367
- balanceResult.output as
368
- | {
369
- balances?: Record<string, number>;
370
- }
371
- | undefined
372
- )?.balances;
373
- if (!balances || Object.keys(balances).length === 0) {
363
+ const output = balanceResult.output as
364
+ | {
365
+ balances?: Record<string, number>;
366
+ activeMint?: string;
367
+ }
368
+ | undefined;
369
+ mintUrl =
370
+ output?.activeMint ??
371
+ (output?.balances ? Object.keys(output.balances)[0] : undefined);
372
+ if (!mintUrl) {
374
373
  console.log("No mint URLs found in wallet balance");
375
374
  process.exit(1);
376
375
  }
377
- mintUrl = Object.keys(balances)[0];
378
376
  console.log(`Using mint URL: ${mintUrl}`);
379
377
  }
380
378
 
@@ -520,7 +518,7 @@ program
520
518
  ...updates,
521
519
  };
522
520
 
523
- await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2));
521
+ saveDaemonConfig(updatedConfig);
524
522
 
525
523
  console.log(`Remote daemon URL set to: ${url}`);
526
524
  if (options.authUrl) {
@@ -555,7 +553,7 @@ program
555
553
  const { daemonUrl: _daemonUrl, authUrl: _authUrl, ...rest } = config;
556
554
  const updatedConfig: RoutstrdConfig = rest;
557
555
 
558
- await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2));
556
+ saveDaemonConfig(updatedConfig);
559
557
 
560
558
  console.log("Switched back to local daemon mode.");
561
559
  if (previousDaemonUrl) {
@@ -578,7 +576,17 @@ program
578
576
  )
579
577
  .action(async () => {
580
578
  await requireLocalDaemon();
581
- await initDaemon();
579
+ try {
580
+ await initDaemon();
581
+ } catch (error) {
582
+ // An expected, user-actionable refusal — print the structured message
583
+ // without Bun's unhandled-rejection source snippet and stack trace.
584
+ if (error instanceof WalletMigrationConflictError) {
585
+ console.error(error.message);
586
+ process.exit(1);
587
+ }
588
+ throw error;
589
+ }
582
590
  });
583
591
 
584
592
  // Start - start the background daemon
@@ -592,11 +600,18 @@ program
592
600
  await requireLocalDaemon();
593
601
  const config = await loadConfig();
594
602
  await stopLegacyCocod();
595
- await startDaemon({
596
- port: options.port || String(config.port || 8008),
597
- host: options.host || config.host || undefined,
598
- provider: options.provider,
599
- });
603
+ try {
604
+ await startDaemon({
605
+ port: options.port || String(config.port || 8008),
606
+ host: options.host || config.host || undefined,
607
+ provider: options.provider,
608
+ });
609
+ } catch (error) {
610
+ // startDaemon embeds the failed daemon's own output in the message
611
+ // (including the wallet-conflict report), so print it without a stack.
612
+ console.error(error instanceof Error ? error.message : error);
613
+ process.exit(1);
614
+ }
600
615
  });
601
616
 
602
617
  // Status - check daemon status
@@ -641,7 +656,7 @@ program
641
656
  )
642
657
  .option(
643
658
  "--mint-url <url>",
644
- "Mint URL to refund the deleted API key balance to (defaults to first mint in wallet)",
659
+ "Mint URL to refund the deleted API key balance to (defaults to active wallet mint)",
645
660
  )
646
661
  .action(async (options: { apiKeys: boolean; deleteApiKeys?: string; mintUrl?: string }) => {
647
662
  await ensureDaemonRunning();
@@ -1102,6 +1117,94 @@ providersCmd
1102
1117
  }
1103
1118
  });
1104
1119
 
1120
+ providersCmd
1121
+ .command("reviews")
1122
+ .description(
1123
+ "Show all known providers with their stored review events and event IDs",
1124
+ )
1125
+ .action(async () => {
1126
+ await ensureDaemonRunning();
1127
+
1128
+ const result = await callDaemon("/providers/reviews");
1129
+ if (result.error) {
1130
+ console.log(result.error);
1131
+ process.exit(1);
1132
+ }
1133
+
1134
+ const output = result.output as
1135
+ | {
1136
+ providers: Array<{
1137
+ index: number;
1138
+ baseUrl: string;
1139
+ disabled: boolean;
1140
+ nodePubkeys: string[];
1141
+ reviewCount: number;
1142
+ reviews: Array<{
1143
+ eventId: string;
1144
+ createdAt: number;
1145
+ authorPubkey: string | null;
1146
+ nodePubkey: string | null;
1147
+ label: string;
1148
+ isLgtm: boolean;
1149
+ tags: string[][];
1150
+ }>;
1151
+ }>;
1152
+ unmatchedReviews: Array<{
1153
+ eventId: string;
1154
+ createdAt: number;
1155
+ authorPubkey: string | null;
1156
+ nodePubkey: string | null;
1157
+ label: string;
1158
+ isLgtm: boolean;
1159
+ tags: string[][];
1160
+ }>;
1161
+ totalCount: number;
1162
+ reviewEventCount: number;
1163
+ }
1164
+ | undefined;
1165
+
1166
+ if (!output) {
1167
+ console.log("No provider review data available.");
1168
+ return;
1169
+ }
1170
+
1171
+ console.log(
1172
+ `Providers (${output.totalCount} total, ${output.reviewEventCount} stored review events):\n`,
1173
+ );
1174
+
1175
+ for (const provider of output.providers) {
1176
+ const status = provider.disabled ? "DISABLED" : "enabled ";
1177
+ console.log(
1178
+ ` [${provider.index}] ${status} ${provider.baseUrl} (${provider.reviewCount} review${provider.reviewCount === 1 ? "" : "s"})`,
1179
+ );
1180
+
1181
+ if (provider.nodePubkeys.length > 0) {
1182
+ console.log(` node pubkeys: ${provider.nodePubkeys.join(", ")}`);
1183
+ }
1184
+
1185
+ for (const review of provider.reviews) {
1186
+ const label = review.label || "review";
1187
+ const created = new Date(review.createdAt * 1000).toISOString();
1188
+ console.log(` - ${label} eventId: ${review.eventId}`);
1189
+ console.log(` node: ${review.nodePubkey ?? "(none)"}`);
1190
+ console.log(` author: ${review.authorPubkey ?? "(none)"}`);
1191
+ console.log(` at: ${created}`);
1192
+ }
1193
+ }
1194
+
1195
+ if (output.unmatchedReviews.length > 0) {
1196
+ console.log(
1197
+ `\n Unmatched review events (${output.unmatchedReviews.length} — node pubkey not in any known provider):`,
1198
+ );
1199
+ for (const review of output.unmatchedReviews) {
1200
+ const created = new Date(review.createdAt * 1000).toISOString();
1201
+ console.log(` - eventId: ${review.eventId}`);
1202
+ console.log(` node: ${review.nodePubkey ?? "(none)"}`);
1203
+ console.log(` at: ${created}`);
1204
+ }
1205
+ }
1206
+ });
1207
+
1105
1208
  // Clients - list and manage clients
1106
1209
  const clientsCmd = program
1107
1210
  .command("clients")
@@ -1531,6 +1634,16 @@ walletCmd
1531
1634
  await handleDaemonCommand("/wallet/status");
1532
1635
  });
1533
1636
 
1637
+ walletCmd
1638
+ .command("doctor")
1639
+ .description("Diagnose conflicting wallets (current routstrd wallet vs legacy cocod)")
1640
+ .action(async () => {
1641
+ const target = summarizeWalletDirectory(defaultWalletDir(), "canonical");
1642
+ const source = summarizeWalletDirectory(legacyCocodDir(), "legacy");
1643
+ console.log(renderWalletDoctor(target, source));
1644
+ if (diagnoseWallets(target, source).conflict) process.exit(1);
1645
+ });
1646
+
1534
1647
  walletCmd
1535
1648
  .command("unlock <passphrase>")
1536
1649
  .description("Unlock the wallet")
@@ -1548,6 +1661,113 @@ walletCmd
1548
1661
  await handleDaemonCommand("/wallet/balance");
1549
1662
  });
1550
1663
 
1664
+ walletCmd
1665
+ .command("cleanup")
1666
+ .description("Clear stuck pending/in-flight wallet operations")
1667
+ .option("--mint-url <url>", "Only clean up operations for this mint URL")
1668
+ .option(
1669
+ "--min-age <hours>",
1670
+ "Minimum age for reclaiming sends/cancelling melts, in hours (default: 168, one week; expired mint quotes are always failed)",
1671
+ "168",
1672
+ )
1673
+ .option("--dry-run", "Report what would be cleaned without applying changes", false)
1674
+ .option("-y, --yes", "Skip confirmation prompt", false)
1675
+ .action(
1676
+ async (options: {
1677
+ mintUrl?: string;
1678
+ minAge: string;
1679
+ dryRun: boolean;
1680
+ yes: boolean;
1681
+ }) => {
1682
+ const minAgeHours = Number.parseFloat(options.minAge);
1683
+ if (!Number.isFinite(minAgeHours) || minAgeHours < 0) {
1684
+ console.error(`Invalid --min-age value: ${options.minAge}`);
1685
+ process.exit(1);
1686
+ }
1687
+
1688
+ if (!options.dryRun && !options.yes) {
1689
+ const rl = require("readline").createInterface({
1690
+ input: process.stdin,
1691
+ output: process.stdout,
1692
+ });
1693
+ const answer = await new Promise<string>((resolve) => {
1694
+ rl.question(
1695
+ "This will fail expired mint quotes, reclaim old pending sends, and cancel prepared melts. Continue? [y/N] ",
1696
+ (value: string) => {
1697
+ rl.close();
1698
+ resolve(value.trim().toLowerCase());
1699
+ },
1700
+ );
1701
+ });
1702
+ if (answer !== "y" && answer !== "yes") {
1703
+ console.log("Aborted.");
1704
+ return;
1705
+ }
1706
+ }
1707
+
1708
+ try {
1709
+ await ensureDaemonRunning();
1710
+
1711
+ const result = await callDaemon("/wallet/cleanup", {
1712
+ method: "POST",
1713
+ body: {
1714
+ mintUrl: options.mintUrl,
1715
+ minAgeMs: Math.round(minAgeHours * 60 * 60 * 1000),
1716
+ dryRun: options.dryRun === true,
1717
+ },
1718
+ });
1719
+
1720
+ if (result.error) {
1721
+ console.log(result.error);
1722
+ process.exit(1);
1723
+ }
1724
+
1725
+ const output = result.output as
1726
+ | {
1727
+ dryRun?: boolean;
1728
+ failedMintQuotes?: number;
1729
+ reclaimedSends?: number;
1730
+ cancelledMelts?: number;
1731
+ skipped?: number;
1732
+ errors?: Array<{ operationId: string; error: string }>;
1733
+ }
1734
+ | undefined;
1735
+
1736
+ if (output) {
1737
+ const prefix = output.dryRun ? "Would clean up:" : "Cleaned up:";
1738
+ console.log(prefix);
1739
+ console.log(
1740
+ ` Expired mint quotes failed: ${output.failedMintQuotes ?? 0}`,
1741
+ );
1742
+ console.log(` Pending sends reclaimed: ${output.reclaimedSends ?? 0}`);
1743
+ console.log(
1744
+ ` Prepared melts cancelled: ${output.cancelledMelts ?? 0}`,
1745
+ );
1746
+ console.log(
1747
+ ` Skipped (still recent or already terminal): ${output.skipped ?? 0}`,
1748
+ );
1749
+ if (output.errors && output.errors.length > 0) {
1750
+ console.log("\nErrors:");
1751
+ for (const e of output.errors) {
1752
+ console.log(` - ${e.operationId}: ${e.error}`);
1753
+ }
1754
+ }
1755
+ }
1756
+ } catch (error) {
1757
+ const message = (error as Error).message;
1758
+ if (
1759
+ message?.includes("fetch failed") ||
1760
+ message?.includes("Connection refused")
1761
+ ) {
1762
+ console.error("Daemon is not running");
1763
+ process.exit(1);
1764
+ }
1765
+ console.error(message);
1766
+ process.exit(1);
1767
+ }
1768
+ },
1769
+ );
1770
+
1551
1771
  const walletReceiveCmd = walletCmd
1552
1772
  .command("receive")
1553
1773
  .description("Wallet receive operations");
@@ -2008,7 +2228,7 @@ program
2008
2228
  ...config,
2009
2229
  mode: selectedMode,
2010
2230
  };
2011
- await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2));
2231
+ saveDaemonConfig(updatedConfig);
2012
2232
  console.log(`Mode set to '${selectedMode}'. Restarting daemon...`);
2013
2233
 
2014
2234
  // Restart daemon
@@ -1,5 +1,13 @@
1
1
  import { mkdir } from "fs/promises";
2
- import { existsSync, readFileSync } from "fs";
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from "fs";
3
11
  import {
4
12
  CONFIG_DIR,
5
13
  CONFIG_FILE,
@@ -10,18 +18,61 @@ import { logger } from "../utils/logger";
10
18
 
11
19
  export const REQUESTS_DIR = `${CONFIG_DIR}/requests`;
12
20
 
21
+ // The daemon config holds spend-capable credentials (the operator `nsec` and
22
+ // the NWC connection string), so the directory and file permissions must match
23
+ // the wallet seed handling (0700/0600), not the umask default (0755/0644).
24
+
25
+ function chmodIgnoreErrors(path: string, mode: number): void {
26
+ try {
27
+ chmodSync(path, mode);
28
+ } catch {
29
+ // Best effort on filesystems that do not support POSIX modes.
30
+ }
31
+ }
32
+
33
+ /** Create the config directories (0700), correcting existing installs too. */
34
+ export function ensureDirsSync(): void {
35
+ try {
36
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
37
+ } catch {
38
+ // Directory may already exist
39
+ }
40
+ chmodSync(CONFIG_DIR, 0o700);
41
+
42
+ try {
43
+ mkdirSync(REQUESTS_DIR, { recursive: true, mode: 0o700 });
44
+ } catch {
45
+ // Directory may already exist
46
+ }
47
+ chmodSync(REQUESTS_DIR, 0o700);
48
+ }
49
+
13
50
  export async function ensureDirs(): Promise<void> {
14
51
  try {
15
- await mkdir(CONFIG_DIR, { recursive: true });
16
- await mkdir(REQUESTS_DIR, { recursive: true });
52
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
53
+ } catch {
54
+ // Directory may already exist
55
+ }
56
+ chmodSync(CONFIG_DIR, 0o700);
57
+
58
+ try {
59
+ await mkdir(REQUESTS_DIR, { recursive: true, mode: 0o700 });
17
60
  } catch {
18
61
  // Directory may already exist
19
62
  }
63
+ chmodSync(REQUESTS_DIR, 0o700);
64
+ }
65
+
66
+ function repairConfigPermissions(): void {
67
+ if (existsSync(CONFIG_FILE)) {
68
+ chmodIgnoreErrors(CONFIG_FILE, 0o600);
69
+ }
20
70
  }
21
71
 
22
72
  export async function loadDaemonConfig(): Promise<RoutstrdConfig> {
23
73
  try {
24
74
  if (existsSync(CONFIG_FILE)) {
75
+ repairConfigPermissions();
25
76
  const content = await Bun.file(CONFIG_FILE).text();
26
77
  return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
27
78
  }
@@ -34,6 +85,7 @@ export async function loadDaemonConfig(): Promise<RoutstrdConfig> {
34
85
  export function loadDaemonConfigSync(): RoutstrdConfig {
35
86
  try {
36
87
  if (existsSync(CONFIG_FILE)) {
88
+ repairConfigPermissions();
37
89
  const content = readFileSync(CONFIG_FILE, "utf-8");
38
90
  return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
39
91
  }
@@ -43,6 +95,29 @@ export function loadDaemonConfigSync(): RoutstrdConfig {
43
95
  return DEFAULT_CONFIG;
44
96
  }
45
97
 
98
+ /**
99
+ * Persist the daemon config atomically with owner-only permissions, mirroring
100
+ * the wallet config writer (saveConfig in wallet/coco-client.ts): write a
101
+ * 0600 temp file, then rename over the target. Synchronous so callers get
102
+ * error propagation instead of silently dropping credential updates.
103
+ */
46
104
  export function saveDaemonConfig(config: RoutstrdConfig): void {
47
- Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
105
+ ensureDirsSync();
106
+ const temporaryFile = `${CONFIG_FILE}.${process.pid}.tmp`;
107
+ try {
108
+ writeFileSync(temporaryFile, JSON.stringify(config, null, 2), {
109
+ mode: 0o600,
110
+ });
111
+ renameSync(temporaryFile, CONFIG_FILE);
112
+ } catch (error) {
113
+ try {
114
+ unlinkSync(temporaryFile);
115
+ } catch {
116
+ // The temporary file may not have been created.
117
+ }
118
+ throw error;
119
+ }
120
+ // renameSync preserves the temp file's mode, but chmod anyway so an
121
+ // existing over-permissive file is corrected even on exotic filesystems.
122
+ chmodIgnoreErrors(CONFIG_FILE, 0o600);
48
123
  }
@@ -0,0 +1,104 @@
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
+ });