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/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
- package/README.md +5 -0
- package/bun.lock +316 -2
- package/dist/daemon/index.js +70789 -54681
- package/dist/index.js +729 -183
- package/package.json +2 -2
- package/src/cli.ts +258 -38
- package/src/daemon/config-store.ts +79 -4
- package/src/daemon/fatal-error.test.ts +104 -0
- package/src/daemon/fatal-error.ts +48 -0
- package/src/daemon/models.test.ts +81 -0
- package/src/daemon/models.ts +18 -2
- package/src/daemon/wallet/auto-refill.ts +4 -6
- package/src/daemon/wallet/cleanup.test.ts +168 -0
- package/src/daemon/wallet/cleanup.ts +104 -0
- package/src/daemon/wallet/coco-client.test.ts +191 -30
- package/src/daemon/wallet/coco-client.ts +867 -74
- package/src/daemon/wallet/cocod-client.ts +49 -1
- package/src/daemon/wallet/diagnostics.test.ts +378 -0
- package/src/daemon/wallet/diagnostics.ts +471 -0
- package/src/daemon/wallet/migration.test.ts +30 -6
- package/src/daemon/wallet/migration.ts +30 -85
- package/src/daemon/wallet/receive-dedup.test.ts +238 -0
- package/src/daemon/wallet/receive-dedup.ts +384 -0
- package/src/daemon/wallet/wallet-state.ts +75 -0
- package/src/integrations/pi.ts +17 -1
- package/src/start-daemon.ts +21 -1
- package/src/tui/usage/data.ts +13 -2
- package/src/tui/usage/render.ts +8 -0
- package/src/utils/config.ts +3 -1
- package/tests/daemon/config-store.perms.test.ts +55 -0
- package/tests/daemon/config-store.scenario.ts +113 -0
- package/tests/integrations/hermes.test.ts +42 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "routstrd",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|