routstrd 0.4.0 → 0.4.1

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/dist/index.js CHANGED
@@ -9914,6 +9914,17 @@ async function startDaemonUnlocked(options) {
9914
9914
  detached: true
9915
9915
  });
9916
9916
  proc.unref();
9917
+ const killSpawnedDaemon = () => {
9918
+ try {
9919
+ process.kill(-proc.pid, "SIGTERM");
9920
+ } catch {}
9921
+ };
9922
+ const abandonOnSignal = () => {
9923
+ killSpawnedDaemon();
9924
+ process.exit(1);
9925
+ };
9926
+ process.once("SIGINT", abandonOnSignal);
9927
+ process.once("SIGTERM", abandonOnSignal);
9917
9928
  let exitCode = null;
9918
9929
  proc.exited.then((code) => {
9919
9930
  exitCode = code;
@@ -9944,12 +9955,15 @@ Daemon output:
9944
9955
  ${daemonOutput}` : ` Check logs in ${LOGS_DIR2}`));
9945
9956
  }
9946
9957
  if (await healthyDaemonHost(port, host)) {
9958
+ process.off("SIGINT", abandonOnSignal);
9959
+ process.off("SIGTERM", abandonOnSignal);
9947
9960
  printStartupProgress(progressLogOffset);
9948
9961
  console.log(`Routstr daemon started (PID: ${proc.pid}, ${formatElapsed(Date.now() - startedAt)}).`);
9949
9962
  return;
9950
9963
  }
9951
9964
  }
9952
- throw new Error(`Daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds. Check logs in ${LOGS_DIR2}`);
9965
+ killSpawnedDaemon();
9966
+ throw new Error(`Daemon did not become healthy within ${Math.round(startupTimeoutMs / 1000)} seconds; ` + `the incomplete daemon (PID ${proc.pid}) was stopped. Check logs in ${LOGS_DIR2}`);
9953
9967
  }
9954
9968
  async function startDaemon(options = {}) {
9955
9969
  const port = options.port || "8008";
@@ -21276,7 +21290,7 @@ function formatTime(timestamp) {
21276
21290
  const p = (n) => String(n).padStart(2, "0");
21277
21291
  return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
21278
21292
  }
21279
- function formatNumber(n) {
21293
+ function formatNumber2(n) {
21280
21294
  if (n >= 1e6)
21281
21295
  return (n / 1e6).toFixed(1) + "M";
21282
21296
  if (n >= 1000)
@@ -21518,8 +21532,8 @@ function renderOverview(stats, balance, status, width) {
21518
21532
  `${COLORS.bold}Avg Cost/Req:${COLORS.reset} ${formatCost(avgCost)} sats`
21519
21533
  ];
21520
21534
  const rightBox = [
21521
- `${COLORS.bold}Total Tokens:${COLORS.reset} ${formatNumber(totals.totalTokens)}`,
21522
- `${COLORS.bold}Avg Tokens/Req:${COLORS.reset} ${formatNumber(Math.round(avgTokens))}`,
21535
+ `${COLORS.bold}Total Tokens:${COLORS.reset} ${formatNumber2(totals.totalTokens)}`,
21536
+ `${COLORS.bold}Avg Tokens/Req:${COLORS.reset} ${formatNumber2(Math.round(avgTokens))}`,
21523
21537
  `${COLORS.bold}Prompt/Comp:${COLORS.reset} ${(totals.promptTokens / Math.max(1, totals.completionTokens)).toFixed(2)}x`
21524
21538
  ];
21525
21539
  const halfWidth1 = Math.floor(width / 2);
@@ -21667,11 +21681,11 @@ function renderToday(stats, width) {
21667
21681
  `${COLORS.bold}Date:${COLORS.reset} ${todayStats.date}`,
21668
21682
  `${COLORS.bold}Requests:${COLORS.reset} ${formatReqs(todayStats.requests)}`,
21669
21683
  `${COLORS.bold}Cost:${COLORS.reset} ${COLORS.green}${formatCost(todayStats.satsCost)} sats${COLORS.reset}`,
21670
- `${COLORS.bold}Tokens:${COLORS.reset} ${formatNumber(todayStats.totalTokens)} (p: ${formatNumber(todayStats.promptTokens)} + c: ${formatNumber(todayStats.completionTokens)})`
21684
+ `${COLORS.bold}Tokens:${COLORS.reset} ${formatNumber2(todayStats.totalTokens)} (p: ${formatNumber2(todayStats.promptTokens)} + c: ${formatNumber2(todayStats.completionTokens)})`
21671
21685
  ];
21672
21686
  let output = renderBox(summaryLines, width, "Today");
21673
21687
  if (recentDays.length > 0) {
21674
- const dayLines = recentDays.map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber(d.totalTokens)} tokens`);
21688
+ const dayLines = recentDays.map((d) => `${d.date}: ${formatReqs(d.requests)} req, ${formatCost(d.satsCost)} sats, ${formatNumber2(d.totalTokens)} tokens`);
21675
21689
  output += `
21676
21690
  ` + renderBox(dayLines, width, "Recent Days");
21677
21691
  }
@@ -21713,7 +21727,7 @@ function renderModels(stats, width) {
21713
21727
  lines.push(`${color}${COLORS.bold}${model.modelId}${COLORS.reset}`);
21714
21728
  lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${formatCost(model.satsCost)} sats (${pct}%)`);
21715
21729
  lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${formatReqs(model.requests)}`);
21716
- lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber(model.totalTokens)}`);
21730
+ lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber2(model.totalTokens)}`);
21717
21731
  lines.push(` ${COLORS.dim}Avg:${COLORS.reset} ${formatCost(model.satsCost / model.requests)} sats/req`);
21718
21732
  lines.push(` ${renderBarChart(" ", model.satsCost, maxCost, width - 6, color, Number(pct), "model-detail")}`);
21719
21733
  lines.push("");
@@ -21731,7 +21745,7 @@ function renderProviders(stats, width) {
21731
21745
  lines.push(`${COLORS.cyan}${COLORS.bold}${shortUrl}${COLORS.reset}`);
21732
21746
  lines.push(` ${COLORS.dim}Requests:${COLORS.reset} ${formatReqs(provider.requests)}`);
21733
21747
  lines.push(` ${COLORS.dim}Cost:${COLORS.reset} ${formatCost(provider.satsCost)} sats`);
21734
- lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber(provider.totalTokens)}`);
21748
+ lines.push(` ${COLORS.dim}Tokens:${COLORS.reset} ${formatNumber2(provider.totalTokens)}`);
21735
21749
  lines.push("");
21736
21750
  }
21737
21751
  return renderBox(lines, width, "Provider Breakdown");
@@ -21740,9 +21754,9 @@ function renderTokens(stats, width) {
21740
21754
  const totals = stats.summary.totals;
21741
21755
  const modelStats = stats.summary.models;
21742
21756
  const summaryLines = [
21743
- `${COLORS.bold}Total Prompt Tokens:${COLORS.reset} ${formatNumber(totals.promptTokens)}`,
21744
- `${COLORS.bold}Total Completion Tokens:${COLORS.reset} ${formatNumber(totals.completionTokens)}`,
21745
- `${COLORS.bold}Total Tokens:${COLORS.reset} ${formatNumber(totals.totalTokens)}`,
21757
+ `${COLORS.bold}Total Prompt Tokens:${COLORS.reset} ${formatNumber2(totals.promptTokens)}`,
21758
+ `${COLORS.bold}Total Completion Tokens:${COLORS.reset} ${formatNumber2(totals.completionTokens)}`,
21759
+ `${COLORS.bold}Total Tokens:${COLORS.reset} ${formatNumber2(totals.totalTokens)}`,
21746
21760
  `${COLORS.bold}Prompt/Completion Ratio:${COLORS.reset} ${(totals.promptTokens / Math.max(1, totals.completionTokens)).toFixed(2)}x`,
21747
21761
  `${COLORS.bold}Avg Tokens/Request:${COLORS.reset} ${(totals.totalTokens / Math.max(1, stats.totalEntries)).toFixed(0)}`
21748
21762
  ];
@@ -21751,7 +21765,7 @@ function renderTokens(stats, width) {
21751
21765
  const tokenLines = modelStats.slice(0, 6).map((m) => {
21752
21766
  const color = MODEL_COLORS[m.modelId] || MODEL_COLORS.default;
21753
21767
  const promptPct = m.totalTokens > 0 ? (m.promptTokens / m.totalTokens * 100).toFixed(0) : "0";
21754
- return `${color}${m.modelId.padEnd(15)}${COLORS.reset} ${formatNumber(m.totalTokens).padStart(8)} tokens (${promptPct}% prompt)`;
21768
+ return `${color}${m.modelId.padEnd(15)}${COLORS.reset} ${formatNumber2(m.totalTokens).padStart(8)} tokens (${promptPct}% prompt)`;
21755
21769
  });
21756
21770
  output += `
21757
21771
  ` + renderBox(tokenLines, width, "Tokens by Model");
@@ -21787,7 +21801,7 @@ function renderClients(stats, width) {
21787
21801
  const dClient = client.client.slice(0, col1 - 1).padEnd(col1);
21788
21802
  const dReqs = formatReqs(client.requests).padEnd(col2);
21789
21803
  const dCost = `${formatCost(client.satsCost)} sats (${pct}%)`.padEnd(col3);
21790
- const dTok = formatNumber(client.totalTokens).padEnd(col4);
21804
+ const dTok = formatNumber2(client.totalTokens).padEnd(col4);
21791
21805
  const dAvg = `${avgCostFormatted} sats/req`;
21792
21806
  lines.push(`${color}${COLORS.bold}${dClient}${COLORS.reset}` + `${dReqs}` + `${COLORS.green}${dCost}${COLORS.reset}` + `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`);
21793
21807
  lines.push(` ${renderBarChart("", client.satsCost, maxCost, width - 6, color, Number(pct), "client-detail")}`);
@@ -21802,7 +21816,7 @@ function renderClients(stats, width) {
21802
21816
  continue;
21803
21817
  clientModelLines.push(`${COLORS.bold}${topClient.client}${COLORS.reset} (${formatReqs(topClient.requests)} reqs, ${formatCost(topClient.satsCost)} sats)`);
21804
21818
  for (const m of topClient.topModels) {
21805
- clientModelLines.push(` ${MODEL_COLORS[m.modelId] || MODEL_COLORS.default}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
21819
+ clientModelLines.push(` ${MODEL_COLORS[m.modelId] || MODEL_COLORS.default}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber2(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
21806
21820
  }
21807
21821
  clientModelLines.push("");
21808
21822
  }
@@ -21838,7 +21852,7 @@ function renderNpubs(stats, clients, width) {
21838
21852
  const dNpub = shortNpub.padEnd(col1);
21839
21853
  const dReqs = formatReqs(npub.requests).padEnd(col2);
21840
21854
  const dCost = `${formatCost(npub.satsCost)} sats (${pct}%)`.padEnd(col3);
21841
- const dTok = formatNumber(npub.totalTokens).padEnd(col4);
21855
+ const dTok = formatNumber2(npub.totalTokens).padEnd(col4);
21842
21856
  const dAvg = `${avgCostFormatted} sats/req`;
21843
21857
  lines.push(`${COLORS.magenta}${COLORS.bold}${dNpub}${COLORS.reset}` + `${dReqs}` + `${COLORS.green}${dCost}${COLORS.reset}` + `${COLORS.dim}${dTok}${dAvg}${COLORS.reset}`);
21844
21858
  lines.push(` ${COLORS.dim}${npub.npub}${COLORS.reset}`);
@@ -21854,7 +21868,7 @@ function renderNpubs(stats, clients, width) {
21854
21868
  continue;
21855
21869
  npubModelLines.push(`${COLORS.bold}${truncateNpub(topNpub.npub)}${COLORS.reset} (${formatReqs(topNpub.requests)} reqs, ${formatCost(topNpub.satsCost)} sats)`);
21856
21870
  for (const m of topNpub.topModels) {
21857
- npubModelLines.push(` ${MODEL_COLORS[m.modelId] || MODEL_COLORS.default}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
21871
+ npubModelLines.push(` ${MODEL_COLORS[m.modelId] || MODEL_COLORS.default}${m.modelId.padEnd(18)}${COLORS.reset} ${formatNumber2(m.totalTokens).padEnd(8)} tokens ${formatCost(m.satsCost)} sats`);
21858
21872
  }
21859
21873
  npubModelLines.push("");
21860
21874
  }
@@ -21890,7 +21904,7 @@ function renderRecent(stats, width) {
21890
21904
  entry.cacheReadInputTokens || 0,
21891
21905
  entry.cacheCreationInputTokens || 0,
21892
21906
  entry.completionTokens
21893
- ].map(formatNumber).join("/");
21907
+ ].map(formatNumber2).join("/");
21894
21908
  const totalSats = typeof entry.totalMsats === "number" ? entry.totalMsats / 1000 : entry.satsCost;
21895
21909
  const cost = [
21896
21910
  formatCost(msatsToSats(entry.inputMsats)),
@@ -22898,9 +22912,9 @@ async function addClientAction(options) {
22898
22912
  // src/cli.ts
22899
22913
  init_config();
22900
22914
  init_logger();
22901
- import { chmodSync as chmodSync2, existsSync as existsSync11, mkdirSync as mkdirSync9, readFileSync as readFileSync3, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
22915
+ import { chmodSync as chmodSync2, existsSync as existsSync13, mkdirSync as mkdirSync9, readFileSync as readFileSync4, statSync as statSync4, writeFileSync as writeFileSync2 } from "fs";
22902
22916
  import { execSync } from "child_process";
22903
- import { dirname as dirname8, join as join7 } from "path";
22917
+ import { dirname as dirname8, join as join9 } from "path";
22904
22918
 
22905
22919
  // src/integrations/index.ts
22906
22920
  init_logger();
@@ -24255,6 +24269,11 @@ var STALE_SOCKET_ERROR_CODES = new Set([
24255
24269
  "ENOENT",
24256
24270
  "FailedToOpenSocket"
24257
24271
  ]);
24272
+ var RECOVERY_STALL_MARKERS = new Map([
24273
+ ["SendOperationService\x00Could not reach mint for recovery, will retry later", "Send recovery: mint unreachable"],
24274
+ ["ReceiveOperationService\x00Could not reach mint for receive recovery, will retry later", "Receive recovery: mint unreachable"],
24275
+ ["MintOperationService\x00Failed to reconcile stale pending mint operation", "Mint recovery: pending operation check failed"]
24276
+ ]);
24258
24277
  var SAFE_COCO_LOG_FIELDS = new Set([
24259
24278
  "module",
24260
24279
  "mintUrl",
@@ -24271,13 +24290,22 @@ var SAFE_COCO_LOG_FIELDS = new Set([
24271
24290
  "rollingBackOperations",
24272
24291
  "orphanedReservations"
24273
24292
  ]);
24293
+ function isZombieProcess(pid, readFile7 = (path) => readFileSync(path, "utf-8")) {
24294
+ try {
24295
+ const stat2 = readFile7(`/proc/${pid}/stat`);
24296
+ const commandEnd = stat2.lastIndexOf(")");
24297
+ return commandEnd >= 0 && stat2.charAt(commandEnd + 2) === "Z";
24298
+ } catch {
24299
+ return false;
24300
+ }
24301
+ }
24274
24302
  function defaultIsProcessRunning(pid) {
24275
24303
  try {
24276
24304
  process.kill(pid, 0);
24277
- return true;
24278
24305
  } catch (error) {
24279
24306
  return error.code === "EPERM";
24280
24307
  }
24308
+ return !isZombieProcess(pid);
24281
24309
  }
24282
24310
  function hasErrorCode(error, codes) {
24283
24311
  let current = error;
@@ -24294,24 +24322,7 @@ function hasErrorCode(error, codes) {
24294
24322
  }
24295
24323
  async function assertLegacyCocodNotRunning(options = {}) {
24296
24324
  const socketPath = options.socketPath || legacyCocodSocketPath();
24297
- const pidFilePath = options.pidFilePath || legacyCocodPidPath();
24298
24325
  const pathExists = options.pathExists || existsSync9;
24299
- const readFile7 = options.readFile || ((path) => readFileSync(path, "utf-8"));
24300
- const isProcessRunning2 = options.isProcessRunning || defaultIsProcessRunning;
24301
- const getRunningLegacyPid = () => {
24302
- if (!pathExists(pidFilePath))
24303
- return null;
24304
- try {
24305
- const pid = Number.parseInt(readFile7(pidFilePath).trim(), 10);
24306
- return Number.isInteger(pid) && pid > 0 && pid !== options.ignorePid && isProcessRunning2(pid) ? pid : null;
24307
- } catch {
24308
- return null;
24309
- }
24310
- };
24311
- const runningPid = getRunningLegacyPid();
24312
- if (runningPid !== null) {
24313
- throw new Error(`Legacy cocod daemon is still running with PID ${runningPid}. ` + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + `Run 'cocod stop' or 'kill ${runningPid}' and try again.`);
24314
- }
24315
24326
  if (!pathExists(socketPath))
24316
24327
  return;
24317
24328
  const fetchImpl = options.fetchImpl || fetch;
@@ -24324,12 +24335,8 @@ async function assertLegacyCocodNotRunning(options = {}) {
24324
24335
  await response.body?.cancel();
24325
24336
  } catch (error) {
24326
24337
  if (hasErrorCode(error, STALE_SOCKET_ERROR_CODES)) {
24327
- const newlyRunningPid = getRunningLegacyPid();
24328
- if (newlyRunningPid === null) {
24329
- logger.debug(`Ignoring stale legacy cocod socket at ${socketPath}`);
24330
- return;
24331
- }
24332
- throw new Error(`Legacy cocod daemon is still running with PID ${newlyRunningPid}. ` + "Refusing to open the wallet database because cocod and coco-core cannot safely use it at the same time. " + `Run 'cocod stop' or 'kill ${newlyRunningPid}' and try again.`, { cause: error });
24338
+ logger.debug(`Ignoring stale legacy cocod socket at ${socketPath}`);
24339
+ return;
24333
24340
  }
24334
24341
  throw new Error(`Cannot verify whether the legacy cocod daemon has stopped at ${socketPath}. ` + "Refusing to open the wallet database to prevent concurrent access. " + "Run 'cocod stop', verify the daemon has exited, and try again.", { cause: error });
24335
24342
  }
@@ -24393,12 +24400,14 @@ async function stopLegacyCocod(options = {}) {
24393
24400
  function claimLegacyCocodPidFile(options = {}) {
24394
24401
  return claimPidFile({
24395
24402
  ...options,
24396
- pidFilePath: options.pidFilePath || legacyCocodPidPath()
24403
+ pidFilePath: options.pidFilePath || legacyCocodPidPath(),
24404
+ label: options.label || "legacy cocod exclusion lock"
24397
24405
  });
24398
24406
  }
24399
24407
  function claimPidFile(options) {
24400
24408
  const pidFilePath = options.pidFilePath;
24401
24409
  const pid = options.pid ?? process.pid;
24410
+ const label = options.label || "wallet process lock";
24402
24411
  const openExclusive = options.openExclusive || ((path) => openSync2(path, "wx", 384));
24403
24412
  const writePid = options.writePid || ((fd2, ownerPid) => writeFileSync(fd2, String(ownerPid)));
24404
24413
  const closeFile = options.closeFile || closeSync2;
@@ -24415,16 +24424,17 @@ function claimPidFile(options) {
24415
24424
  try {
24416
24425
  stalePid = Number.parseInt(readFile7(pidFilePath).trim(), 10);
24417
24426
  } catch {
24418
- throw new Error(`Cannot claim the wallet process lock at ${pidFilePath}. ` + "Another cocod or routstrd process may be starting. Stop it and try again.", { cause: error });
24427
+ throw new Error(`Cannot claim the ${label} at ${pidFilePath}. ` + "Another cocod or routstrd process may be starting. Stop it and try again.", { cause: error });
24419
24428
  }
24420
24429
  if (!Number.isInteger(stalePid) || stalePid <= 0 || isProcessRunning2(stalePid)) {
24421
- throw new Error(`Cannot claim the wallet process lock at ${pidFilePath}. ` + "Another cocod or routstrd process may be starting. Stop it and try again.", { cause: error });
24430
+ const ownerMessage = Number.isInteger(stalePid) && stalePid > 0 ? `PID ${stalePid} is still running and holds it. ` + `Stop that process first ('routstrd stop' or 'kill ${stalePid}').` : "Another cocod or routstrd process may be starting. Stop it and try again.";
24431
+ throw new Error(`Cannot claim the ${label} at ${pidFilePath}: ${ownerMessage}`, { cause: error });
24422
24432
  }
24423
24433
  try {
24424
24434
  removeFile(pidFilePath);
24425
24435
  fd = openExclusive(pidFilePath);
24426
24436
  } catch (retryError) {
24427
- throw new Error(`Cannot claim the wallet process lock at ${pidFilePath}. ` + "Another cocod or routstrd process may be starting. Stop it and try again.", { cause: retryError });
24437
+ throw new Error(`Cannot claim the ${label} at ${pidFilePath}. ` + "Another cocod or routstrd process may be starting. Stop it and try again.", { cause: retryError });
24428
24438
  }
24429
24439
  }
24430
24440
  try {
@@ -24438,36 +24448,48 @@ function claimPidFile(options) {
24438
24448
  closeFile(fd);
24439
24449
  }
24440
24450
  let released = false;
24441
- return () => {
24451
+ const release = () => {
24442
24452
  if (released)
24443
24453
  return;
24444
24454
  released = true;
24455
+ process.removeListener("exit", release);
24445
24456
  try {
24446
24457
  if (readFile7(pidFilePath).trim() === String(pid)) {
24447
24458
  removeFile(pidFilePath);
24448
24459
  }
24449
24460
  } catch (error) {
24450
24461
  if (error.code !== "ENOENT") {
24451
- logger.warn(`Failed to release wallet process lock at ${pidFilePath}:`, error);
24462
+ logger.warn(`Failed to release ${label} at ${pidFilePath}:`, error);
24452
24463
  }
24453
24464
  }
24454
24465
  };
24466
+ process.once("exit", release);
24467
+ return release;
24455
24468
  }
24456
24469
 
24457
24470
  // src/daemon/wallet/migration.ts
24458
24471
  import {
24459
24472
  chmodSync,
24460
24473
  copyFileSync,
24461
- existsSync as existsSync10,
24474
+ existsSync as existsSync12,
24462
24475
  mkdirSync as mkdirSync8,
24463
- readFileSync as readFileSync2,
24464
24476
  renameSync as renameSync2,
24465
24477
  rmSync,
24466
- statSync
24478
+ statSync as statSync3
24467
24479
  } from "fs";
24480
+ import { Database as Database2 } from "bun:sqlite";
24481
+ import { basename, dirname as dirname7, join as join8 } from "path";
24482
+
24483
+ // src/daemon/wallet/diagnostics.ts
24484
+ import { createHash } from "crypto";
24485
+ import { existsSync as existsSync11, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
24468
24486
  import { Database } from "bun:sqlite";
24469
- import { basename, dirname as dirname7, join as join6 } from "path";
24470
- function state(configPath, dbPath) {
24487
+ import { join as join7 } from "path";
24488
+
24489
+ // src/daemon/wallet/wallet-state.ts
24490
+ import { existsSync as existsSync10, readFileSync as readFileSync2, statSync } from "fs";
24491
+ import { join as join6 } from "path";
24492
+ function walletState(configPath, dbPath) {
24471
24493
  const hasConfig = existsSync10(configPath);
24472
24494
  const hasDb = existsSync10(dbPath);
24473
24495
  if (hasConfig)
@@ -24483,9 +24505,36 @@ function filesEqual(left, right) {
24483
24505
  return false;
24484
24506
  return readFileSync2(left).equals(readFileSync2(right));
24485
24507
  }
24486
- function sqlString(value) {
24487
- return `'${value.replaceAll("'", "''")}'`;
24508
+ function classifyWalletMigration(targetDir, sourceDir) {
24509
+ const targetConfig = join6(targetDir, "config.json");
24510
+ const targetDb = join6(targetDir, "coco.db");
24511
+ const sourceConfig = join6(sourceDir, "config.json");
24512
+ const sourceDb = join6(sourceDir, "coco.db");
24513
+ const sourceWal = `${sourceDb}-wal`;
24514
+ const sourceShm = `${sourceDb}-shm`;
24515
+ const targetState = walletState(targetConfig, targetDb);
24516
+ const sourceState = walletState(sourceConfig, sourceDb);
24517
+ if (targetState === "initialized" && sourceState === "initialized") {
24518
+ const configsMatch = filesEqual(targetConfig, sourceConfig);
24519
+ const databasesMatch = !existsSync10(targetDb) && !existsSync10(sourceDb) ? true : filesEqual(targetDb, sourceDb);
24520
+ if (configsMatch && databasesMatch)
24521
+ return { kind: "already-current" };
24522
+ return { kind: "conflict" };
24523
+ }
24524
+ if (targetState === "initialized")
24525
+ return { kind: "already-current" };
24526
+ if (!existsSync10(sourceDb) && (existsSync10(sourceWal) || existsSync10(sourceShm))) {
24527
+ return { kind: "orphaned-sidecars" };
24528
+ }
24529
+ if (targetState === "database-only" || sourceState === "database-only") {
24530
+ return { kind: "database-only", targetState, sourceState };
24531
+ }
24532
+ if (sourceState === "absent")
24533
+ return { kind: "fresh" };
24534
+ return { kind: "migrate" };
24488
24535
  }
24536
+
24537
+ // src/daemon/wallet/diagnostics.ts
24489
24538
  var SUMMARY_QUERIES = {
24490
24539
  proofs: "SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state",
24491
24540
  counters: "SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId",
@@ -24507,8 +24556,339 @@ function verifyDatabase(database, label) {
24507
24556
  }
24508
24557
  return summary;
24509
24558
  }
24559
+ function mnemonicFingerprint(mnemonic) {
24560
+ const normalized = mnemonic.trim().split(/\s+/).join(" ");
24561
+ const hex3 = createHash("sha256").update(normalized).digest("hex");
24562
+ return hex3.slice(0, 16).match(/.{1,4}/g)?.join("-") ?? hex3.slice(0, 16);
24563
+ }
24564
+ function summarizeDbReadonly(dbPath) {
24565
+ let database;
24566
+ try {
24567
+ database = new Database(dbPath, { readonly: true });
24568
+ const raw = verifyDatabase(database, "Wallet database");
24569
+ const proofs = raw.proofs ?? [];
24570
+ const mints = raw.mints ?? [];
24571
+ const amountByState = {};
24572
+ let totalProofs = 0;
24573
+ let totalAmount = 0;
24574
+ for (const row of proofs) {
24575
+ const count = typeof row.count === "number" ? row.count : 0;
24576
+ const amount = typeof row.amount === "number" ? row.amount : 0;
24577
+ totalProofs += count;
24578
+ totalAmount += amount;
24579
+ const state = row.state || "unknown";
24580
+ amountByState[state] = (amountByState[state] ?? 0) + amount;
24581
+ }
24582
+ return {
24583
+ summary: {
24584
+ proofs,
24585
+ mints,
24586
+ totalProofs,
24587
+ totalAmount,
24588
+ amountByState,
24589
+ distinctMints: new Set(proofs.map((row) => row.mintUrl).filter(Boolean)).size
24590
+ }
24591
+ };
24592
+ } catch (error) {
24593
+ return { error: error instanceof Error ? error.message : String(error) };
24594
+ } finally {
24595
+ database?.close();
24596
+ }
24597
+ }
24598
+ function summarizeConfig(configPath) {
24599
+ const result = { exists: false, path: configPath };
24600
+ try {
24601
+ if (!existsSync11(configPath))
24602
+ return result;
24603
+ result.exists = true;
24604
+ result.mtimeMs = statSync2(configPath).mtimeMs;
24605
+ const parsed = JSON.parse(readFileSync3(configPath, "utf-8"));
24606
+ result.encrypted = parsed.encrypted === true;
24607
+ if (typeof parsed.createdAt === "string")
24608
+ result.createdAt = parsed.createdAt;
24609
+ if (typeof parsed.version === "number")
24610
+ result.version = parsed.version;
24611
+ if (typeof parsed.defaultMintUrl === "string") {
24612
+ result.defaultMintUrl = parsed.defaultMintUrl;
24613
+ }
24614
+ if (typeof parsed.mnemonic === "string") {
24615
+ result.hasMnemonic = true;
24616
+ if (!result.encrypted)
24617
+ result.fingerprint = mnemonicFingerprint(parsed.mnemonic);
24618
+ }
24619
+ } catch (error) {
24620
+ result.error = error instanceof Error ? error.message : String(error);
24621
+ }
24622
+ return result;
24623
+ }
24624
+ function summarizeDb(dbPath) {
24625
+ const result = { exists: false, path: dbPath };
24626
+ try {
24627
+ if (!existsSync11(dbPath))
24628
+ return result;
24629
+ result.exists = true;
24630
+ const stats = statSync2(dbPath);
24631
+ result.mtimeMs = stats.mtimeMs;
24632
+ result.sizeBytes = stats.size;
24633
+ const { summary, error } = summarizeDbReadonly(dbPath);
24634
+ if (summary)
24635
+ result.summary = summary;
24636
+ if (error)
24637
+ result.error = error;
24638
+ } catch (error) {
24639
+ result.error = error instanceof Error ? error.message : String(error);
24640
+ }
24641
+ return result;
24642
+ }
24643
+ function summarizeWalletDirectory(dir, role) {
24644
+ return {
24645
+ dir,
24646
+ role,
24647
+ config: summarizeConfig(join7(dir, "config.json")),
24648
+ db: summarizeDb(join7(dir, "coco.db"))
24649
+ };
24650
+ }
24651
+
24652
+ class WalletMigrationConflictError extends Error {
24653
+ target;
24654
+ source;
24655
+ constructor(target, source) {
24656
+ super(renderWalletConflict(target, source));
24657
+ this.name = "WalletMigrationConflictError";
24658
+ this.target = target;
24659
+ this.source = source;
24660
+ }
24661
+ }
24662
+ function timeAgo(ms) {
24663
+ if (ms === undefined || !Number.isFinite(ms))
24664
+ return;
24665
+ const elapsed = Date.now() - ms;
24666
+ if (elapsed < 0)
24667
+ return;
24668
+ const seconds = Math.floor(elapsed / 1000);
24669
+ if (seconds < 5)
24670
+ return "just now";
24671
+ if (seconds < 60)
24672
+ return `${seconds}s ago`;
24673
+ const minutes = Math.floor(seconds / 60);
24674
+ if (minutes < 60)
24675
+ return `${minutes}m ago`;
24676
+ const hours = Math.floor(minutes / 60);
24677
+ if (hours < 48)
24678
+ return `${hours}h ago`;
24679
+ const days = Math.floor(hours / 24);
24680
+ if (days < 60)
24681
+ return `${days}d ago`;
24682
+ const months = Math.floor(days / 30);
24683
+ if (months < 24)
24684
+ return `${months}mo ago`;
24685
+ return `${Math.floor(days / 365)}y ago`;
24686
+ }
24687
+ function modifiedDescription(config) {
24688
+ if (config.createdAt)
24689
+ return `created ${config.createdAt}`;
24690
+ const ago = timeAgo(config.mtimeMs);
24691
+ if (ago)
24692
+ return `modified ${ago}`;
24693
+ return "modified time unknown";
24694
+ }
24695
+ function roleLabel(role) {
24696
+ return role === "canonical" ? "current routstrd wallet" : "legacy cocod wallet";
24697
+ }
24698
+ function configLine(config) {
24699
+ if (!config.exists)
24700
+ return "config.json absent";
24701
+ if (config.error)
24702
+ return `config.json present (unreadable: ${config.error})`;
24703
+ const parts = ["config.json present"];
24704
+ if (config.fingerprint)
24705
+ parts.push(`fingerprint ${config.fingerprint}`);
24706
+ else if (config.encrypted)
24707
+ parts.push("encrypted (mnemonic not readable)");
24708
+ else
24709
+ parts.push("no mnemonic found");
24710
+ parts.push(modifiedDescription(config));
24711
+ if (config.defaultMintUrl)
24712
+ parts.push(`default mint ${config.defaultMintUrl}`);
24713
+ return parts.join(" \xB7 ");
24714
+ }
24715
+ function formatNumber(value) {
24716
+ return value.toLocaleString("en-US");
24717
+ }
24718
+ function amountByStateLine(summary) {
24719
+ const parts = Object.entries(summary.amountByState).filter(([, amount]) => amount > 0);
24720
+ if (parts.length === 0)
24721
+ return "0 sats";
24722
+ return parts.map(([state, amount]) => `${state}: ${formatNumber(amount)} sats`).join(", ");
24723
+ }
24724
+ function dbLine(db) {
24725
+ if (!db.exists)
24726
+ return "coco.db absent";
24727
+ if (db.error && !db.summary)
24728
+ return `coco.db present (unreadable: ${db.error})`;
24729
+ const parts = ["coco.db present"];
24730
+ if (db.summary) {
24731
+ const { totalProofs, mints, distinctMints } = db.summary;
24732
+ const mintCount = mints.length > 0 ? mints.length : distinctMints;
24733
+ parts.push(`${formatNumber(totalProofs)} proof${totalProofs === 1 ? "" : "s"}`);
24734
+ parts.push(amountByStateLine(db.summary));
24735
+ parts.push(`${mintCount} mint${mintCount === 1 ? "" : "s"}`);
24736
+ }
24737
+ return parts.join(" \xB7 ");
24738
+ }
24739
+ function block(diag, letter) {
24740
+ return [
24741
+ ` ${letter}) ${diag.dir} (${roleLabel(diag.role)})`,
24742
+ ` ${configLine(diag.config)}`,
24743
+ ` ${dbLine(diag.db)}`
24744
+ ].join(`
24745
+ `);
24746
+ }
24747
+ function moveAsideCommands(target, source) {
24748
+ return [
24749
+ " \u2022 If B is your wallet (keep the legacy wallet):",
24750
+ ` mv "${target.dir}" "${target.dir}.old"`,
24751
+ "",
24752
+ " \u2022 If A is your wallet (keep the normal routstrd wallet):",
24753
+ ` mv "${source.dir}" "${source.dir}.old"`
24754
+ ].join(`
24755
+ `);
24756
+ }
24757
+ function renderResolutionSteps(target, source) {
24758
+ return [
24759
+ "To resolve, decide which wallet is yours, move the other one aside, and run",
24760
+ "'routstrd onboard' (or 'routstrd start') again. Stop the daemon first:",
24761
+ "",
24762
+ " routstrd stop",
24763
+ "",
24764
+ "Then:",
24765
+ "",
24766
+ moveAsideCommands(target, source)
24767
+ ].join(`
24768
+ `);
24769
+ }
24770
+ function renderWalletConflict(target, source) {
24771
+ return [
24772
+ "Cannot migrate wallet: two different wallets were found, and routstrd will",
24773
+ "not choose a mnemonic or merge databases automatically \u2014 the wrong choice",
24774
+ "could lose access to your funds.",
24775
+ "",
24776
+ block(target, "A"),
24777
+ block(source, "B"),
24778
+ "",
24779
+ "No files were changed.",
24780
+ "",
24781
+ renderResolutionSteps(target, source),
24782
+ "",
24783
+ "For a full comparison and safe cleanup, run: routstrd wallet doctor"
24784
+ ].join(`
24785
+ `);
24786
+ }
24787
+ function conflictVerdict(target, source) {
24788
+ const unreadable = !!target.config.error || !!source.config.error || !!target.db.error || !!source.db.error;
24789
+ if (unreadable) {
24790
+ return {
24791
+ text: "Verdict: both wallets exist but one or both could not be fully read; startup will refuse to migrate. See the details above.",
24792
+ showResolution: true,
24793
+ conflict: true
24794
+ };
24795
+ }
24796
+ if (target.config.fingerprint && source.config.fingerprint) {
24797
+ if (target.config.fingerprint === source.config.fingerprint) {
24798
+ return {
24799
+ text: "Verdict: both wallets share the same mnemonic but the files differ, so startup still refuses. Keep the wallet with your funds and move the other aside.",
24800
+ showResolution: true,
24801
+ conflict: true
24802
+ };
24803
+ }
24804
+ return {
24805
+ text: "Verdict: the two wallets have DIFFERENT mnemonics. Startup will refuse to migrate.",
24806
+ showResolution: true,
24807
+ conflict: true
24808
+ };
24809
+ }
24810
+ return {
24811
+ text: "Verdict: both wallets exist but their mnemonics cannot be compared (encrypted or missing). Startup will refuse to migrate unless the files are identical.",
24812
+ showResolution: true,
24813
+ conflict: true
24814
+ };
24815
+ }
24816
+ function verdictFromClassification(target, source, classification) {
24817
+ switch (classification.kind) {
24818
+ case "fresh":
24819
+ return {
24820
+ text: "Verdict: no wallet found in either location (fresh install).",
24821
+ showResolution: false,
24822
+ conflict: false
24823
+ };
24824
+ case "already-current":
24825
+ return {
24826
+ text: "Verdict: the current routstrd wallet is authoritative; no migration needed.",
24827
+ showResolution: false,
24828
+ conflict: false
24829
+ };
24830
+ case "migrate":
24831
+ return {
24832
+ text: "Verdict: only the legacy cocod wallet exists; it will be migrated on next startup.",
24833
+ showResolution: false,
24834
+ conflict: false
24835
+ };
24836
+ case "conflict":
24837
+ return conflictVerdict(target, source);
24838
+ case "database-only":
24839
+ return {
24840
+ text: "Verdict: a wallet is incomplete (coco.db without config.json). Startup will refuse to migrate. See the details above.",
24841
+ showResolution: false,
24842
+ conflict: true
24843
+ };
24844
+ case "orphaned-sidecars":
24845
+ return {
24846
+ text: "Verdict: the legacy wallet has SQLite sidecar files without coco.db. Startup will refuse to migrate. Restore the matching database first.",
24847
+ showResolution: false,
24848
+ conflict: true
24849
+ };
24850
+ }
24851
+ }
24852
+ function diagnoseWallets(target, source) {
24853
+ let classification;
24854
+ try {
24855
+ classification = classifyWalletMigration(target.dir, source.dir);
24856
+ } catch {
24857
+ if (target.config.error || source.config.error || target.db.error || source.db.error) {
24858
+ return conflictVerdict(target, source);
24859
+ }
24860
+ return {
24861
+ text: "Verdict: the wallets could not be fully compared (read error); startup will refuse to migrate. See the details above.",
24862
+ showResolution: true,
24863
+ conflict: true
24864
+ };
24865
+ }
24866
+ return verdictFromClassification(target, source, classification);
24867
+ }
24868
+ function renderWalletDoctor(target, source) {
24869
+ const verdict = diagnoseWallets(target, source);
24870
+ const lines = [
24871
+ "Routstr wallet diagnostic",
24872
+ "========================",
24873
+ "",
24874
+ block(target, "A"),
24875
+ block(source, "B"),
24876
+ "",
24877
+ verdict.text
24878
+ ];
24879
+ if (verdict.showResolution) {
24880
+ lines.push("", renderResolutionSteps(target, source));
24881
+ }
24882
+ return lines.join(`
24883
+ `);
24884
+ }
24885
+
24886
+ // src/daemon/wallet/migration.ts
24887
+ function sqlString(value) {
24888
+ return `'${value.replaceAll("'", "''")}'`;
24889
+ }
24510
24890
  function snapshotDatabase(sourcePath, destinationPath) {
24511
- const source = new Database(sourcePath);
24891
+ const source = new Database2(sourcePath);
24512
24892
  let sourceSummary;
24513
24893
  try {
24514
24894
  sourceSummary = verifyDatabase(source, "Legacy wallet database");
@@ -24516,7 +24896,7 @@ function snapshotDatabase(sourcePath, destinationPath) {
24516
24896
  } finally {
24517
24897
  source.close();
24518
24898
  }
24519
- const destination = new Database(destinationPath, { readonly: true });
24899
+ const destination = new Database2(destinationPath, { readonly: true });
24520
24900
  try {
24521
24901
  const destinationSummary = verifyDatabase(destination, "Staged wallet database");
24522
24902
  if (JSON.stringify(sourceSummary) !== JSON.stringify(destinationSummary)) {
@@ -24529,48 +24909,42 @@ function snapshotDatabase(sourcePath, destinationPath) {
24529
24909
  async function migrateLegacyWallet(options = {}) {
24530
24910
  const targetDir = options.walletDir || walletDir();
24531
24911
  const sourceDir = options.legacyDir || legacyCocodDir();
24532
- const targetConfig = join6(targetDir, "config.json");
24533
- const targetDb = join6(targetDir, "coco.db");
24534
- const sourceConfig = join6(sourceDir, "config.json");
24535
- const sourceDb = join6(sourceDir, "coco.db");
24912
+ const sourceConfig = join8(sourceDir, "config.json");
24913
+ const sourceDb = join8(sourceDir, "coco.db");
24536
24914
  const sourceWal = `${sourceDb}-wal`;
24537
24915
  const sourceShm = `${sourceDb}-shm`;
24538
- const targetState = state(targetConfig, targetDb);
24539
- const sourceState = state(sourceConfig, sourceDb);
24540
- if (targetState === "initialized" && sourceState === "initialized") {
24541
- const configsMatch = filesEqual(targetConfig, sourceConfig);
24542
- const databasesMatch = !existsSync10(targetDb) && !existsSync10(sourceDb) ? true : filesEqual(targetDb, sourceDb);
24543
- if (configsMatch && databasesMatch)
24916
+ const classification = classifyWalletMigration(targetDir, sourceDir);
24917
+ switch (classification.kind) {
24918
+ case "fresh":
24919
+ return { status: "fresh" };
24920
+ case "already-current":
24544
24921
  return { status: "already-current" };
24545
- throw new Error(`Cannot migrate wallet: both ${targetDir} and ${sourceDir} contain different wallet data. ` + "Refusing to choose a mnemonic or merge wallet databases automatically.");
24546
- }
24547
- if (targetState === "initialized")
24548
- return { status: "already-current" };
24549
- if (!existsSync10(sourceDb) && (existsSync10(sourceWal) || existsSync10(sourceShm))) {
24550
- throw new Error(`Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` + "Restore the matching main database before migration.");
24551
- }
24552
- if (targetState === "database-only" || sourceState === "database-only") {
24553
- throw new Error(`Cannot migrate wallet: ${targetDir} is ${targetState} and ${sourceDir} is ${sourceState}. ` + "A coco.db without config.json cannot be opened; restore the matching config first.");
24922
+ case "migrate":
24923
+ break;
24924
+ case "conflict":
24925
+ throw new WalletMigrationConflictError(summarizeWalletDirectory(targetDir, "canonical"), summarizeWalletDirectory(sourceDir, "legacy"));
24926
+ case "orphaned-sidecars":
24927
+ throw new Error(`Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` + "Restore the matching main database before migration.");
24928
+ case "database-only":
24929
+ throw new Error(`Cannot migrate wallet: ${targetDir} is ${classification.targetState} and ${sourceDir} is ${classification.sourceState}. ` + "A coco.db without config.json cannot be opened; restore the matching config first.");
24554
24930
  }
24555
- if (sourceState === "absent")
24556
- return { status: "fresh" };
24557
24931
  await options.assertLegacyStopped?.();
24558
24932
  const releaseLegacyLock = await options.acquireLegacyLock?.();
24559
- const stagingDir = join6(dirname7(targetDir), `.${targetDir.split(/[\\/]/).at(-1) || "wallet"}.staging-${process.pid}-${Date.now()}`);
24933
+ const stagingDir = join8(dirname7(targetDir), `.${targetDir.split(/[\\/]/).at(-1) || "wallet"}.staging-${process.pid}-${Date.now()}`);
24560
24934
  try {
24561
24935
  await options.assertLegacyStopped?.();
24562
24936
  mkdirSync8(dirname7(targetDir), { recursive: true, mode: 448 });
24563
24937
  mkdirSync8(stagingDir, { mode: 448 });
24564
- const stagedConfig = join6(stagingDir, "config.json");
24565
- const stagedDb = join6(stagingDir, "coco.db");
24938
+ const stagedConfig = join8(stagingDir, "config.json");
24939
+ const stagedDb = join8(stagingDir, "coco.db");
24566
24940
  copyFileSync(sourceConfig, stagedConfig);
24567
24941
  chmodSync(stagedConfig, 384);
24568
- const hasSourceDb = existsSync10(sourceDb);
24942
+ const hasSourceDb = existsSync12(sourceDb);
24569
24943
  if (hasSourceDb) {
24570
24944
  snapshotDatabase(sourceDb, stagedDb);
24571
24945
  chmodSync(stagedDb, 384);
24572
24946
  }
24573
- if (statSync(stagedConfig).size !== statSync(sourceConfig).size) {
24947
+ if (statSync3(stagedConfig).size !== statSync3(sourceConfig).size) {
24574
24948
  throw new Error("Cannot migrate wallet: staged config failed size validation.");
24575
24949
  }
24576
24950
  renameSync2(stagingDir, targetDir);
@@ -24581,13 +24955,13 @@ async function migrateLegacyWallet(options = {}) {
24581
24955
  releaseLegacyLock?.();
24582
24956
  }
24583
24957
  const cleanupWarnings = [];
24584
- const sourceFiles = [sourceConfig, sourceDb, sourceWal, sourceShm].filter(existsSync10);
24958
+ const sourceFiles = [sourceConfig, sourceDb, sourceWal, sourceShm].filter(existsSync12);
24585
24959
  if (sourceFiles.length > 0) {
24586
- const archiveDir = join6(sourceDir, `wallet-migrated-${Date.now()}`);
24960
+ const archiveDir = join8(sourceDir, `wallet-migrated-${Date.now()}`);
24587
24961
  try {
24588
24962
  mkdirSync8(archiveDir, { mode: 448 });
24589
24963
  for (const path of sourceFiles)
24590
- renameSync2(path, join6(archiveDir, basename(path)));
24964
+ renameSync2(path, join8(archiveDir, basename(path)));
24591
24965
  } catch (error) {
24592
24966
  const message = error instanceof Error ? error.message : String(error);
24593
24967
  cleanupWarnings.push(`Could not archive legacy wallet files: ${message}`);
@@ -26721,7 +27095,7 @@ zoo`.split(`
26721
27095
  // package.json
26722
27096
  var package_default = {
26723
27097
  name: "routstrd",
26724
- version: "0.4.0",
27098
+ version: "0.4.1",
26725
27099
  module: "src/index.ts",
26726
27100
  type: "module",
26727
27101
  private: false,
@@ -26787,10 +27161,10 @@ Invoice:
26787
27161
  ${invoice}`);
26788
27162
  }
26789
27163
  function initializeWallet(walletDir2 = walletDir()) {
26790
- const walletConfig = join7(walletDir2, "config.json");
27164
+ const walletConfig = join9(walletDir2, "config.json");
26791
27165
  mkdirSync9(walletDir2, { recursive: true, mode: 448 });
26792
27166
  chmodSync2(walletDir2, 448);
26793
- if (existsSync11(walletConfig)) {
27167
+ if (existsSync13(walletConfig)) {
26794
27168
  chmodSync2(walletConfig, 384);
26795
27169
  console.log("Wallet already initialized.");
26796
27170
  return;
@@ -26830,7 +27204,7 @@ Restarting routstrd daemon...`);
26830
27204
  for (let i2 = 0;i2 < 100; i2++) {
26831
27205
  await new Promise((resolve) => setTimeout(resolve, 100));
26832
27206
  const healthDown = !await isDaemonRunning();
26833
- const pidFileReleased = !existsSync11(pidFilePath);
27207
+ const pidFileReleased = !existsSync13(pidFilePath);
26834
27208
  if (healthDown && pidFileReleased)
26835
27209
  break;
26836
27210
  }
@@ -26873,11 +27247,11 @@ async function requireLocalDaemon() {
26873
27247
  }
26874
27248
  async function initDaemon() {
26875
27249
  console.log("Initializing routstrd...");
26876
- if (!existsSync11(CONFIG_DIR)) {
27250
+ if (!existsSync13(CONFIG_DIR)) {
26877
27251
  mkdirSync9(CONFIG_DIR, { recursive: true });
26878
27252
  console.log(`Created config directory: ${CONFIG_DIR}`);
26879
27253
  }
26880
- if (!existsSync11(CONFIG_FILE)) {
27254
+ if (!existsSync13(CONFIG_FILE)) {
26881
27255
  const config2 = {
26882
27256
  ...DEFAULT_CONFIG,
26883
27257
  cocodPath: null
@@ -26900,26 +27274,18 @@ A new Nostr identity has been generated for authentication.`);
26900
27274
  }
26901
27275
  console.log(`Database will be stored at: ${DB_PATH}`);
26902
27276
  await stopLegacyCocod();
26903
- let migrationLockOwner;
26904
27277
  const migration = await migrateLegacyWallet({
26905
27278
  assertLegacyStopped: () => assertLegacyCocodNotRunning({
26906
- socketPath: legacyCocodSocketPath(),
26907
- pidFilePath: legacyCocodPidPath(),
26908
- ignorePid: migrationLockOwner
27279
+ socketPath: legacyCocodSocketPath()
26909
27280
  }),
26910
27281
  acquireLegacyLock: () => {
26911
27282
  mkdirSync9(dirname8(legacyCocodPidPath()), {
26912
27283
  recursive: true,
26913
27284
  mode: 448
26914
27285
  });
26915
- const release = claimLegacyCocodPidFile({
27286
+ return claimLegacyCocodPidFile({
26916
27287
  pidFilePath: legacyCocodPidPath()
26917
27288
  });
26918
- migrationLockOwner = process.pid;
26919
- return () => {
26920
- migrationLockOwner = undefined;
26921
- release();
26922
- };
26923
27289
  }
26924
27290
  });
26925
27291
  if (migration.status === "migrated") {
@@ -26977,7 +27343,7 @@ program.command("update").description("Update routstrd to the latest version").a
26977
27343
  All packages are already up to date \u2014 nothing to do.`);
26978
27344
  }
26979
27345
  });
26980
- program.command("refund").description("Refund pending tokens and API keys to a specified mint").option("-m, --mint-url <mintUrl>", "Mint URL to refund to (defaults to first mint in wallet)").option("-y, --yes", "Skip confirmation prompt", false).option("--xcashu", "Refund xcashu tokens only (uses refundXcashuTokens)", false).action(async (options) => {
27346
+ program.command("refund").description("Refund pending tokens and API keys to a specified mint").option("-m, --mint-url <mintUrl>", "Mint URL to refund to (defaults to active wallet mint)").option("-y, --yes", "Skip confirmation prompt", false).option("--xcashu", "Refund xcashu tokens only (uses refundXcashuTokens)", false).action(async (options) => {
26981
27347
  await ensureDaemonRunning();
26982
27348
  let mintUrl = options.mintUrl;
26983
27349
  if (!mintUrl) {
@@ -26986,12 +27352,12 @@ program.command("refund").description("Refund pending tokens and API keys to a s
26986
27352
  console.log(balanceResult.error);
26987
27353
  process.exit(1);
26988
27354
  }
26989
- const balances = balanceResult.output?.balances;
26990
- if (!balances || Object.keys(balances).length === 0) {
27355
+ const output = balanceResult.output;
27356
+ mintUrl = output?.activeMint ?? (output?.balances ? Object.keys(output.balances)[0] : undefined);
27357
+ if (!mintUrl) {
26991
27358
  console.log("No mint URLs found in wallet balance");
26992
27359
  process.exit(1);
26993
27360
  }
26994
- mintUrl = Object.keys(balances)[0];
26995
27361
  console.log(`Using mint URL: ${mintUrl}`);
26996
27362
  }
26997
27363
  try {
@@ -27078,7 +27444,7 @@ program.command("remote [url]").description("Show the configured remote daemon,
27078
27444
  process.exit(1);
27079
27445
  }
27080
27446
  }
27081
- if (!existsSync11(CONFIG_DIR)) {
27447
+ if (!existsSync13(CONFIG_DIR)) {
27082
27448
  mkdirSync9(CONFIG_DIR, { recursive: true });
27083
27449
  }
27084
27450
  const updates = { daemonUrl: url };
@@ -27133,17 +27499,30 @@ Use 'routstrd onboard' for first time using local mode or 'routstrd start' to st
27133
27499
  });
27134
27500
  program.command("onboard").description("Initialize routstrd (creates config directory and initializes wallet)").action(async () => {
27135
27501
  await requireLocalDaemon();
27136
- await initDaemon();
27502
+ try {
27503
+ await initDaemon();
27504
+ } catch (error) {
27505
+ if (error instanceof WalletMigrationConflictError) {
27506
+ console.error(error.message);
27507
+ process.exit(1);
27508
+ }
27509
+ throw error;
27510
+ }
27137
27511
  });
27138
27512
  program.command("start").description("Start the background daemon").option("--port <port>", "Port to listen on").option("--host <host>", "Bind address (default: 127.0.0.1)").option("-p, --provider <provider>", "Default provider to use").action(async (options) => {
27139
27513
  await requireLocalDaemon();
27140
27514
  const config = await loadConfig();
27141
27515
  await stopLegacyCocod();
27142
- await startDaemon({
27143
- port: options.port || String(config.port || 8008),
27144
- host: options.host || config.host || undefined,
27145
- provider: options.provider
27146
- });
27516
+ try {
27517
+ await startDaemon({
27518
+ port: options.port || String(config.port || 8008),
27519
+ host: options.host || config.host || undefined,
27520
+ provider: options.provider
27521
+ });
27522
+ } catch (error) {
27523
+ console.error(error instanceof Error ? error.message : error);
27524
+ process.exit(1);
27525
+ }
27147
27526
  });
27148
27527
  program.command("status").description("Check daemon and wallet status").action(async () => {
27149
27528
  const running = await isDaemonRunning();
@@ -27169,7 +27548,7 @@ program.command("status").description("Check daemon and wallet status").action(a
27169
27548
  }
27170
27549
  }
27171
27550
  });
27172
- program.command("balance").description("Get wallet and API key balances").option("--api-keys", "List all stored API keys (baseUrl + key + balance)", false).option("--delete-api-keys <baseUrl>", "Delete the API key stored for the given provider base URL (refunds balance first)").option("--mint-url <url>", "Mint URL to refund the deleted API key balance to (defaults to first mint in wallet)").action(async (options) => {
27551
+ program.command("balance").description("Get wallet and API key balances").option("--api-keys", "List all stored API keys (baseUrl + key + balance)", false).option("--delete-api-keys <baseUrl>", "Delete the API key stored for the given provider base URL (refunds balance first)").option("--mint-url <url>", "Mint URL to refund the deleted API key balance to (defaults to active wallet mint)").action(async (options) => {
27173
27552
  await ensureDaemonRunning();
27174
27553
  if (options.deleteApiKeys) {
27175
27554
  const baseUrl = options.deleteApiKeys;
@@ -27714,6 +28093,13 @@ var walletCmd = program.command("wallet").description("Wallet operations");
27714
28093
  walletCmd.command("status").description("Check wallet status").action(async () => {
27715
28094
  await handleDaemonCommand("/wallet/status");
27716
28095
  });
28096
+ walletCmd.command("doctor").description("Diagnose conflicting wallets (current routstrd wallet vs legacy cocod)").action(async () => {
28097
+ const target = summarizeWalletDirectory(walletDir(), "canonical");
28098
+ const source = summarizeWalletDirectory(legacyCocodDir(), "legacy");
28099
+ console.log(renderWalletDoctor(target, source));
28100
+ if (diagnoseWallets(target, source).conflict)
28101
+ process.exit(1);
28102
+ });
27717
28103
  walletCmd.command("unlock <passphrase>").description("Unlock the wallet").action(async (passphrase) => {
27718
28104
  await handleDaemonCommand("/wallet/unlock", {
27719
28105
  method: "POST",
@@ -27723,6 +28109,68 @@ walletCmd.command("unlock <passphrase>").description("Unlock the wallet").action
27723
28109
  walletCmd.command("balance").description("Get wallet balance").action(async () => {
27724
28110
  await handleDaemonCommand("/wallet/balance");
27725
28111
  });
28112
+ walletCmd.command("cleanup").description("Clear stuck pending/in-flight wallet operations").option("--mint-url <url>", "Only clean up operations for this mint URL").option("--min-age <hours>", "Minimum age for reclaiming sends/cancelling melts, in hours (default: 168, one week; expired mint quotes are always failed)", "168").option("--dry-run", "Report what would be cleaned without applying changes", false).option("-y, --yes", "Skip confirmation prompt", false).action(async (options) => {
28113
+ const minAgeHours = Number.parseFloat(options.minAge);
28114
+ if (!Number.isFinite(minAgeHours) || minAgeHours < 0) {
28115
+ console.error(`Invalid --min-age value: ${options.minAge}`);
28116
+ process.exit(1);
28117
+ }
28118
+ if (!options.dryRun && !options.yes) {
28119
+ const rl = __require("readline").createInterface({
28120
+ input: process.stdin,
28121
+ output: process.stdout
28122
+ });
28123
+ const answer = await new Promise((resolve) => {
28124
+ rl.question("This will fail expired mint quotes, reclaim old pending sends, and cancel prepared melts. Continue? [y/N] ", (value) => {
28125
+ rl.close();
28126
+ resolve(value.trim().toLowerCase());
28127
+ });
28128
+ });
28129
+ if (answer !== "y" && answer !== "yes") {
28130
+ console.log("Aborted.");
28131
+ return;
28132
+ }
28133
+ }
28134
+ try {
28135
+ await ensureDaemonRunning();
28136
+ const result = await callDaemon("/wallet/cleanup", {
28137
+ method: "POST",
28138
+ body: {
28139
+ mintUrl: options.mintUrl,
28140
+ minAgeMs: Math.round(minAgeHours * 60 * 60 * 1000),
28141
+ dryRun: options.dryRun === true
28142
+ }
28143
+ });
28144
+ if (result.error) {
28145
+ console.log(result.error);
28146
+ process.exit(1);
28147
+ }
28148
+ const output = result.output;
28149
+ if (output) {
28150
+ const prefix = output.dryRun ? "Would clean up:" : "Cleaned up:";
28151
+ console.log(prefix);
28152
+ console.log(` Expired mint quotes failed: ${output.failedMintQuotes ?? 0}`);
28153
+ console.log(` Pending sends reclaimed: ${output.reclaimedSends ?? 0}`);
28154
+ console.log(` Prepared melts cancelled: ${output.cancelledMelts ?? 0}`);
28155
+ console.log(` Skipped (still recent or already terminal): ${output.skipped ?? 0}`);
28156
+ if (output.errors && output.errors.length > 0) {
28157
+ console.log(`
28158
+ Errors:`);
28159
+ for (const e of output.errors) {
28160
+ console.log(` - ${e.operationId}: ${e.error}`);
28161
+ }
28162
+ }
28163
+ }
28164
+ } catch (error) {
28165
+ const message = error.message;
28166
+ if (message?.includes("fetch failed") || message?.includes("Connection refused")) {
28167
+ console.error("Daemon is not running");
28168
+ process.exit(1);
28169
+ }
28170
+ console.error(message);
28171
+ process.exit(1);
28172
+ }
28173
+ });
27726
28174
  var walletReceiveCmd = walletCmd.command("receive").description("Wallet receive operations");
27727
28175
  walletReceiveCmd.command("cashu <token>").description("Receive a Cashu token").action(async (token) => {
27728
28176
  await handleDaemonCommand("/wallet/receive/cashu", {
@@ -27890,7 +28338,7 @@ serviceCmd.command("install").description("Install and start routstrd using PM2
27890
28338
  const path = __require("path");
27891
28339
  daemonPath = path.join(path.dirname(import.meta.url).replace("file://", ""), "daemon", "index.js");
27892
28340
  }
27893
- if (!existsSync11(daemonPath)) {
28341
+ if (!existsSync13(daemonPath)) {
27894
28342
  console.error(`Could not find daemon at ${daemonPath}. Did you run 'bun run build'?`);
27895
28343
  process.exit(1);
27896
28344
  }
@@ -27938,7 +28386,7 @@ program.command("restart").description("Restart the background daemon").option("
27938
28386
  for (let i2 = 0;i2 < 100; i2++) {
27939
28387
  await new Promise((resolve) => setTimeout(resolve, 100));
27940
28388
  const healthDown = !await isDaemonRunning();
27941
- const pidFileReleased = !existsSync11(pidFilePath);
28389
+ const pidFileReleased = !existsSync13(pidFilePath);
27942
28390
  if (healthDown && pidFileReleased) {
27943
28391
  break;
27944
28392
  }
@@ -28029,7 +28477,7 @@ function getLogFileForDate2(logsDir, date = new Date) {
28029
28477
  return `${logsDir}/${year}-${month}-${day}.log`;
28030
28478
  }
28031
28479
  function readLastLines(file, lines) {
28032
- const content = readFileSync3(file, "utf8");
28480
+ const content = readFileSync4(file, "utf8");
28033
28481
  const allLines = content.replace(/\r\n/g, `
28034
28482
  `).split(`
28035
28483
  `);
@@ -28043,13 +28491,13 @@ async function followLogFile(file, lines) {
28043
28491
  if (initial) {
28044
28492
  console.log(initial);
28045
28493
  }
28046
- let position = statSync2(file).size;
28494
+ let position = statSync4(file).size;
28047
28495
  while (true) {
28048
28496
  await new Promise((resolve) => setTimeout(resolve, 1000));
28049
- if (!existsSync11(file)) {
28497
+ if (!existsSync13(file)) {
28050
28498
  continue;
28051
28499
  }
28052
- const size = statSync2(file).size;
28500
+ const size = statSync4(file).size;
28053
28501
  if (size < position) {
28054
28502
  position = 0;
28055
28503
  }
@@ -28068,17 +28516,17 @@ program.command("logs").description("View daemon logs").option("-f, --follow", "
28068
28516
  const yesterday = new Date;
28069
28517
  yesterday.setDate(yesterday.getDate() - 1);
28070
28518
  const yesterdayFile = getLogFileForDate2(logsDir, yesterday);
28071
- if (!existsSync11(todayFile) && !existsSync11(yesterdayFile)) {
28519
+ if (!existsSync13(todayFile) && !existsSync13(yesterdayFile)) {
28072
28520
  console.log("No log files found. Daemon may not have started yet.");
28073
28521
  console.log(`Logs directory: ${logsDir}`);
28074
28522
  process.exit(1);
28075
28523
  }
28076
28524
  const lines = parseInt(options.lines, 10);
28077
28525
  const logFiles = [yesterdayFile, todayFile].filter((file, index, files) => {
28078
- return existsSync11(file) && files.indexOf(file) === index;
28526
+ return existsSync13(file) && files.indexOf(file) === index;
28079
28527
  });
28080
28528
  if (options.follow) {
28081
- if (existsSync11(todayFile)) {
28529
+ if (existsSync13(todayFile)) {
28082
28530
  await followLogFile(todayFile, lines);
28083
28531
  } else {
28084
28532
  console.log("No log file for today to follow.");