import { program } from "commander"; import { startDaemon } from "./start-daemon"; import { handleDaemonCommand, callDaemon, callAuth, ensureDaemonRunning, isDaemonRunning, loadConfig, getDaemonBaseUrl, getUserNpub, } from "./utils/daemon-client"; import { listClientsAction, deleteClientAction, addClientAction, } from "./utils/clients"; import { existsSync, mkdirSync } from "fs"; import { execSync } from "child_process"; import { CONFIG_DIR, DB_PATH, CONFIG_FILE, DEFAULT_CONFIG, LOGS_DIR, type RoutstrdConfig, } from "./utils/config"; import { logger } from "./utils/logger"; import { setupIntegration, runIntegrationsForClients } from "./integrations"; import { getClientsList } from "./utils/clients"; import * as QRCode from "qrcode"; import { normalizeNostrPubkey, npubFromPubkey, npubFromSecretKey } from "./utils/nip98"; import { generateSecretKey, nip19 } from "nostr-tools"; import { isCocodInstalled, resolveCocodExecutable, } from "./daemon/wallet/cocod-client"; 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}`); } async function installCocodOrExit(): Promise { console.log("cocod not found. Installing globally with bun..."); const installProc = Bun.spawn( ["bun", "install", "--global", "@routstr/cocod"], { stdout: "inherit", stderr: "inherit", }, ); const installCode = await installProc.exited; if (installCode !== 0 || !(await isCocodInstalled())) { console.error( "Failed to install cocod. Please run 'bun install --global @routstr/cocod' manually.", ); throw new Error("cocod installation failed"); } console.log("cocod installed successfully."); } /** * Restart the routstrd and cocod daemons after an update so the new * binaries take effect immediately. Failures are collected and reported * but never roll back the update itself. */ 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..."); // Graceful stop — the /stop endpoint closes the HTTP server // (draining active connections) then exits. 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()) { throw new Error("routstrd did not stop within 5 seconds"); } console.log("routstrd daemon stopped."); console.log("Starting routstrd daemon..."); await startDaemon({ port: String(config.port || 8008), 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}`); } } // --- cocod daemon --- try { const cocodExecutable = resolveCocodExecutable(config.cocodPath); // Check whether cocod is currently running. const pingProc = Bun.spawn([cocodExecutable, "ping"], { stdout: "ignore", stderr: "ignore", }); const cocodWasRunning = (await pingProc.exited) === 0; if (!cocodWasRunning) { console.log("\ncocod daemon was not running — skipping restart."); } else { console.log("\nRestarting cocod daemon..."); const stopProc = Bun.spawn([cocodExecutable, "stop"], { stdout: "inherit", stderr: "inherit", }); const stopCode = await stopProc.exited; if (stopCode !== 0) { throw new Error(`cocod stop exited with code ${stopCode}`); } console.log("cocod daemon stopped."); // Start cocod in the background (detached, like the wallet client does). const env = { ...process.env }; const startProc = Bun.spawn([cocodExecutable, "daemon"], { stdout: "ignore", stderr: "ignore", stdin: "ignore", detached: true, env, }); startProc.unref?.(); // Poll until cocod responds to ping (max ~10 s). let started = false; for (let i = 0; i < 100; i++) { await new Promise((resolve) => setTimeout(resolve, 100)); const poll = Bun.spawn([cocodExecutable, "ping"], { stdout: "ignore", stderr: "ignore", }); if ((await poll.exited) === 0) { started = true; break; } } if (!started) { throw new Error("cocod did not come back up within 10 seconds"); } console.log("cocod daemon restarted."); } } catch (error) { const msg = error instanceof Error ? error.message : String(error); failures.push(`cocod daemon: ${msg}`); } // --- report --- if (failures.length > 0) { console.error("\n⚠ Some daemons failed to restart:"); for (const f of failures) { console.error(` - ${f}`); } console.error( "The update was applied but may not take effect until daemons are manually restarted.", ); process.exit(1); } console.log("\n✓ All daemons 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 if (!existsSync(CONFIG_DIR)) { mkdirSync(CONFIG_DIR, { recursive: true }); console.log(`Created config directory: ${CONFIG_DIR}`); } // Create initial config if (!existsSync(CONFIG_FILE)) { const config: RoutstrdConfig = { ...DEFAULT_CONFIG, cocodPath: null, }; await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2)); 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; await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2)); 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`); } if (!(await isCocodInstalled(config.cocodPath))) { if (config.cocodPath) { logger.error( `Configured cocod executable was not found: ${config.cocodPath}`, ); return; } await installCocodOrExit(); } const cocodExecutable = resolveCocodExecutable(config.cocodPath); console.log(`Database will be stored at: ${DB_PATH}`); console.log("\nInitializing cocod..."); const initProc = Bun.spawn([cocodExecutable, "init"], { stdout: "pipe", stderr: "pipe", }); let initStdout = ""; let initStderr = ""; const stdoutDone = initProc.stdout ? initProc.stdout.pipeTo( new WritableStream({ write(chunk) { const text = new TextDecoder().decode(chunk); initStdout += text; process.stdout.write(text); }, }), ) : Promise.resolve(); const stderrDone = initProc.stderr ? initProc.stderr.pipeTo( new WritableStream({ write(chunk) { const text = new TextDecoder().decode(chunk); initStderr += text; process.stderr.write(text); }, }), ) : Promise.resolve(); const [initCode] = await Promise.all([ initProc.exited, stdoutDone, stderrDone, ]); const combinedOutput = `${initStdout}\n${initStderr}`.toLowerCase(); const alreadyInitialized = combinedOutput.includes("already initialized"); if (initCode !== 0 && !alreadyInitialized) { console.error( "Failed to initialize cocod. Please run 'cocod init' manually.", ); return; } if (alreadyInitialized) { console.log("cocod is already initialized."); } else { console.log("cocod initialized successfully."); } await startDaemon({ port: String(config.port || 8008) }); 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 and cocod to the latest versions") .action(async () => { const packages = [ { name: "routstrd", label: "routstrd" }, { name: "@routstr/cocod", label: "cocod" }, ]; 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 first mint in wallet)", ) .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 balances = ( balanceResult.output as | { balances?: Record; } | undefined )?.balances; if (!balances || Object.keys(balances).length === 0) { console.log("No mint URLs found in wallet balance"); process.exit(1); } mintUrl = Object.keys(balances)[0]; 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 - configure a remote daemon URL program .command("remote ") .description("Configure a remote daemon URL") .option("--auth-url ", "URL of the auth proxy for management commands (npubs, clients, usage)") .action(async (url: string, options: { authUrl?: string }) => { 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 config = await loadConfig(); 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, }; await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2)); 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}`, ); } }); // Onboard - initialize the daemon program .command("onboard") .description( "Initialize routstrd (creates config directory and initializes cocod)", ) .action(async () => { await requireLocalDaemon(); await initDaemon(); }); // Start - start the background daemon program .command("start") .description("Start the background daemon") .option("--port ", "Port to listen on") .option("-p, --provider ", "Default provider to use") .action(async (options: { port?: string; provider?: string }) => { await requireLocalDaemon(); const config = await loadConfig(); if (!(await isCocodInstalled(config.cocodPath))) { const installHint = config.cocodPath ? `Configured cocod executable was not found: ${config.cocodPath}` : "cocod is not installed. Run 'routstrd onboard' first to install cocod."; logger.error(installHint); process.exit(1); } await startDaemon({ port: options.port || String(config.port || 8008), provider: options.provider, }); }); // 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") .action(async () => { await ensureDaemonRunning(); 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; 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}`); 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}`); } } }); // 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.", ); }); // 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("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"); }); 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 }, }); }); // ── 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 { // 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("-p, --provider ", "Default provider to use") .action(async (options: { port?: 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 daemon to fully stop 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."); } else { console.log("Daemon was not running."); } console.log("Starting daemon..."); await startDaemon({ port: options.port || String(config.port || 8008), 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, }; await Bun.write(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2)); 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), provider: config.provider || undefined, }); console.log(`Daemon restarted with mode '${selectedMode}'.`); }); // Logs function getLogFileForDate(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 `${LOGS_DIR}/${year}-${month}-${day}.log`; } program .command("logs") .description("View daemon logs") .option("-f, --follow", "Follow log output", false) .option("-n, --lines ", "Number of lines to show", "50") .action(async (options: { follow: boolean; lines: string }) => { await requireLocalDaemon(); const todayFile = getLogFileForDate(); const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const yesterdayFile = getLogFileForDate(yesterday); if (!existsSync(todayFile) && !existsSync(yesterdayFile)) { console.log("No log files found. Daemon may not have started yet."); console.log(`Logs directory: ${LOGS_DIR}`); 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) { const proc = Bun.spawn(["tail", "-n", String(lines), "-f", todayFile], { stdout: "inherit", stderr: "inherit", stdin: "inherit", }); const exitCode = await proc.exited; process.exit(exitCode); } const proc = Bun.spawn(["tail", "-n", String(lines), ...logFiles], { stdout: "inherit", stderr: "inherit", stdin: "inherit", }); const exitCode = await proc.exited; process.exit(exitCode); }); export function cli(args: string[]) { program.parse(args); }