routstrd 0.4.7 → 0.4.9

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.7",
3
+ "version": "0.4.9",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
@@ -8,10 +8,14 @@
8
8
  "routstrd": "./dist/index.js"
9
9
  },
10
10
  "files": [
11
- "dist",
11
+ "dist/index.js",
12
+ "dist/daemon/index.js",
12
13
  "src",
14
+ "!src/**/*.test.ts",
15
+ "!src/daemon/wallet/fixtures",
13
16
  "README.md",
14
- "SECURITY.md"
17
+ "SECURITY.md",
18
+ "SKILL.md"
15
19
  ],
16
20
  "scripts": {
17
21
  "start": "bun src/index.ts start",
@@ -20,6 +24,7 @@
20
24
  "lint": "tsc --noEmit",
21
25
  "test": "bun test",
22
26
  "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",
27
+ "build:binary": "bun build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig src/index.ts --outfile=dist/routstrd",
23
28
  "prepublishOnly": "bun run build"
24
29
  },
25
30
  "devDependencies": {
@@ -33,7 +38,7 @@
33
38
  "@cashu/cashu-ts": "^4.3.0",
34
39
  "@cashu/coco-core": "^1.0.1",
35
40
  "@cashu/coco-sqlite-bun": "^1.0.1",
36
- "@routstr/sdk": "^0.4.2",
41
+ "@routstr/sdk": "^0.4.4",
37
42
  "@scure/bip39": "^2.2.0",
38
43
  "applesauce-core": "^5.1.0",
39
44
  "applesauce-relay": "^5.1.0",
package/src/cli.ts CHANGED
@@ -11,10 +11,13 @@ import {
11
11
  getDaemonBaseUrl,
12
12
  getUserNpub,
13
13
  } from "./utils/daemon-client";
14
+ import { waitForDaemonToExit } from "./utils/daemon-stop";
14
15
  import {
15
16
  listClientsAction,
16
17
  deleteClientAction,
17
18
  addClientAction,
19
+ refreshModelsAndClientsAction,
20
+ setAutomaticRefreshAction,
18
21
  } from "./utils/clients";
19
22
  import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
20
23
  import { execSync } from "child_process";
@@ -28,7 +31,7 @@ import {
28
31
  type RoutstrdConfig,
29
32
  } from "./utils/config";
30
33
  import { COCO_LOGS_DIR, logger } from "./utils/logger";
31
- import { setupIntegration, runIntegrationsForClients, type IntegrationKey } from "./integrations";
34
+ import { setupIntegration, type IntegrationKey } from "./integrations";
32
35
  import {
33
36
  assertLegacyCocodNotRunning,
34
37
  claimLegacyCocodPidFile,
@@ -48,18 +51,22 @@ import {
48
51
  walletDir as defaultWalletDir,
49
52
  walletPidPath,
50
53
  } from "./daemon/wallet/paths";
51
- import { getClientsList } from "./utils/clients";
52
54
  import * as QRCode from "qrcode";
53
55
  import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98";
54
56
  import { generateSecretKey, nip19 } from "nostr-tools";
55
57
  import { generateMnemonic } from "@scure/bip39";
56
58
  import { wordlist } from "@scure/bip39/wordlists/english.js";
57
- import packageJson from "../package.json" with { type: "json" };
58
59
  import {
59
60
  compareVersions,
60
61
  getGlobalPackageVersion,
61
62
  getLatestNpmVersion,
62
63
  } from "./utils/update-checker.ts";
64
+ import { isStandaloneExecutable, pm2DaemonArgs } from "./runtime";
65
+ import { VERSION } from "./version";
66
+ import {
67
+ getLatestStandaloneRelease,
68
+ installStandaloneRelease,
69
+ } from "./utils/standalone-update";
63
70
 
64
71
  type RoutstrModel = {
65
72
  id: string;
@@ -105,6 +112,40 @@ async function printLightningInvoice(invoice: string): Promise<void> {
105
112
  console.log(`${qr}\nInvoice:\n${invoice}`);
106
113
  }
107
114
 
115
+ /** Block until the daemon has minted the paid invoice, then show the credit. */
116
+ async function waitForMintQuote(operationId: string): Promise<void> {
117
+ console.log(
118
+ "\nWaiting for payment... (Ctrl+C stops waiting; the daemon keeps checking)",
119
+ );
120
+ for (;;) {
121
+ const result = await callDaemon(
122
+ `/wallet/receive/bolt11/${encodeURIComponent(operationId)}`,
123
+ );
124
+ const quote = result.output as
125
+ | { state?: string; amount?: number; error?: string }
126
+ | undefined;
127
+ if (quote?.state === "finalized" && quote.error) {
128
+ // The mint issued the quote but the wallet could not restore the proofs.
129
+ console.error(`Payment reached the mint but could not be credited: ${quote.error}`);
130
+ process.exit(1);
131
+ }
132
+ if (quote?.state === "finalized") {
133
+ const balance = await callDaemon("/wallet/balance");
134
+ const balances =
135
+ (balance.output as { balances?: Record<string, number> } | undefined)
136
+ ?.balances ?? {};
137
+ const total = Object.values(balances).reduce((sum, sats) => sum + sats, 0);
138
+ console.log(`Received ${quote.amount} sat. Balance: ${total} sat`);
139
+ return;
140
+ }
141
+ if (quote?.state === "failed") {
142
+ console.error(`Invoice failed: ${quote.error ?? "unknown reason"}`);
143
+ process.exit(1);
144
+ }
145
+ await new Promise((resolve) => setTimeout(resolve, 2000));
146
+ }
147
+ }
148
+
108
149
  export function initializeWallet(walletDir = defaultWalletDir()): void {
109
150
  const walletConfig = join(walletDir, "config.json");
110
151
 
@@ -134,9 +175,38 @@ export function initializeWallet(walletDir = defaultWalletDir()): void {
134
175
  console.log("IMPORTANT: Write down this mnemonic and keep it safe!");
135
176
  }
136
177
 
178
+ type PidFileDeps = {
179
+ readFile(path: string): string;
180
+ isProcessRunning(pid: number): boolean;
181
+ };
182
+
183
+ export function getLivePidFileOwner(
184
+ path: string,
185
+ deps: PidFileDeps = {
186
+ readFile: (pidPath) => readFileSync(pidPath, "utf8"),
187
+ isProcessRunning: (pid) => {
188
+ try {
189
+ process.kill(pid, 0);
190
+ return true;
191
+ } catch (error) {
192
+ return (error as NodeJS.ErrnoException).code === "EPERM";
193
+ }
194
+ },
195
+ },
196
+ ): number | null {
197
+ try {
198
+ const contents = deps.readFile(path).trim();
199
+ if (!/^\d+$/.test(contents)) return null;
200
+ const pid = Number.parseInt(contents, 10);
201
+ return pid > 0 && deps.isProcessRunning(pid) ? pid : null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
137
207
  /**
138
208
  * Restart the routstrd daemon after an update so the new binary takes
139
- * effect immediately. Failures are collected and reported but never
209
+ * effect immediately. Failures are collected and reported but never
140
210
  * roll back the update itself.
141
211
  *
142
212
  * Note: cocod is no longer a separate process — the wallet now runs
@@ -156,22 +226,22 @@ async function restartDaemonsAfterUpdate(): Promise<void> {
156
226
  if (!wasRunning) {
157
227
  console.log("\nroutstrd daemon was not running — skipping restart.");
158
228
  } else {
159
- console.log("\nRestarting routstrd daemon...");
160
-
161
- await callDaemon("/stop", { method: "POST" });
162
-
163
- // Wait for HTTP health check to fail AND wallet lock to be released.
164
229
  const pidFilePath = walletPidPath();
165
- for (let i = 0; i < 100; i++) {
166
- await new Promise((resolve) => setTimeout(resolve, 100));
167
- const healthDown = !(await isDaemonRunning());
168
- const pidFileReleased = !existsSync(pidFilePath);
169
- if (healthDown && pidFileReleased) break;
230
+ const ownerPid = getLivePidFileOwner(pidFilePath);
231
+ if (ownerPid === null) {
232
+ throw new Error(
233
+ `Refusing to stop the process at ${getDaemonBaseUrl(config)} because ` +
234
+ `no live routstrd owner was found in ${pidFilePath}`,
235
+ );
170
236
  }
171
237
 
172
- if (await isDaemonRunning()) {
173
- throw new Error("routstrd did not stop within 10 seconds");
174
- }
238
+ console.log(`\nRestarting routstrd daemon (PID: ${ownerPid})...`);
239
+ await callDaemon("/stop", { method: "POST" });
240
+
241
+ // Wait for the old daemon to fully exit: it keeps serving ongoing
242
+ // requests before it releases the wallet lock and the new daemon can
243
+ // safely claim it.
244
+ await waitForDaemonToExit({ pidFilePath });
175
245
  console.log("routstrd daemon stopped.");
176
246
 
177
247
  await stopLegacyCocod();
@@ -290,12 +360,26 @@ async function initDaemon(integrationKey?: IntegrationKey): Promise<void> {
290
360
  program
291
361
  .name("routstrd")
292
362
  .description("Routstr daemon - Manage routstr processes")
293
- .version(packageJson.version, "--version", "output the version number");
363
+ .version(VERSION, "--version", "output the version number");
294
364
 
295
365
  program
296
366
  .command("update")
297
367
  .description("Update routstrd to the latest version")
298
368
  .action(async () => {
369
+ if (isStandaloneExecutable()) {
370
+ const release = await getLatestStandaloneRelease();
371
+ if ((compareVersions(VERSION, release.version) ?? -1) >= 0) {
372
+ console.log(`routstrd is already up to date (v${VERSION}).`);
373
+ return;
374
+ }
375
+
376
+ console.log(`Updating routstrd from v${VERSION} to v${release.version}...`);
377
+ await installStandaloneRelease(release);
378
+ console.log("routstrd updated successfully.\n");
379
+ await restartDaemonsAfterUpdate();
380
+ return;
381
+ }
382
+
299
383
  const packages = [{ name: "routstrd", label: "routstrd" }];
300
384
 
301
385
  let updatedAny = false;
@@ -637,6 +721,22 @@ program
637
721
  },
638
722
  );
639
723
 
724
+ program
725
+ .command("daemon")
726
+ .description("Run the daemon in the foreground")
727
+ .option("--port <port>", "Port to listen on")
728
+ .option("--host <host>", "Bind address")
729
+ .option("-p, --provider <provider>", "Default provider to use")
730
+ .action(async (options: { port?: string; host?: string; provider?: string }) => {
731
+ await requireLocalDaemon();
732
+ const argv = ["routstrd", "daemon"];
733
+ if (options.port) argv.push("--port", options.port);
734
+ if (options.host) argv.push("--host", options.host);
735
+ if (options.provider) argv.push("--provider", options.provider);
736
+ const { runDaemon } = await import("./daemon/index");
737
+ await runDaemon(argv);
738
+ });
739
+
640
740
  // Start - start the background daemon
641
741
  program
642
742
  .command("start")
@@ -858,26 +958,7 @@ program
858
958
  .description("Refresh routstr21 models and client integrations")
859
959
  .action(async () => {
860
960
  await ensureDaemonRunning();
861
- const config = await loadConfig();
862
-
863
- // Refresh models via daemon API
864
- console.log("Refreshing routstr21 models...");
865
- const result = await callDaemon("/v1/models?refresh=true");
866
- if (result.error) {
867
- console.log(`Model refresh failed: ${result.error}`);
868
- process.exit(1);
869
- }
870
- console.log("Models refreshed.");
871
-
872
- // Refresh integrations for all clients
873
- const clients = await getClientsList();
874
- if (clients.length > 0) {
875
- console.log(`Refreshing ${clients.length} client integration(s)...`);
876
- await runIntegrationsForClients(clients, config);
877
- console.log("Client integrations refreshed.");
878
- } else {
879
- console.log("No clients to refresh.");
880
- }
961
+ await refreshModelsAndClientsAction();
881
962
  });
882
963
 
883
964
  // Models - list routstr21 models
@@ -1256,7 +1337,54 @@ providersCmd
1256
1337
  // Clients - list and manage clients
1257
1338
  const clientsCmd = program
1258
1339
  .command("clients")
1259
- .description("List and manage clients");
1340
+ .description("List and manage clients")
1341
+ .option(
1342
+ "--manual-refresh",
1343
+ "Refresh routstr21 models and all client integrations now",
1344
+ false,
1345
+ )
1346
+ .option(
1347
+ "--disable-automatic-refresh",
1348
+ "Disable the daemon's scheduled refresh job",
1349
+ false,
1350
+ )
1351
+ .option(
1352
+ "--enable-automatic-refresh",
1353
+ "Re-enable the daemon's scheduled refresh job",
1354
+ false,
1355
+ )
1356
+ .action(
1357
+ async (options: {
1358
+ manualRefresh: boolean;
1359
+ disableAutomaticRefresh: boolean;
1360
+ enableAutomaticRefresh: boolean;
1361
+ }) => {
1362
+ if (options.disableAutomaticRefresh && options.enableAutomaticRefresh) {
1363
+ console.error(
1364
+ "error: --disable-automatic-refresh and --enable-automatic-refresh are mutually exclusive.",
1365
+ );
1366
+ process.exit(1);
1367
+ }
1368
+
1369
+ if (
1370
+ !options.manualRefresh &&
1371
+ !options.disableAutomaticRefresh &&
1372
+ !options.enableAutomaticRefresh
1373
+ ) {
1374
+ clientsCmd.help({ error: true });
1375
+ return;
1376
+ }
1377
+
1378
+ if (options.manualRefresh) {
1379
+ await ensureDaemonRunning();
1380
+ await refreshModelsAndClientsAction();
1381
+ }
1382
+
1383
+ if (options.disableAutomaticRefresh || options.enableAutomaticRefresh) {
1384
+ await setAutomaticRefreshAction(options.enableAutomaticRefresh);
1385
+ }
1386
+ },
1387
+ );
1260
1388
 
1261
1389
  clientsCmd
1262
1390
  .command("list")
@@ -1301,12 +1429,23 @@ const npubsCmd = program
1301
1429
 
1302
1430
  type NpubEntry = {
1303
1431
  npub: string;
1432
+ name: string | null;
1304
1433
  role: string;
1305
1434
  };
1306
1435
 
1436
+ /** Normalize a user-supplied npub display name: trim whitespace and cap at 64 chars. */
1437
+ function parseNpubName(raw: string | undefined): { name?: string | null; error?: string } {
1438
+ if (raw === undefined) return {};
1439
+ const trimmed = raw.trim();
1440
+ if ([...trimmed].length > 64) {
1441
+ return { error: "Invalid name. Maximum length is 64 characters." };
1442
+ }
1443
+ return { name: trimmed === "" ? null : trimmed };
1444
+ }
1445
+
1307
1446
  npubsCmd
1308
1447
  .command("list")
1309
- .description("List configured npubs with their roles")
1448
+ .description("List configured npubs with their roles and names")
1310
1449
  .action(async () => {
1311
1450
  await ensureDaemonRunning();
1312
1451
  const config = await loadConfig();
@@ -1322,7 +1461,7 @@ npubsCmd
1322
1461
  : result;
1323
1462
  const npubs = (data as { npubs?: NpubEntry[] } | undefined)?.npubs ?? [];
1324
1463
  if (npubs.length === 0) {
1325
- console.log("No admin npubs configured. Run 'routstrd npubs register' to register yourself as the first admin.");
1464
+ console.log("No npubs configured. Run 'routstrd npubs register' to register yourself as the first admin.");
1326
1465
  return;
1327
1466
  }
1328
1467
  console.log(`Npubs (${npubs.length}):`);
@@ -1330,12 +1469,13 @@ npubsCmd
1330
1469
  for (const entry of npubs) {
1331
1470
  const marker = entry.npub === userNpub ? " → you" : "";
1332
1471
  if (entry.npub === userNpub) found = true;
1333
- console.log(`- ${entry.npub} [${entry.role}]${marker}`);
1472
+ const name = entry.name ? ` "${entry.name}"` : "";
1473
+ console.log(`- ${entry.npub} [${entry.role}]${name}${marker}`);
1334
1474
  }
1335
1475
  if (userNpub && !found) {
1336
1476
  console.log("");
1337
1477
  console.log(
1338
- "Your npub is not in the admin list. Ask the admin to add your npub:",
1478
+ "Your npub is not in the npub list. Ask an admin to add your npub:",
1339
1479
  );
1340
1480
  console.log(` ${userNpub}`);
1341
1481
  }
@@ -1344,7 +1484,8 @@ npubsCmd
1344
1484
  npubsCmd
1345
1485
  .command("register")
1346
1486
  .description("Register yourself as the first admin (only when no admins exist)")
1347
- .action(async () => {
1487
+ .option("-n, --name <name>", "Display name for this npub (optional)")
1488
+ .action(async (options: { name?: string }) => {
1348
1489
  await ensureDaemonRunning();
1349
1490
  const config = await loadConfig();
1350
1491
  const userNpub = getUserNpub(config);
@@ -1372,19 +1513,27 @@ npubsCmd
1372
1513
  console.error("Failed to normalize user npub.");
1373
1514
  process.exit(1);
1374
1515
  }
1516
+ const name = parseNpubName(options.name);
1517
+ if (name.error) {
1518
+ console.error(name.error);
1519
+ process.exit(1);
1520
+ }
1521
+ const body: Record<string, string | null> = { npub: npubFromPubkey(normalized) };
1522
+ if (name.name !== undefined) body.name = name.name;
1375
1523
  const addResult = await callAuth("/npubs", {
1376
1524
  method: "POST",
1377
- body: { npub: npubFromPubkey(normalized) },
1525
+ body,
1378
1526
  });
1379
1527
  if (addResult.error) {
1380
1528
  console.log(addResult.error);
1381
1529
  process.exit(1);
1382
1530
  }
1383
1531
  const output = addResult.output as
1384
- | { npub?: string; added?: boolean; error?: string }
1532
+ | { npub?: string; name?: string | null; added?: boolean; error?: string }
1385
1533
  | undefined;
1386
1534
  if (output?.npub) {
1387
- console.log(`Successfully registered as first admin npub: ${output.npub}`);
1535
+ const nameSuffix = output.name ? ` ("${output.name}")` : "";
1536
+ console.log(`Successfully registered as first admin npub: ${output.npub}${nameSuffix}`);
1388
1537
  } else {
1389
1538
  console.log(`Successfully registered as first admin npub: ${userNpub}`);
1390
1539
  }
@@ -1394,7 +1543,8 @@ npubsCmd
1394
1543
  .command("add <npub>")
1395
1544
  .description("Add a npub (hex pubkey or npub1...). Defaults to 'user' role unless --role is specified.")
1396
1545
  .option("-r, --role <role>", "Role for the npub: 'admin' or 'user' (default: 'user')", "user")
1397
- .action(async (npubArg: string, options: { role: string }) => {
1546
+ .option("-n, --name <name>", "Display name for the npub (optional)")
1547
+ .action(async (npubArg: string, options: { role: string; name?: string }) => {
1398
1548
  await ensureDaemonRunning();
1399
1549
  const normalized = normalizeNostrPubkey(npubArg);
1400
1550
  if (!normalized) {
@@ -1405,7 +1555,16 @@ npubsCmd
1405
1555
  console.error("Invalid role. Expected 'admin' or 'user'.");
1406
1556
  process.exit(1);
1407
1557
  }
1408
- const body: Record<string, string> = { npub: npubFromPubkey(normalized), role: options.role };
1558
+ const name = parseNpubName(options.name);
1559
+ if (name.error) {
1560
+ console.error(name.error);
1561
+ process.exit(1);
1562
+ }
1563
+ const body: Record<string, string | null> = {
1564
+ npub: npubFromPubkey(normalized),
1565
+ role: options.role,
1566
+ };
1567
+ if (name.name !== undefined) body.name = name.name;
1409
1568
  const result = await callAuth("/npubs", {
1410
1569
  method: "POST",
1411
1570
  body,
@@ -1415,44 +1574,63 @@ npubsCmd
1415
1574
  process.exit(1);
1416
1575
  }
1417
1576
  const output = result.output as
1418
- | { npub?: string; role?: string; added?: boolean; error?: string }
1577
+ | { npub?: string; name?: string | null; role?: string; added?: boolean; error?: string }
1419
1578
  | undefined;
1420
1579
  if (output?.npub) {
1580
+ const nameSuffix = output.name ? ` ("${output.name}")` : "";
1421
1581
  console.log(
1422
- `${output.added ? "Added" : "Already configured"} npub: ${output.npub} [${output.role ?? "user"}]`,
1582
+ `${output.added ? "Added" : "Already configured"} npub: ${output.npub} [${output.role ?? "user"}]${nameSuffix}`,
1423
1583
  );
1424
1584
  }
1425
1585
  });
1426
1586
 
1427
1587
  npubsCmd
1428
1588
  .command("update <npub>")
1429
- .description("Update the role of an existing npub (requires admin)")
1430
- .requiredOption("-r, --role <role>", "New role: 'admin' or 'user'")
1431
- .action(async (npubArg: string, options: { role: string }) => {
1589
+ .description("Update the role and/or name of an existing npub (requires admin)")
1590
+ .option("-r, --role <role>", "New role: 'admin' or 'user'")
1591
+ .option("-n, --name <name>", "New display name (empty string clears it)")
1592
+ .action(async (npubArg: string, options: { role?: string; name?: string }) => {
1432
1593
  await ensureDaemonRunning();
1433
1594
  const normalized = normalizeNostrPubkey(npubArg);
1434
1595
  if (!normalized) {
1435
1596
  console.error("Invalid npub value. Use npub1... or 64-char hex pubkey.");
1436
1597
  process.exit(1);
1437
1598
  }
1438
- if (options.role !== "admin" && options.role !== "user") {
1599
+ if (options.role !== undefined && options.role !== "admin" && options.role !== "user") {
1439
1600
  console.error("Invalid role. Expected 'admin' or 'user'.");
1440
1601
  process.exit(1);
1441
1602
  }
1603
+ if (options.role === undefined && options.name === undefined) {
1604
+ console.error("Provide '--role' and/or '--name' to update.");
1605
+ process.exit(1);
1606
+ }
1607
+ const name = parseNpubName(options.name);
1608
+ if (name.error) {
1609
+ console.error(name.error);
1610
+ process.exit(1);
1611
+ }
1612
+ const body: Record<string, string | null> = {
1613
+ npub: npubFromPubkey(normalized),
1614
+ };
1615
+ if (options.role !== undefined) body.role = options.role;
1616
+ if (name.name !== undefined) body.name = name.name;
1442
1617
  const result = await callAuth("/npubs", {
1443
1618
  method: "PATCH",
1444
- body: { npub: npubFromPubkey(normalized), role: options.role },
1619
+ body,
1445
1620
  });
1446
1621
  if (result.error) {
1447
1622
  console.log(result.error);
1448
1623
  process.exit(1);
1449
1624
  }
1450
- // PATCH /npubs returns { npub, pubkey, role } at the top level, not wrapped in { output }
1625
+ // PATCH /npubs returns { npub, pubkey, name, role } at the top level, not wrapped in { output }
1451
1626
  const data = (result.output ?? result) as
1452
- | { npub?: string; pubkey?: string; role?: string; error?: string }
1627
+ | { npub?: string; pubkey?: string; name?: string | null; role?: string; error?: string }
1453
1628
  | undefined;
1454
1629
  if (data?.npub) {
1455
- console.log(`Updated npub ${data.npub} role to '${data.role}'.`);
1630
+ const parts: string[] = [];
1631
+ if (data.role) parts.push(`role='${data.role}'`);
1632
+ parts.push(`name=${data.name ? `"${data.name}"` : "(none)"}`);
1633
+ console.log(`Updated npub ${data.npub} (${parts.join(", ")}).`);
1456
1634
  } else {
1457
1635
  console.log("Npub not found or update failed.");
1458
1636
  }
@@ -1649,11 +1827,14 @@ program
1649
1827
  });
1650
1828
 
1651
1829
  const output = result.output as
1652
- | { invoice?: string; amount?: number; mintUrl?: string }
1830
+ | { invoice?: string; operationId?: string; amount?: number; mintUrl?: string }
1653
1831
  | undefined;
1654
1832
 
1655
1833
  if (typeof output?.invoice === "string" && output.invoice) {
1656
1834
  await printLightningInvoice(output.invoice);
1835
+ if (typeof output.operationId === "string") {
1836
+ await waitForMintQuote(output.operationId);
1837
+ }
1657
1838
  return;
1658
1839
  }
1659
1840
 
@@ -2081,7 +2262,21 @@ program
2081
2262
  .command("stop")
2082
2263
  .description("Stop the background daemon")
2083
2264
  .action(async () => {
2084
- await handleDaemonCommand("/stop", { method: "POST" });
2265
+ if (!(await isDaemonRunning())) {
2266
+ console.log("Daemon was not running.");
2267
+ return;
2268
+ }
2269
+ await callDaemon("/stop", { method: "POST" });
2270
+
2271
+ // The daemon exits only after ongoing requests finish; wait for it so
2272
+ // the wallet lock is actually free when this command returns.
2273
+ try {
2274
+ await waitForDaemonToExit({ pidFilePath: walletPidPath() });
2275
+ } catch (error) {
2276
+ logger.error(error instanceof Error ? error.message : String(error));
2277
+ process.exit(1);
2278
+ }
2279
+ console.log("Daemon stopped.");
2085
2280
  });
2086
2281
 
2087
2282
  // Service - PM2 management
@@ -2098,6 +2293,12 @@ serviceCmd
2098
2293
  try {
2099
2294
  execSync("pm2 -v", { stdio: "ignore" });
2100
2295
  } catch (e) {
2296
+ if (isStandaloneExecutable()) {
2297
+ console.error(
2298
+ "PM2 is optional and is not bundled with routstrd. Install PM2 separately before using 'routstrd service install'.",
2299
+ );
2300
+ process.exit(1);
2301
+ }
2101
2302
  console.log("PM2 not found. Installing PM2 globally with bun...");
2102
2303
  try {
2103
2304
  execSync("bun install -g pm2", { stdio: "inherit" });
@@ -2109,37 +2310,15 @@ serviceCmd
2109
2310
  }
2110
2311
  }
2111
2312
 
2112
- // 2. Resolve the path to the daemon
2113
- // In a global install, we want the bundled daemon in dist/daemon/index.js
2114
- let daemonPath: string;
2115
- try {
2116
- // Try to resolve relative to this file first (works in dev and global)
2117
- daemonPath = Bun.resolveSync("./daemon/index.js", import.meta.url);
2118
- } catch (e) {
2119
- // Fallback for some bundling scenarios
2120
- const path = require("path");
2121
- daemonPath = path.join(
2122
- path.dirname(import.meta.url).replace("file://", ""),
2123
- "daemon",
2124
- "index.js",
2125
- );
2126
- }
2127
-
2128
- if (!existsSync(daemonPath)) {
2129
- console.error(
2130
- `Could not find daemon at ${daemonPath}. Did you run 'bun run build'?`,
2131
- );
2132
- process.exit(1);
2133
- }
2134
-
2135
2313
  console.log("Starting routstrd via PM2...");
2136
2314
  try {
2137
2315
  await stopLegacyCocod();
2138
2316
 
2139
- // Use --interpreter bun to ensure it runs with bun
2140
- execSync(`pm2 start "${daemonPath}" --name routstrd --interpreter bun`, {
2141
- stdio: "inherit",
2317
+ const proc = Bun.spawn(["pm2", ...pm2DaemonArgs()], {
2318
+ stdout: "inherit",
2319
+ stderr: "inherit",
2142
2320
  });
2321
+ if ((await proc.exited) !== 0) throw new Error("PM2 exited with an error");
2143
2322
 
2144
2323
  console.log("\n✅ routstrd is now managed by PM2.");
2145
2324
  console.log("\nTo ensure it starts on system reboot, run:");
@@ -2194,19 +2373,12 @@ program
2194
2373
  console.log("Stopping daemon...");
2195
2374
  await callDaemon("/stop", { method: "POST" });
2196
2375
 
2197
- // Wait for HTTP health check to fail AND wallet lock to be released.
2198
- const pidFilePath = walletPidPath();
2199
- for (let i = 0; i < 100; i++) {
2200
- await new Promise((resolve) => setTimeout(resolve, 100));
2201
- const healthDown = !(await isDaemonRunning());
2202
- const pidFileReleased = !existsSync(pidFilePath);
2203
- if (healthDown && pidFileReleased) {
2204
- break;
2205
- }
2206
- }
2207
-
2208
- if (await isDaemonRunning()) {
2209
- logger.error("Daemon failed to stop within 10 seconds");
2376
+ // Wait for the old daemon to fully exit so the wallet lock is free
2377
+ // before a new daemon is spawned.
2378
+ try {
2379
+ await waitForDaemonToExit({ pidFilePath: walletPidPath() });
2380
+ } catch (error) {
2381
+ logger.error(error instanceof Error ? error.message : String(error));
2210
2382
  process.exit(1);
2211
2383
  }
2212
2384
  console.log("Daemon stopped.");
@@ -2285,15 +2457,12 @@ program
2285
2457
  console.log("Stopping daemon...");
2286
2458
  await callDaemon("/stop", { method: "POST" });
2287
2459
 
2288
- for (let i = 0; i < 50; i++) {
2289
- await new Promise((resolve) => setTimeout(resolve, 100));
2290
- if (!(await isDaemonRunning())) {
2291
- break;
2292
- }
2293
- }
2294
-
2295
- if (await isDaemonRunning()) {
2296
- logger.error("Daemon failed to stop within 5 seconds");
2460
+ // Wait for the old daemon to fully exit so the wallet lock is free
2461
+ // before a new daemon is spawned.
2462
+ try {
2463
+ await waitForDaemonToExit({ pidFilePath: walletPidPath() });
2464
+ } catch (error) {
2465
+ logger.error(error instanceof Error ? error.message : String(error));
2297
2466
  process.exit(1);
2298
2467
  }
2299
2468
  console.log("Daemon stopped.");
@@ -2589,5 +2758,5 @@ program
2589
2758
  );
2590
2759
 
2591
2760
  export function cli(args: string[]) {
2592
- program.parse(args);
2761
+ return program.parseAsync(args);
2593
2762
  }