import { program } from "commander"; import { startDaemon } from "./start-daemon"; import { ensureDirsSync, saveDaemonConfig } from "./daemon/config-store"; import { handleDaemonCommand, callDaemon, callAuth, ensureDaemonRunning, isDaemonRunning, loadConfig, getDaemonBaseUrl, getUserNpub, } from "./utils/daemon-client"; import { listClientsAction, deleteClientAction, addClientAction, } from "./utils/clients"; import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs"; import { execSync } from "child_process"; import { dirname, join } from "path"; import { CONFIG_DIR, DB_PATH, CONFIG_FILE, DEFAULT_CONFIG, LOGS_DIR, type RoutstrdConfig, } from "./utils/config"; import { COCO_LOGS_DIR, logger } from "./utils/logger"; import { setupIntegration, runIntegrationsForClients } from "./integrations"; import { assertLegacyCocodNotRunning, claimLegacyCocodPidFile, stopLegacyCocod, } from "./daemon/wallet/coco-client"; import { migrateLegacyWallet } from "./daemon/wallet/migration"; import { diagnoseWallets, renderWalletDoctor, summarizeWalletDirectory, WalletMigrationConflictError, } from "./daemon/wallet/diagnostics"; import { legacyCocodDir, legacyCocodPidPath, legacyCocodSocketPath, walletDir as defaultWalletDir, walletPidPath, } from "./daemon/wallet/paths"; import { getClientsList } from "./utils/clients"; import * as QRCode from "qrcode"; import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98"; import { generateSecretKey, nip19 } from "nostr-tools"; import { generateMnemonic } from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english.js"; import packageJson from "../package.json" with { type: "json" }; import { compareVersions, getGlobalPackageVersion, getLatestNpmVersion, } from "./utils/update-checker.ts"; type RoutstrModel = { id: string; name?: string; description?: string; context_length?: number; }; type UsageEntry = { id: string; timestamp: number; modelId: string; baseUrl: string; requestId: string; cost: number; satsCost: number; promptTokens: number; completionTokens: number; totalTokens: number; client?: string; }; function parsePositiveIntOrExit(value: string, fieldName: string): number { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed <= 0) { console.error(`Invalid ${fieldName}: ${value}`); process.exit(1); } return parsed; } function isPositiveIntegerString(value: string): boolean { return /^[1-9]\d*$/.test(value.trim()); } async function printLightningInvoice(invoice: string): Promise { const paymentUri = `lightning:${invoice}`; const qr = await QRCode.toString(paymentUri, { type: "terminal", small: true, }); console.log(`${qr}\nInvoice:\n${invoice}`); } export function initializeWallet(walletDir = defaultWalletDir()): void { const walletConfig = join(walletDir, "config.json"); // The wallet directory and config contain the plaintext seed phrase. Correct // permissions on existing installations as well as newly created ones. mkdirSync(walletDir, { recursive: true, mode: 0o700 }); chmodSync(walletDir, 0o700); if (existsSync(walletConfig)) { chmodSync(walletConfig, 0o600); console.log("Wallet already initialized."); return; } const mnemonic = generateMnemonic(wordlist); const config = { version: 1, mnemonic, encrypted: false, createdAt: new Date().toISOString(), }; writeFileSync(walletConfig, JSON.stringify(config, null, 2), { mode: 0o600, flag: "wx", }); console.log("Initialized. Mnemonic:", mnemonic); console.log("IMPORTANT: Write down this mnemonic and keep it safe!"); } /** * Restart the routstrd daemon after an update so the new binary takes * effect immediately. Failures are collected and reported but never * roll back the update itself. * * Note: cocod is no longer a separate process — the wallet now runs * in-process via coco-core, so there is nothing else to restart. */ async function restartDaemonsAfterUpdate(): Promise { const config = await loadConfig(); const isRemote = !!config.daemonUrl; const failures: string[] = []; // --- routstrd daemon --- if (isRemote) { console.log("\nUsing remote daemon — skipping routstrd daemon restart."); } else { try { const wasRunning = await isDaemonRunning(); if (!wasRunning) { console.log("\nroutstrd daemon was not running — skipping restart."); } else { console.log("\nRestarting routstrd daemon..."); await callDaemon("/stop", { method: "POST" }); // Wait for HTTP health check to fail AND wallet lock to be released. const pidFilePath = walletPidPath(); for (let i = 0; i < 100; i++) { await new Promise((resolve) => setTimeout(resolve, 100)); const healthDown = !(await isDaemonRunning()); const pidFileReleased = !existsSync(pidFilePath); if (healthDown && pidFileReleased) break; } if (await isDaemonRunning()) { throw new Error("routstrd did not stop within 10 seconds"); } console.log("routstrd daemon stopped."); await stopLegacyCocod(); console.log("Starting routstrd daemon..."); await startDaemon({ port: String(config.port || 8008), host: config.host || undefined, provider: config.provider || undefined, }); console.log("routstrd daemon restarted."); } } catch (error) { const msg = error instanceof Error ? error.message : String(error); failures.push(`routstrd daemon: ${msg}`); } } // --- report --- if (failures.length > 0) { console.error("\n⚠ Failed to restart daemon:"); for (const f of failures) { console.error(` - ${f}`); } console.error( "The update was applied but may not take effect until the daemon is manually restarted.", ); process.exit(1); } console.log("\n✓ Daemon restarted successfully."); } async function requireLocalDaemon(): Promise { const config = await loadConfig(); if (config.daemonUrl) { console.error( `This command is not available when using a remote daemon (${config.daemonUrl}).`, ); process.exit(1); } } async function initDaemon(): Promise { console.log("Initializing routstrd..."); // Create config directory (0700, correcting existing installs too) if (!existsSync(CONFIG_DIR)) { console.log(`Created config directory: ${CONFIG_DIR}`); } ensureDirsSync(); // Create initial config (0600, atomic write) if (!existsSync(CONFIG_FILE)) { const config: RoutstrdConfig = { ...DEFAULT_CONFIG, cocodPath: null, }; saveDaemonConfig(config); console.log(`Created config file: ${CONFIG_FILE}`); } const config = await loadConfig(); if (!config.nsec) { const secretKey = generateSecretKey(); const nsec = nip19.nsecEncode(secretKey); const npub = npubFromSecretKey(secretKey); config.nsec = nsec; saveDaemonConfig(config); console.log("\nA new Nostr identity has been generated for authentication."); console.log(`Your npub: ${npub}`); console.log(`You can view it in the config file at: ${CONFIG_FILE}\n`); } console.log(`Database will be stored at: ${DB_PATH}`); await stopLegacyCocod(); const migration = await migrateLegacyWallet({ assertLegacyStopped: () => assertLegacyCocodNotRunning({ socketPath: legacyCocodSocketPath(), }), acquireLegacyLock: () => { mkdirSync(dirname(legacyCocodPidPath()), { recursive: true, mode: 0o700, }); return claimLegacyCocodPidFile({ pidFilePath: legacyCocodPidPath(), }); }, }); if (migration.status === "migrated") { console.log(`Migrated wallet from ${migration.from} to ${migration.to}.`); for (const warning of migration.cleanupWarnings) console.warn(warning); } initializeWallet(); await startDaemon({ port: String(config.port || 8008), host: config.host || undefined }); await setupIntegration(config); console.log("\nInitialization complete!"); console.log( "\n use 'routstrd receive ' or 'routstrd receive 2100' to top up your local wallet using Lightning!", ); console.log( "\n full wallet commands still work too, e.g. 'routstrd wallet receive cashu ' and 'routstrd wallet receive bolt11 2100'.", ); console.log( "\nTo ensure routstrd persists across system restarts, run: 'routstrd service install'", ); } program .name("routstrd") .description("Routstr daemon - Manage routstr processes") .version(packageJson.version, "--version", "output the version number"); program .command("update") .description("Update routstrd to the latest version") .action(async () => { const packages = [{ name: "routstrd", label: "routstrd" }]; let updatedAny = false; for (const { name, label } of packages) { const installed = await getGlobalPackageVersion(name); const latest = await getLatestNpmVersion(name); // Only skip when we're confident the installed version is current. // If we can't determine either version we fall through to installing. if ( installed && latest && (compareVersions(installed, latest) ?? -1) >= 0 ) { console.log(`${label} is already up to date (v${installed}).`); continue; } const fromPart = installed ? ` from v${installed}` : ""; const toPart = latest ? ` to v${latest}` : ""; console.log(`Updating ${label}${fromPart}${toPart}...`); const proc = Bun.spawn(["bun", "install", "-g", name], { stdout: "inherit", stderr: "inherit", }); const code = await proc.exited; if (code !== 0) { console.error(`Failed to update ${label}.`); process.exit(1); } console.log(`${label} updated successfully.\n`); updatedAny = true; } if (updatedAny) { console.log("All requested updates have been applied!"); // Restart daemons so the new binaries take effect immediately. await restartDaemonsAfterUpdate(); } else { console.log("\nAll packages are already up to date — nothing to do."); } }); program .command("refund") .description("Refund pending tokens and API keys to a specified mint") .option( "-m, --mint-url ", "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: { mintUrl?: string; yes: boolean; xcashu: boolean }) => { await ensureDaemonRunning(); let mintUrl = options.mintUrl; if (!mintUrl) { const balanceResult = await callDaemon("/balance"); if (balanceResult.error) { console.log(balanceResult.error); process.exit(1); } const output = balanceResult.output as | { balances?: Record; activeMint?: string; } | undefined; mintUrl = output?.activeMint ?? (output?.balances ? Object.keys(output.balances)[0] : undefined); if (!mintUrl) { console.log("No mint URLs found in wallet balance"); process.exit(1); } console.log(`Using mint URL: ${mintUrl}`); } try { if (options.xcashu) { // xcashu path: only refund xcashu tokens const result = await callDaemon("/refund/xcashu", { method: "POST", body: { mintUrl }, }); if (result.error) { console.log(result.error); process.exit(1); } const output = result.output as | { message: string; results: Array<{ baseUrl: string; token: string; success: boolean; error?: string }>; } | undefined; if (output) { console.log(output.message); console.log("\nResults:"); for (const r of output.results) { const status = r.success ? "success" : `failed: ${r.error || "unknown"}`; console.log(` - ${r.baseUrl}: ${status}`); } } return; } const result = await callDaemon("/refund", { method: "POST", body: { mintUrl }, }); if (result.error) { console.log(result.error); process.exit(1); } const output = result.output as | { message: string; pendingTokens: number; apiKeys: number; results: Array<{ baseUrl: string; success: boolean }>; } | undefined; if (output) { console.log(output.message); console.log(`\nPending tokens: ${output.pendingTokens}`); console.log(`API keys: ${output.apiKeys}`); console.log("\nResults:"); for (const r of output.results) { console.log(` - ${r.baseUrl}: ${r.success ? "success" : "failed"}`); } } } catch (error) { const message = (error as Error).message; if ( message?.includes("fetch failed") || message?.includes("Connection refused") ) { console.error("Daemon is not running"); process.exit(1); } console.error(message); process.exit(1); } }); // Remote - show or configure a remote daemon URL program .command("remote [url]") .description( "Show the configured remote daemon, or configure one. With no URL, prints the current remote node; pass a URL to set one up.", ) .option("--auth-url ", "URL of the auth proxy for management commands (npubs, clients, usage)") .action(async (url: string | undefined, options: { authUrl?: string }) => { const config = await loadConfig(); // No URL provided -> show the current remote node, or tell the user to set one up. if (!url) { if (config.daemonUrl) { console.log(`Remote daemon URL: ${config.daemonUrl}`); if (config.authUrl) { console.log(`Auth proxy URL: ${config.authUrl}`); } const npub = getUserNpub(config); if (npub) { console.log(`Nostr identity: ${npub}`); } return; } console.error("No remote node is set up."); console.error( "Pass a URL to set one up, e.g. 'routstrd remote https://:'.", ); process.exit(1); } try { new URL(url); } catch { console.error(`Invalid URL: ${url}`); process.exit(1); } if (options.authUrl) { try { new URL(options.authUrl); } catch { console.error(`Invalid auth URL: ${options.authUrl}`); process.exit(1); } } if (!existsSync(CONFIG_DIR)) { mkdirSync(CONFIG_DIR, { recursive: true }); } const updates: Partial = { daemonUrl: url }; if (options.authUrl) { updates.authUrl = options.authUrl; } let generatedNpub: string | undefined; if (!config.nsec) { const secretKey = generateSecretKey(); const nsec = nip19.nsecEncode(secretKey); const npub = npubFromSecretKey(secretKey); updates.nsec = nsec; generatedNpub = npub; } const updatedConfig: RoutstrdConfig = { ...config, ...updates, }; saveDaemonConfig(updatedConfig); console.log(`Remote daemon URL set to: ${url}`); if (options.authUrl) { console.log(`Auth proxy URL set to: ${options.authUrl}`); } if (generatedNpub) { console.log( `\nA new Nostr identity has been generated for remote authentication.`, ); console.log(`Your npub: ${generatedNpub}`); console.log( `You can view it in the config file at: ${CONFIG_FILE}`, ); } }); // Local - switch back to local daemon mode (removes daemonUrl/authUrl) program .command("local") .description("Switch back to local daemon mode (removes remote daemon URL)") .action(async () => { const config = await loadConfig(); if (!config.daemonUrl) { console.log("Already in local mode — no remote daemon URL is configured."); return; } const previousDaemonUrl = config.daemonUrl; const previousAuthUrl = config.authUrl; const { daemonUrl: _daemonUrl, authUrl: _authUrl, ...rest } = config; const updatedConfig: RoutstrdConfig = rest; saveDaemonConfig(updatedConfig); console.log("Switched back to local daemon mode."); if (previousDaemonUrl) { console.log(` Removed remote daemon URL: ${previousDaemonUrl}`); } if (previousAuthUrl) { console.log(` Removed auth proxy URL: ${previousAuthUrl}`); } console.log( `\nUse 'routstrd onboard' for first time using local mode or 'routstrd start' to start the local daemon if you already are using it.`, ); console.log(`You can view the config file at: ${CONFIG_FILE}`); }); // Onboard - initialize the daemon program .command("onboard") .description( "Initialize routstrd (creates config directory and initializes wallet)", ) .action(async () => { await requireLocalDaemon(); try { await initDaemon(); } catch (error) { // An expected, user-actionable refusal — print the structured message // without Bun's unhandled-rejection source snippet and stack trace. if (error instanceof WalletMigrationConflictError) { console.error(error.message); process.exit(1); } throw error; } }); // Start - start the background daemon program .command("start") .description("Start the background daemon") .option("--port ", "Port to listen on") .option("--host ", "Bind address (default: 127.0.0.1)") .option("-p, --provider ", "Default provider to use") .action(async (options: { port?: string; host?: string; provider?: string }) => { await requireLocalDaemon(); const config = await loadConfig(); await stopLegacyCocod(); try { await startDaemon({ port: options.port || String(config.port || 8008), host: options.host || config.host || undefined, provider: options.provider, }); } catch (error) { // startDaemon embeds the failed daemon's own output in the message // (including the wallet-conflict report), so print it without a stack. console.error(error instanceof Error ? error.message : error); process.exit(1); } }); // Status - check daemon status program .command("status") .description("Check daemon and wallet status") .action(async () => { const running = await isDaemonRunning(); if (!running) { console.log("Daemon is not running"); process.exit(1); } const result = await callDaemon("/status"); if (result.error) { console.log(result.error); process.exit(1); } if (result.output !== undefined) { if (typeof result.output === "string") { console.log(result.output); } else { try { const formatted = JSON.stringify(result.output, null, 2); console.log(formatted ?? String(result.output)); } catch { console.log(String(result.output)); } } } }); // Balance - get wallet and API key balances 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 ", "Delete the API key stored for the given provider base URL (refunds balance first)", ) .option( "--mint-url ", "Mint URL to refund the deleted API key balance to (defaults to active wallet mint)", ) .action(async (options: { apiKeys: boolean; deleteApiKeys?: string; mintUrl?: string }) => { await ensureDaemonRunning(); if (options.deleteApiKeys) { const baseUrl = options.deleteApiKeys; const queryParts = [`baseUrl=${encodeURIComponent(baseUrl)}`]; if (options.mintUrl) { queryParts.push(`mintUrl=${encodeURIComponent(options.mintUrl)}`); } const result = await callDaemon( `/keys/api/delete?${queryParts.join("&")}`, { method: "DELETE" }, ); if (result.error) { console.log(result.error); process.exit(1); } const out = result.output as | { baseUrl?: string; removed?: boolean; refunded?: boolean; refundedAmount?: number; refundMessage?: string; message?: string; } | undefined; if (out) { console.log(out.message ?? `Removed API key for ${baseUrl}`); if (out.refunded && out.refundedAmount !== undefined) { console.log(` Refunded: ${out.refundedAmount} sats`); } else if (out.refundMessage) { console.log(` Refund: ${out.refundMessage}`); } } return; } if (options.apiKeys) { const result = await callDaemon("/keys/api"); if (result.error) { console.log(result.error); process.exit(1); } const data = result.output as | { apiKeys: Array<{ baseUrl: string; key: string; balance: number; lastUsed: number | null; }>; count: number; total: number; unit: string; } | undefined; console.log("=== API Keys ===\n"); if (!data || data.apiKeys.length === 0) { console.log(" No API keys stored."); return; } for (const k of data.apiKeys) { const lastUsed = k.lastUsed ? new Date(k.lastUsed).toISOString() : "never"; console.log(` ${k.baseUrl}`); console.log(` key: ${k.key}`); console.log(` balance: ${k.balance} ${data.unit}`); console.log(` lastUsed: ${lastUsed}`); console.log(""); } console.log(` Total: ${data.apiKeys.length} key(s), ${data.total} ${data.unit}`); return; } const [walletResult, keysResult] = await Promise.all([ callDaemon("/balance"), callDaemon("/keys/balance"), ]); console.log("Checking full system balance...\n"); console.log("=== Wallet Balance ==="); let totalWallet = 0; if ( walletResult.output && typeof walletResult.output === "object" && "balances" in walletResult.output ) { const balances = ( walletResult.output as { balances: Record } ).balances; for (const [mintUrl, balance] of Object.entries(balances)) { console.log(` ${mintUrl}: ${balance} sats`); totalWallet += balance; } console.log(` Total: ${totalWallet} sats`); } else if (walletResult.error) { console.error("Wallet error:", walletResult.error); } console.log("\n=== API Keys ==="); let totalApiKeys = 0; if ( keysResult.output && typeof keysResult.output === "object" && "keys" in keysResult.output ) { const keys = ( keysResult.output as { keys: Array<{ id: string; name: string; balance: number }>; } ).keys; const apiKeyEntries = keys.filter((k) => k.id.startsWith("apikey:")); for (const key of apiKeyEntries) { const name = key.name.replace("API Key: ", ""); console.log(` ${name}: ${key.balance} sats`); totalApiKeys += key.balance; } if (apiKeyEntries.length === 0) { console.log(" No API keys found"); } else { console.log(` Total: ${totalApiKeys} sats`); } } else if (keysResult.error) { console.error("Keys error:", keysResult.error); } console.log("\n=== Summary ==="); console.log( ` Wallet: ${totalWallet} sats | API Keys: ${totalApiKeys} sats`, ); console.log(` Grand Total: ${totalWallet + totalApiKeys} sats`); }); // Ping program .command("ping") .description("Test connection to the daemon") .action(async () => { await handleDaemonCommand("/ping"); }); // Refresh - refresh models and integrations program .command("refresh") .description("Refresh routstr21 models and client integrations") .action(async () => { await ensureDaemonRunning(); const config = await loadConfig(); // Refresh models via daemon API console.log("Refreshing routstr21 models..."); const result = await callDaemon("/v1/models?refresh=true"); if (result.error) { console.log(`Model refresh failed: ${result.error}`); process.exit(1); } console.log("Models refreshed."); // Refresh integrations for all clients const clients = await getClientsList(); if (clients.length > 0) { console.log(`Refreshing ${clients.length} client integration(s)...`); await runIntegrationsForClients(clients, config); console.log("Client integrations refreshed."); } else { console.log("No clients to refresh."); } }); // Models - list routstr21 models program .command("models") .description("List available routstr21 models") .option("-r, --refresh", "Force refresh routstr21 models from Nostr", false) .option("-m, --model ", "Show providers for a specific model") .action(async (options: { refresh: boolean; model?: string }) => { await ensureDaemonRunning(); if (options.model) { // Show providers for specific model const result = await callDaemon( `/models/${encodeURIComponent(options.model)}/providers`, ); if (result.error) { console.log(result.error); process.exit(1); } const modelData = result.output as | { id: string; name?: string; description?: string; context_length?: number; providers: Array<{ baseUrl: string; disabled?: boolean; pricing: { prompt: number; completion: number; request: number; max_cost: number; }; }>; } | undefined; if (!modelData) { console.log("Model not found"); process.exit(1); } console.log(`\n${modelData.name || modelData.id}`); if (modelData.description) { console.log(` ${modelData.description}`); } if (modelData.context_length) { console.log( ` Context: ${modelData.context_length.toLocaleString()} tokens`, ); } console.log(`\n Providers (${modelData.providers.length}):`); for (const provider of modelData.providers) { console.log( `\n ${provider.baseUrl}${provider.disabled ? " [Disabled]" : ""}`, ); console.log( ` Prompt: ${(provider.pricing.prompt * 1000000).toFixed(2)} sats/M tokens`, ); console.log( ` Completion: ${(provider.pricing.completion * 1000000).toFixed(2)} sats/M tokens`, ); console.log( ` Request: ${provider.pricing.request.toFixed(2)} sats`, ); console.log( ` Max cost: ${provider.pricing.max_cost.toFixed(2)} sats`, ); } console.log(""); return; } // List all models with interactive selection const result = await callDaemon( options.refresh ? "/models?refresh=true" : "/models", ); if (result.error) { console.log(result.error); process.exit(1); } if ( result.output && typeof result.output === "object" && "models" in result.output ) { const models = (result.output as { models: RoutstrModel[] }).models; if (models.length === 0) { console.log("No routstr21 models found"); } else { console.log(`\nFound ${models.length} routstr21 models:`); console.log( "(Use 'routstrd models -m ' to see providers and pricing)\n", ); models.forEach((model, i) => { const details = [ model.name && model.name !== model.id ? model.name : null, model.context_length ? `${model.context_length} ctx` : null, ] .filter(Boolean) .join(" - "); console.log( ` ${String(i + 1).padStart(2)}. ${model.id}${details ? ` (${details})` : ""}`, ); }); console.log(""); } } }); program .command("usage") .description("Show recent usage logs and total sats cost") .option("-n, --limit ", "Number of recent usage entries", "10") .action(async (options: { limit: string }) => { await ensureDaemonRunning(); const requested = Number.parseInt(options.limit, 10); const limit = Number.isFinite(requested) && requested > 0 ? Math.min(requested, 1000) : 10; const result = await callAuth(`/usage?limit=${limit}`); if (result.error) { console.log(result.error); process.exit(1); } // The daemon returns { output: UsageEntry[] } where output is the array directly const entries = (result.output as UsageEntry[] | undefined) || []; // Calculate totals from entries const totalEntries = entries.length; const totalSatsCost = entries.reduce( (sum, e) => sum + (e.satsCost || 0), 0, ); const recentSatsCost = totalSatsCost; // For now, recent = total since we don't have time window console.log(`Usage entries: showing ${entries.length} of ${totalEntries}`); console.log(`Total sats cost (all time): ${totalSatsCost.toFixed(3)} sats`); console.log(`Sats cost (shown entries): ${recentSatsCost.toFixed(3)} sats`); if (entries.length === 0) { console.log("No usage entries yet."); return; } console.log(""); entries.forEach((entry, index) => { const time = new Date(entry.timestamp).toISOString(); const provider = entry.baseUrl || "unknown"; const reqId = entry.requestId || "unknown"; const client = entry.client ? ` | client: ${entry.client}` : ""; console.log( `${index + 1}. ${time} | ${entry.modelId} | ${provider} | ${entry.satsCost.toFixed(3)} sats${client}`, ); console.log( ` tokens p/c/t: ${entry.promptTokens}/${entry.completionTokens}/${entry.totalTokens} | request: ${reqId}`, ); }); }); // Providers - list and manage providers const providersCmd = program .command("providers") .description("List and manage providers"); providersCmd .command("list") .description("List all providers with their enabled/disabled status") .option("--refresh", "Force re-fetch all Nostr events and refresh models from all enabled providers", false) .action(async (options: { refresh: boolean }) => { await ensureDaemonRunning(); const query = options.refresh ? "/providers?refresh=true" : "/providers"; const result = await callDaemon(query); if (result.error) { console.log(result.error); process.exit(1); } const output = result.output as | { providers: Array<{ index: number; baseUrl: string; disabled: boolean; }>; disabledCount: number; totalCount: number; } | undefined; if (!output?.providers) { console.log("No providers found."); return; } console.log( `Providers (${output.totalCount} total, ${output.disabledCount} disabled):\n`, ); for (const provider of output.providers) { const status = provider.disabled ? "DISABLED" : "enabled "; console.log(` [${provider.index}] ${status} ${provider.baseUrl}`); } }); providersCmd .command("disable ") .description( "Disable providers by their indices (e.g., routstrd providers disable 0 2 5)", ) .action(async (indices: string[]) => { await ensureDaemonRunning(); const indexNums = indices .map((s) => parseInt(s, 10)) .filter((n) => Number.isFinite(n)); if (indexNums.length === 0) { console.log("No valid indices provided."); process.exit(1); } const result = await callDaemon("/providers/disable", { method: "POST", body: { indices: indexNums }, }); if (result.error) { console.log(result.error); process.exit(1); } const output = result.output as | { message: string; disabled: string[] } | undefined; if (output) { console.log(output.message); for (const url of output.disabled) { console.log(` - ${url}`); } } }); providersCmd .command("enable ") .description( "Enable providers by their indices (e.g., routstrd providers enable 0 2 5)", ) .action(async (indices: string[]) => { await ensureDaemonRunning(); const indexNums = indices .map((s) => parseInt(s, 10)) .filter((n) => Number.isFinite(n)); if (indexNums.length === 0) { console.log("No valid indices provided."); process.exit(1); } const result = await callDaemon("/providers/enable", { method: "POST", body: { indices: indexNums }, }); if (result.error) { console.log(result.error); process.exit(1); } const output = result.output as | { message: string; enabled: string[] } | undefined; if (output) { console.log(output.message); for (const url of output.enabled) { console.log(` - ${url}`); } } }); providersCmd .command("reviews") .description( "Show all known providers with their stored review events and event IDs", ) .action(async () => { await ensureDaemonRunning(); const result = await callDaemon("/providers/reviews"); if (result.error) { console.log(result.error); process.exit(1); } const output = result.output as | { providers: Array<{ index: number; baseUrl: string; disabled: boolean; nodePubkeys: string[]; reviewCount: number; reviews: Array<{ eventId: string; createdAt: number; authorPubkey: string | null; nodePubkey: string | null; label: string; isLgtm: boolean; tags: string[][]; }>; }>; unmatchedReviews: Array<{ eventId: string; createdAt: number; authorPubkey: string | null; nodePubkey: string | null; label: string; isLgtm: boolean; tags: string[][]; }>; totalCount: number; reviewEventCount: number; } | undefined; if (!output) { console.log("No provider review data available."); return; } console.log( `Providers (${output.totalCount} total, ${output.reviewEventCount} stored review events):\n`, ); for (const provider of output.providers) { const status = provider.disabled ? "DISABLED" : "enabled "; console.log( ` [${provider.index}] ${status} ${provider.baseUrl} (${provider.reviewCount} review${provider.reviewCount === 1 ? "" : "s"})`, ); if (provider.nodePubkeys.length > 0) { console.log(` node pubkeys: ${provider.nodePubkeys.join(", ")}`); } for (const review of provider.reviews) { const label = review.label || "review"; const created = new Date(review.createdAt * 1000).toISOString(); console.log(` - ${label} eventId: ${review.eventId}`); console.log(` node: ${review.nodePubkey ?? "(none)"}`); console.log(` author: ${review.authorPubkey ?? "(none)"}`); console.log(` at: ${created}`); } } if (output.unmatchedReviews.length > 0) { console.log( `\n Unmatched review events (${output.unmatchedReviews.length} — node pubkey not in any known provider):`, ); for (const review of output.unmatchedReviews) { const created = new Date(review.createdAt * 1000).toISOString(); console.log(` - eventId: ${review.eventId}`); console.log(` node: ${review.nodePubkey ?? "(none)"}`); console.log(` at: ${created}`); } } }); // Clients - list and manage clients const clientsCmd = program .command("clients") .description("List and manage clients"); clientsCmd .command("list") .description("List all clients") .action(async () => { await listClientsAction(); }); clientsCmd .command("delete ") .description("Delete a client by its ID") .action(async (id: string) => { await deleteClientAction(id); }); clientsCmd .command("add") .description("Add a new client or set up client integrations") .option("-n, --name ", "Client name") .option("--opencode", "Set up OpenCode integration") .option("--openclaw", "Set up OpenClaw integration") .option("--pi-agent", "Set up Pi Agent integration") .option("--claude-code", "Set up Claude Code integration") .option("--hermes", "Set up Hermes integration") .action( async (options: { name?: string; opencode?: boolean; openclaw?: boolean; piAgent?: boolean; claudeCode?: boolean; hermes?: boolean; }) => { await addClientAction(options); }, ); // Npubs - manage npubs (admin and user roles) const npubsCmd = program .command("npubs") .description("Manage npubs on the daemon (admin or user roles)"); type NpubEntry = { npub: string; role: string; }; npubsCmd .command("list") .description("List configured npubs with their roles") .action(async () => { await ensureDaemonRunning(); const config = await loadConfig(); const userNpub = getUserNpub(config); const result = await callAuth("/npubs"); if (result.error) { console.log(result.error); process.exit(1); } // Handle both wrapped { output: { npubs } } and direct { npubs } response formats const data = (result.output as { npubs?: NpubEntry[] } | undefined)?.npubs ? result.output : result; const npubs = (data as { npubs?: NpubEntry[] } | undefined)?.npubs ?? []; if (npubs.length === 0) { console.log("No admin npubs configured. Run 'routstrd npubs register' to register yourself as the first admin."); return; } console.log(`Npubs (${npubs.length}):`); let found = false; for (const entry of npubs) { const marker = entry.npub === userNpub ? " → you" : ""; if (entry.npub === userNpub) found = true; console.log(`- ${entry.npub} [${entry.role}]${marker}`); } if (userNpub && !found) { console.log(""); console.log( "Your npub is not in the admin list. Ask the admin to add your npub:", ); console.log(` ${userNpub}`); } }); npubsCmd .command("register") .description("Register yourself as the first admin (only when no admins exist)") .action(async () => { await ensureDaemonRunning(); const config = await loadConfig(); const userNpub = getUserNpub(config); if (!userNpub) { console.error( "No Nostr identity configured. Run 'routstrd remote ' to set one up.", ); process.exit(1); } const result = await callAuth("/npubs"); if (result.error) { console.log(result.error); process.exit(1); } const data = (result.output as { npubs?: NpubEntry[] } | undefined)?.npubs ? result.output : result; const npubs = (data as { npubs?: NpubEntry[] } | undefined)?.npubs ?? []; if (npubs.length > 0) { console.log(`Admin npubs already configured (${npubs.length}). Ask your admin to add your npub. \n Your npub: ${userNpub}`); return; } const normalized = normalizeNostrPubkey(userNpub); if (!normalized) { console.error("Failed to normalize user npub."); process.exit(1); } const addResult = await callAuth("/npubs", { method: "POST", body: { npub: npubFromPubkey(normalized) }, }); if (addResult.error) { console.log(addResult.error); process.exit(1); } const output = addResult.output as | { npub?: string; added?: boolean; error?: string } | undefined; if (output?.npub) { console.log(`Successfully registered as first admin npub: ${output.npub}`); } else { console.log(`Successfully registered as first admin npub: ${userNpub}`); } }); npubsCmd .command("add ") .description("Add a npub (hex pubkey or npub1...). Defaults to 'user' role unless --role is specified.") .option("-r, --role ", "Role for the npub: 'admin' or 'user' (default: 'user')", "user") .action(async (npubArg: string, options: { role: string }) => { await ensureDaemonRunning(); const normalized = normalizeNostrPubkey(npubArg); if (!normalized) { console.error("Invalid npub value. Use npub1... or 64-char hex pubkey."); process.exit(1); } if (options.role !== "admin" && options.role !== "user") { console.error("Invalid role. Expected 'admin' or 'user'."); process.exit(1); } const body: Record = { npub: npubFromPubkey(normalized), role: options.role }; const result = await callAuth("/npubs", { method: "POST", body, }); if (result.error) { console.log(result.error); process.exit(1); } const output = result.output as | { npub?: string; role?: string; added?: boolean; error?: string } | undefined; if (output?.npub) { console.log( `${output.added ? "Added" : "Already configured"} npub: ${output.npub} [${output.role ?? "user"}]`, ); } }); npubsCmd .command("update ") .description("Update the role of an existing npub (requires admin)") .requiredOption("-r, --role ", "New role: 'admin' or 'user'") .action(async (npubArg: string, options: { role: string }) => { await ensureDaemonRunning(); const normalized = normalizeNostrPubkey(npubArg); if (!normalized) { console.error("Invalid npub value. Use npub1... or 64-char hex pubkey."); process.exit(1); } if (options.role !== "admin" && options.role !== "user") { console.error("Invalid role. Expected 'admin' or 'user'."); process.exit(1); } const result = await callAuth("/npubs", { method: "PATCH", body: { npub: npubFromPubkey(normalized), role: options.role }, }); if (result.error) { console.log(result.error); process.exit(1); } // PATCH /npubs returns { npub, pubkey, role } at the top level, not wrapped in { output } const data = (result.output ?? result) as | { npub?: string; pubkey?: string; role?: string; error?: string } | undefined; if (data?.npub) { console.log(`Updated npub ${data.npub} role to '${data.role}'.`); } else { console.log("Npub not found or update failed."); } }); npubsCmd .command("delete ") .description("Delete an npub (hex pubkey or npub1...)") .action(async (npubArg: string) => { await ensureDaemonRunning(); const normalized = normalizeNostrPubkey(npubArg); if (!normalized) { console.error("Invalid npub value. Use npub1... or 64-char hex pubkey."); process.exit(1); } const result = await callAuth( `/npubs/${encodeURIComponent(npubFromPubkey(normalized))}`, { method: "DELETE", }, ); if (result.error) { console.log(result.error); process.exit(1); } const output = result.output as | { removed?: boolean; error?: string } | undefined; console.log( output?.removed ? "Removed admin npub." : "Admin npub was not configured.", ); }); program .command("history") .description("Show wallet transaction history") .option("-n, --limit ", "Number of entries to show", "50") .option("--offset ", "Number of entries to skip", "0") .option("-v, --verbose", "Show full details including encoded Cashu tokens") .option("--json", "Output raw JSON with token objects (no encoding)") .action(async (options: { limit: string; offset: string; verbose: boolean; json: boolean }) => { await ensureDaemonRunning(); const limit = Math.min(parseInt(options.limit, 10) || 50, 1000); const offset = parseInt(options.offset, 10) || 0; const result = await callDaemon( `/wallet/history?offset=${offset}&limit=${limit}`, ); if (result.error) { console.log(result.error); process.exit(1); } const data = result.output as | { entries?: Record[]; offset?: number; limit?: number } | undefined; const entries = data?.entries || []; if (entries.length === 0) { console.log("No transaction history yet."); return; } if (options.json) { const jsonOutput = entries.map((e: Record) => { const entry = { ...e }; delete entry.encodedToken; return entry; }); console.log(JSON.stringify(jsonOutput, null, 2)); return; } if (options.verbose) { for (const entry of entries) { const e = entry as Record; const display: Record = {}; for (const key of Object.keys(e).sort()) { display[key] = e[key]; } if (e.encodedToken && e.token) { display.token = e.encodedToken; delete display.encodedToken; } console.log(JSON.stringify(display, null, 2)); } return; } const idCol = "ID"; const timeCol = "Date/Time"; const typeCol = "Type"; const mintCol = "Mint"; const amtCol = "Amount"; const rows = entries.map((entry: Record) => { const id = String(entry.id ?? ""); const time = new Date(Number(entry.createdAt)).toISOString().replace("T", " ").slice(0, 19); const type = String(entry.type ?? "").toUpperCase(); const mint = String(entry.mintUrl ?? ""); const unit = String(entry.unit ?? "sat"); const amount = `${entry.amount} ${unit}`; return { id, time, type, mint, amount }; }); const widths = { id: Math.max(idCol.length, ...rows.map((r) => r.id.length)), time: Math.max(timeCol.length, ...rows.map((r) => r.time.length)), type: Math.max(typeCol.length, ...rows.map((r) => r.type.length)), mint: Math.max(mintCol.length, ...rows.map((r) => r.mint.length)), amount: Math.max(amtCol.length, ...rows.map((r) => r.amount.length)), }; const pad = (s: string, w: number) => s.padEnd(w); const sep = Object.values(widths).map((w) => "-".repeat(w)).join(" | "); console.log( `${pad(idCol, widths.id)} | ${pad(timeCol, widths.time)} | ${pad(typeCol, widths.type)} | ${pad(mintCol, widths.mint)} | ${pad(amtCol, widths.amount)}`, ); console.log(sep); for (const row of rows) { console.log( `${pad(row.id, widths.id)} | ${pad(row.time, widths.time)} | ${pad(row.type, widths.type)} | ${pad(row.mint, widths.mint)} | ${pad(row.amount, widths.amount)}`, ); } }); // Monitor - interactive TUI program .command("monitor") .description("Open interactive TUI for usage monitoring (htop-like)") .action(async () => { const { runUsageTui } = await import("./tui/usage/index.ts"); await runUsageTui(); }); program .command("top") .description("Open interactive TUI for usage monitoring (alias for monitor)") .action(async () => { const { runUsageTui } = await import("./tui/usage/index.ts"); await runUsageTui(); }); program .command("send ") .description( "Shortcut: numbers send Cashu, non-numbers pay a Lightning invoice", ) .option("--mint-url ", "Mint URL to use") .action(async (target: string, options: { mintUrl?: string }) => { if (isPositiveIntegerString(target)) { await handleDaemonCommand("/wallet/send/cashu", { method: "POST", body: { amount: parsePositiveIntOrExit(target, "amount"), mintUrl: options.mintUrl, }, }); return; } await handleDaemonCommand("/wallet/send/bolt11", { method: "POST", body: { invoice: target, mintUrl: options.mintUrl, }, }); }); program .command("receive ") .description( "Shortcut: numbers create a Lightning invoice, non-numbers receive a Cashu token", ) .option("--mint-url ", "Mint URL to use for bolt11 receive") .action(async (value: string, options: { mintUrl?: string }) => { if (isPositiveIntegerString(value)) { try { await ensureDaemonRunning(); const result = await callDaemon("/wallet/receive/bolt11", { method: "POST", body: { amount: parsePositiveIntOrExit(value, "amount"), mintUrl: options.mintUrl, }, }); const output = result.output as | { invoice?: string; amount?: number; mintUrl?: string } | undefined; if (typeof output?.invoice === "string" && output.invoice) { await printLightningInvoice(output.invoice); return; } if (result.output !== undefined) { console.log(JSON.stringify(result.output, null, 2)); } } catch (error) { console.error((error as Error).message); process.exit(1); } return; } await handleDaemonCommand("/wallet/receive/cashu", { method: "POST", body: { token: value }, }); }); const walletCmd = program.command("wallet").description("Wallet operations"); walletCmd .command("status") .description("Check wallet status") .action(async () => { await handleDaemonCommand("/wallet/status"); }); walletCmd .command("doctor") .description("Diagnose conflicting wallets (current routstrd wallet vs legacy cocod)") .action(async () => { const target = summarizeWalletDirectory(defaultWalletDir(), "canonical"); const source = summarizeWalletDirectory(legacyCocodDir(), "legacy"); console.log(renderWalletDoctor(target, source)); if (diagnoseWallets(target, source).conflict) process.exit(1); }); walletCmd .command("unlock ") .description("Unlock the wallet") .action(async (passphrase: string) => { await handleDaemonCommand("/wallet/unlock", { method: "POST", body: { passphrase }, }); }); walletCmd .command("balance") .description("Get wallet balance") .action(async () => { await handleDaemonCommand("/wallet/balance"); }); walletCmd .command("cleanup") .description("Clear stuck pending/in-flight wallet operations") .option("--mint-url ", "Only clean up operations for this mint URL") .option( "--min-age ", "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: { mintUrl?: string; minAge: string; dryRun: boolean; yes: boolean; }) => { const minAgeHours = Number.parseFloat(options.minAge); if (!Number.isFinite(minAgeHours) || minAgeHours < 0) { console.error(`Invalid --min-age value: ${options.minAge}`); process.exit(1); } if (!options.dryRun && !options.yes) { const rl = require("readline").createInterface({ input: process.stdin, output: process.stdout, }); const answer = await new Promise((resolve) => { rl.question( "This will fail expired mint quotes, reclaim old pending sends, and cancel prepared melts. Continue? [y/N] ", (value: string) => { rl.close(); resolve(value.trim().toLowerCase()); }, ); }); if (answer !== "y" && answer !== "yes") { console.log("Aborted."); return; } } try { await ensureDaemonRunning(); const result = await callDaemon("/wallet/cleanup", { method: "POST", body: { mintUrl: options.mintUrl, minAgeMs: Math.round(minAgeHours * 60 * 60 * 1000), dryRun: options.dryRun === true, }, }); if (result.error) { console.log(result.error); process.exit(1); } const output = result.output as | { dryRun?: boolean; failedMintQuotes?: number; reclaimedSends?: number; cancelledMelts?: number; skipped?: number; errors?: Array<{ operationId: string; error: string }>; } | undefined; if (output) { const prefix = output.dryRun ? "Would clean up:" : "Cleaned up:"; console.log(prefix); console.log( ` Expired mint quotes failed: ${output.failedMintQuotes ?? 0}`, ); console.log(` Pending sends reclaimed: ${output.reclaimedSends ?? 0}`); console.log( ` Prepared melts cancelled: ${output.cancelledMelts ?? 0}`, ); console.log( ` Skipped (still recent or already terminal): ${output.skipped ?? 0}`, ); if (output.errors && output.errors.length > 0) { console.log("\nErrors:"); for (const e of output.errors) { console.log(` - ${e.operationId}: ${e.error}`); } } } } catch (error) { const message = (error as Error).message; if ( message?.includes("fetch failed") || message?.includes("Connection refused") ) { console.error("Daemon is not running"); process.exit(1); } console.error(message); process.exit(1); } }, ); const walletReceiveCmd = walletCmd .command("receive") .description("Wallet receive operations"); walletReceiveCmd .command("cashu ") .description("Receive a Cashu token") .action(async (token: string) => { await handleDaemonCommand("/wallet/receive/cashu", { method: "POST", body: { token }, }); }); walletReceiveCmd .command("bolt11 ") .description("Create a Lightning invoice") .option("--mint-url ", "Mint URL to use") .action(async (amount: string, options: { mintUrl?: string }) => { try { await ensureDaemonRunning(); const result = await callDaemon("/wallet/receive/bolt11", { method: "POST", body: { amount: parsePositiveIntOrExit(amount, "amount"), mintUrl: options.mintUrl, }, }); const output = result.output as | { invoice?: string; amount?: number; mintUrl?: string } | undefined; if (typeof output?.invoice === "string" && output.invoice) { await printLightningInvoice(output.invoice); return; } if (result.output !== undefined) { console.log(JSON.stringify(result.output, null, 2)); } } catch (error) { console.error((error as Error).message); process.exit(1); } }); const walletSendCmd = walletCmd .command("send") .description("Wallet send operations"); walletSendCmd .command("cashu ") .description("Create a Cashu token to send") .option("--mint-url ", "Mint URL to use") .action(async (amount: string, options: { mintUrl?: string }) => { await handleDaemonCommand("/wallet/send/cashu", { method: "POST", body: { amount: parsePositiveIntOrExit(amount, "amount"), mintUrl: options.mintUrl, }, }); }); walletSendCmd .command("bolt11 ") .description("Pay a Lightning invoice") .option("--mint-url ", "Mint URL to use") .action(async (invoice: string, options: { mintUrl?: string }) => { await handleDaemonCommand("/wallet/send/bolt11", { method: "POST", body: { invoice, mintUrl: options.mintUrl, }, }); }); const walletMintsCmd = walletCmd .command("mints") .description("Wallet mint operations"); walletMintsCmd .command("list") .description("List configured wallet mints") .action(async () => { await handleDaemonCommand("/wallet/mints"); }); walletMintsCmd .command("add ") .description("Add a wallet mint") .action(async (url: string) => { await handleDaemonCommand("/wallet/mints", { method: "POST", body: { url }, }); }); walletMintsCmd .command("info ") .description("Get wallet mint info") .action(async (url: string) => { await handleDaemonCommand("/wallet/mints/info", { method: "POST", body: { url }, }); }); walletMintsCmd .command("set-default ") .description("Set the default mint for wallet operations") .action(async (url: string) => { await handleDaemonCommand("/wallet/mints/default", { method: "POST", body: { url }, }); }); const walletNpcCmd = walletCmd .command("npc") .description("NPC (npubx.cash) Lightning address operations"); walletNpcCmd .command("address") .description("Show this wallet's NPC Lightning address") .action(async () => { await handleDaemonCommand("/wallet/npc/address"); }); walletNpcCmd .command("username ") .description( "Claim an NPC username (pays the claim fee from the wallet with --confirm)", ) .option("--confirm", "Confirm payment of the username claim fee") .action(async (name: string, options: { confirm?: boolean }) => { await handleDaemonCommand("/wallet/npc/username", { method: "POST", body: { username: name, confirm: options.confirm === true }, }); }); walletNpcCmd .command("sync") .description("Manually sync paid NPC quotes into the wallet") .action(async () => { await handleDaemonCommand("/wallet/npc/sync", { method: "POST" }); }); // ── NWC (Nostr Wallet Connect) commands ───────────────────────── const nwcCmd = program .command("nwc") .description("Manage NWC (Nostr Wallet Connect) integration"); nwcCmd .command("connect") .description("Connect to a Lightning wallet via NWC") .argument("[connection-string]", "NWC connection string (nostr+walletconnect://...)") .action(async (connectionString?: string) => { if (!connectionString) { // Interactive mode: prompt for connection string const rl = require("readline").createInterface({ input: process.stdin, output: process.stdout, }); connectionString = await new Promise((resolve) => { rl.question("Paste your NWC connection string: ", (answer: string) => { rl.close(); resolve(answer.trim()); }); }); } // Quick validation: must be nostr+walletconnect:// with a 64-char hex pubkey if (!/^nostr\+walletconnect:\/\/[0-9a-fA-F]{64}\?relay=/.test(connectionString)) { console.error("Invalid NWC connection string: expected nostr+walletconnect://<64-char-hex>?relay=..."); process.exit(1); } await handleDaemonCommand("/nwc/connect", { method: "POST", body: { connectionString }, }); }); nwcCmd .command("disconnect") .description("Disconnect from NWC wallet") .action(async () => { await handleDaemonCommand("/nwc/disconnect", { method: "POST", }); }); nwcCmd .command("status") .description("Show NWC connection status and wallet info") .action(async () => { await handleDaemonCommand("/nwc/status"); }); nwcCmd .command("fund ") .description("Manually fund the Cashu wallet from the connected NWC wallet") .action(async (amount: string) => { const parsedAmount = parsePositiveIntOrExit(amount, "amount"); await handleDaemonCommand("/nwc/fund", { method: "POST", body: { amount: parsedAmount }, }); }); const autoRefillCmd = nwcCmd .command("auto-refill") .description("Manage automatic wallet refill from NWC"); autoRefillCmd .command("on") .description("Enable auto-refill") .option( "--threshold ", "Refill when Cashu balance drops below this many sats", "500", ) .option("--amount ", "Refill this many sats at a time", "1000") .option( "--cooldown ", "Minimum time between refills in seconds", "300", ) .action(async (options: { threshold: string; amount: string; cooldown: string }) => { const threshold = parsePositiveIntOrExit(options.threshold, "threshold"); const amount = parsePositiveIntOrExit(options.amount, "amount"); const cooldownSec = parsePositiveIntOrExit(options.cooldown, "cooldown"); await handleDaemonCommand("/nwc/auto-refill", { method: "POST", body: { enabled: true, threshold, amount, cooldownMs: cooldownSec * 1000, }, }); }); autoRefillCmd .command("off") .description("Disable auto-refill") .action(async () => { await handleDaemonCommand("/nwc/auto-refill", { method: "POST", body: { enabled: false }, }); }); // Stop program .command("stop") .description("Stop the background daemon") .action(async () => { await handleDaemonCommand("/stop", { method: "POST" }); }); // Service - PM2 management const serviceCmd = program .command("service") .description("Manage routstrd as a system service using PM2"); serviceCmd .command("install") .description("Install and start routstrd using PM2 for persistence") .action(async () => { await requireLocalDaemon(); // 1. Check if PM2 is installed try { execSync("pm2 -v", { stdio: "ignore" }); } catch (e) { console.log("PM2 not found. Installing PM2 globally with bun..."); try { execSync("bun install -g pm2", { stdio: "inherit" }); } catch (err) { console.error( "Failed to install PM2. Please install it manually: bun install -g pm2", ); process.exit(1); } } // 2. Resolve the path to the daemon // In a global install, we want the bundled daemon in dist/daemon/index.js let daemonPath: string; try { // Try to resolve relative to this file first (works in dev and global) daemonPath = Bun.resolveSync("./daemon/index.js", import.meta.url); } catch (e) { // Fallback for some bundling scenarios const path = require("path"); daemonPath = path.join( path.dirname(import.meta.url).replace("file://", ""), "daemon", "index.js", ); } if (!existsSync(daemonPath)) { console.error( `Could not find daemon at ${daemonPath}. Did you run 'bun run build'?`, ); process.exit(1); } console.log("Starting routstrd via PM2..."); try { await stopLegacyCocod(); // Use --interpreter bun to ensure it runs with bun execSync(`pm2 start "${daemonPath}" --name routstrd --interpreter bun`, { stdio: "inherit", }); console.log("\n✅ routstrd is now managed by PM2."); console.log("\nTo ensure it starts on system reboot, run:"); console.log(" pm2 startup"); console.log(" pm2 save"); console.log("\nTo view logs:"); console.log(" pm2 logs routstrd"); } catch (err) { console.error("Failed to start routstrd via PM2."); process.exit(1); } }); serviceCmd .command("uninstall") .description("Stop and remove routstrd from PM2") .action(() => { try { execSync("pm2 delete routstrd", { stdio: "inherit" }); console.log("✅ routstrd service removed from PM2."); } catch (e) { console.error( "Failed to remove service. It might not be running in PM2.", ); } }); serviceCmd .command("logs") .description("View PM2 logs for routstrd") .action(() => { try { execSync("pm2 logs routstrd", { stdio: "inherit" }); } catch (e) { // Ignored } }); // Restart program .command("restart") .description("Restart the background daemon") .option("--port ", "Port to listen on") .option("--host ", "Bind address (default: 127.0.0.1)") .option("-p, --provider ", "Default provider to use") .action(async (options: { port?: string; host?: string; provider?: string }) => { await requireLocalDaemon(); const config = await loadConfig(); const wasRunning = await isDaemonRunning(); if (wasRunning) { console.log("Stopping daemon..."); await callDaemon("/stop", { method: "POST" }); // Wait for HTTP health check to fail AND wallet lock to be released. const pidFilePath = walletPidPath(); for (let i = 0; i < 100; i++) { await new Promise((resolve) => setTimeout(resolve, 100)); const healthDown = !(await isDaemonRunning()); const pidFileReleased = !existsSync(pidFilePath); if (healthDown && pidFileReleased) { break; } } if (await isDaemonRunning()) { logger.error("Daemon failed to stop within 10 seconds"); process.exit(1); } console.log("Daemon stopped."); } else { console.log("Daemon was not running."); } await stopLegacyCocod(); console.log("Starting daemon..."); await startDaemon({ port: options.port || String(config.port || 8008), host: options.host || config.host || undefined, provider: options.provider, }); console.log("Daemon restarted."); }); // Mode program .command("mode") .description("Set the client mode (lazyrefund/apikeys or xcashu)") .action(async () => { await requireLocalDaemon(); const config = await loadConfig(); const currentMode = config.mode || "apikeys"; console.log("Select client mode:"); console.log( " 1) lazyrefund/apikeys - Pseudonymous accounts are kept with the Routstr nodes and are refunded after 5 mins if not used.", ); console.log( " 2) xcashu (coming soon) - Balances are never kept with the nodes, all balances are refunded in response.", ); console.log(`\nCurrent mode: ${currentMode}`); const modes: Array<"apikeys" | "xcashu"> = ["apikeys", "xcashu"]; const selectedIndex = await new Promise((resolve) => { const rl = require("readline").createInterface({ input: process.stdin, output: process.stdout, }); rl.question("\nEnter choice (1-2): ", (answer: string) => { rl.close(); const num = parseInt(answer, 10); resolve(Number.isFinite(num) && num >= 1 && num <= 2 ? num - 1 : 0); }); }); const selectedMode = modes[selectedIndex]; if (selectedMode === "xcashu") { console.log( "\nxcashu mode is coming soon! Only lazyrefund/apikeys is available at this time.", ); return; } if (selectedMode === currentMode) { console.log(`Mode is already set to '${selectedMode}'. No changes made.`); return; } // Update config const updatedConfig: RoutstrdConfig = { ...config, mode: selectedMode, }; saveDaemonConfig(updatedConfig); console.log(`Mode set to '${selectedMode}'. Restarting daemon...`); // Restart daemon const wasRunning = await isDaemonRunning(); if (wasRunning) { console.log("Stopping daemon..."); await callDaemon("/stop", { method: "POST" }); for (let i = 0; i < 50; i++) { await new Promise((resolve) => setTimeout(resolve, 100)); if (!(await isDaemonRunning())) { break; } } if (await isDaemonRunning()) { logger.error("Daemon failed to stop within 5 seconds"); process.exit(1); } console.log("Daemon stopped."); } console.log("Starting daemon..."); await startDaemon({ port: String(config.port || 8008), host: config.host || undefined, provider: config.provider || undefined, }); console.log(`Daemon restarted with mode '${selectedMode}'.`); }); // Logs function getLogFileForDate(logsDir: string, date: Date = new Date()): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${logsDir}/${year}-${month}-${day}.log`; } function readLastLines(file: string, lines: number): string { const content = readFileSync(file, "utf8"); const allLines = content.replace(/\r\n/g, "\n").split("\n"); if (allLines.at(-1) === "") allLines.pop(); return allLines.slice(-lines).join("\n"); } async function followLogFile(file: string, lines: number): Promise { const initial = readLastLines(file, lines); if (initial) { console.log(initial); } let position = statSync(file).size; while (true) { await new Promise((resolve) => setTimeout(resolve, 1000)); if (!existsSync(file)) { continue; } const size = statSync(file).size; if (size < position) { position = 0; } if (size === position) { continue; } const text = await Bun.file(file).slice(position, size).text(); process.stdout.write(text); position = size; } } program .command("logs") .description("View daemon logs") .option("-f, --follow", "Follow log output", false) .option("-c, --coco", "Show Cashu wallet-engine (coco) logs instead of daemon logs", false) .option("-n, --lines ", "Number of lines to show", "50") .action(async (options: { follow: boolean; lines: string; coco: boolean }) => { await requireLocalDaemon(); const logsDir = options.coco ? COCO_LOGS_DIR : LOGS_DIR; const todayFile = getLogFileForDate(logsDir); const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const yesterdayFile = getLogFileForDate(logsDir, yesterday); if (!existsSync(todayFile) && !existsSync(yesterdayFile)) { console.log("No log files found. Daemon may not have started yet."); console.log(`Logs directory: ${logsDir}`); process.exit(1); } const lines = parseInt(options.lines, 10); const logFiles = [yesterdayFile, todayFile].filter((file, index, files) => { return existsSync(file) && files.indexOf(file) === index; }); if (options.follow) { if (existsSync(todayFile)) { await followLogFile(todayFile, lines); } else { console.log("No log file for today to follow."); } return; } for (const file of logFiles) { if (logFiles.length > 1) { console.log(`==> ${file} <==`); } const output = readLastLines(file, lines); if (output) { console.log(output); } } }); export function cli(args: string[]) { program.parse(args); }