Files
routstrd/src/cli.ts
T
2026-03-21 22:59:06 +00:00

555 lines
16 KiB
TypeScript

import { startDaemon } from "./start-daemon";
import {
program,
handleDaemonCommand,
callDaemon,
ensureDaemonRunning,
isDaemonRunning,
loadConfig,
} from "./cli-shared";
import { existsSync, mkdirSync } from "fs";
import {
CONFIG_DIR,
DB_PATH,
CONFIG_FILE,
DEFAULT_CONFIG,
LOG_FILE,
type RoutstrdConfig,
} from "./utils/config";
import { logger } from "./utils/logger";
import { setupIntegration } from "./integrations";
import { createSdkStore } from "@routstr/sdk";
import { createBunSqliteDriver } from "./daemon/sqlite-driver";
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;
};
const cliVersion = "0.1.0";
async function initDaemon(): Promise<void> {
logger.log("Initializing routstrd...");
if (!(await checkCocodInstalled())) {
logger.log("cocod not found. Installing globally with bun...");
const installProc = Bun.spawn(["bun", "install", "--global", "cocod"], {
stdout: "inherit",
stderr: "inherit",
});
const installCode = await installProc.exited;
if (installCode !== 0 || !(await checkCocodInstalled())) {
logger.error("Failed to install cocod. Please run 'bun install --global cocod' manually.");
return;
}
logger.log("cocod installed successfully.");
}
// Create config directory
if (!existsSync(CONFIG_DIR)) {
mkdirSync(CONFIG_DIR, { recursive: true });
logger.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));
logger.log(`Created config file: ${CONFIG_FILE}`);
}
console.log(`Database will be stored at: ${DB_PATH}`);
console.log("\nInitializing cocod...");
const initProc = Bun.spawn(["cocod", "init"], {
stdout: "pipe",
stderr: "pipe",
});
let initStdout = "";
let initStderr = "";
const stdoutDone = initProc.stdout
? initProc.stdout.pipeTo(
new WritableStream<Uint8Array>({
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<Uint8Array>({
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) {
logger.error("Failed to initialize cocod. Please run 'cocod init' manually.");
return;
}
if (alreadyInitialized) {
logger.log("cocod is already initialized.");
} else {
logger.log("cocod initialized successfully.");
}
const config = await loadConfig();
await startDaemon({ port: String(config.port || 8008) });
// Create SDK store for integrations
const sqliteDriver = createBunSqliteDriver(DB_PATH);
const { store } = await createSdkStore({ driver: sqliteDriver });
await setupIntegration(config, store);
logger.log("\nInitialization complete!");
logger.log("\n use 'cocod receive cashu' or 'cocod receive bolt11 2100' to top up your local wallet!");
}
async function checkCocodInstalled(): Promise<boolean> {
try {
const proc = Bun.spawn({
cmd: ["which", "cocod"],
stdout: "pipe",
});
const code = await proc.exited;
return code === 0;
} catch {
return false;
}
}
program
.name("routstrd")
.description("Routstr daemon - Manage routstr processes")
.version(cliVersion, "--version", "output the version number");
// Onboard - initialize the daemon
program
.command("onboard")
.description("Initialize routstrd (creates config directory and initializes cocod)")
.action(async () => {
await initDaemon();
});
// Start - start the background daemon
program
.command("start")
.description("Start the background daemon")
.option("--port <port>", "Port to listen on")
.option("-p, --provider <provider>", "Default provider to use")
.action(async (options: { port?: string; provider?: string }) => {
if (!(await checkCocodInstalled())) {
logger.error("cocod is not installed. Run 'routstrd onboard' first to install cocod.");
process.exit(1);
}
const config = await loadConfig();
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<string, number> }).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");
});
// Models - list routstr21 models
program
.command("models")
.description("List available routstr21 models")
.option("-r, --refresh", "Force refresh routstr21 models from Nostr", false)
.action(async (options: { refresh: boolean }) => {
await ensureDaemonRunning();
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:`);
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(`${i + 1}. ${model.id}${details ? ` (${details})` : ""}`);
});
}
}
});
program
.command("usage")
.description("Show recent usage logs and total sats cost")
.option("-n, --limit <number>", "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 callDaemon(`/usage?limit=${limit}`);
if (result.error) {
console.log(result.error);
process.exit(1);
}
const output = result.output as
| {
entries?: UsageEntry[];
totalEntries?: number;
totalSatsCost?: number;
recentSatsCost?: number;
limit?: number;
}
| undefined;
const entries = output?.entries || [];
const totalEntries = output?.totalEntries || 0;
const totalSatsCost = output?.totalSatsCost || 0;
const recentSatsCost = output?.recentSatsCost || 0;
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}`,
);
});
});
// Monitor - interactive TUI
program
.command("monitor")
.description("Open interactive TUI for usage monitoring (htop-like)")
.action(async () => {
const { runUsageTui } = await import("./cli/usage-tui");
await runUsageTui();
});
// Stop
program
.command("stop")
.description("Stop the background daemon")
.action(async () => {
await handleDaemonCommand("/stop", { method: "POST" });
});
// Restart
program
.command("restart")
.description("Restart the background daemon")
.option("--port <port>", "Port to listen on")
.option("-p, --provider <provider>", "Default provider to use")
.action(async (options: { port?: string; provider?: string }) => {
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 (xcashu, lazyrefund, or apikeys)")
.action(async () => {
const config = await loadConfig();
const currentMode = config.mode || "apikeys";
console.log("Select client mode:");
console.log(" 1) apikeys - Pseudonymous accounts are kept with the Routstr nodes for easy topup and refunds.");
console.log(" 2) xcashu - Balances are never kept with the nodes, all balances are refunded in response.");
console.log(" 3) lazyrefund - Refunds are performed periodically which also means identities are reset periodically.");
console.log(`\nCurrent mode: ${currentMode}`);
const modes: Array<"apikeys" | "xcashu" | "lazyrefund"> = ["apikeys", "xcashu", "lazyrefund"];
const selectedIndex = await new Promise<number>((resolve) => {
const rl = require("readline").createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question("\nEnter choice (1-3): ", (answer: string) => {
rl.close();
const num = parseInt(answer, 10);
resolve(Number.isFinite(num) && num >= 1 && num <= 3 ? num - 1 : 0);
});
});
const selectedMode = modes[selectedIndex];
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
program
.command("logs")
.description("View daemon logs")
.option("-f, --follow", "Follow log output", false)
.option("-n, --lines <number>", "Number of lines to show", "50")
.action(async (options: { follow: boolean; lines: string }) => {
if (!existsSync(LOG_FILE)) {
console.log("No log file found. Daemon may not have started yet.");
process.exit(1);
}
const lines = parseInt(options.lines, 10);
const readLastLines = async (): Promise<string[]> => {
const content = await Bun.file(LOG_FILE).text();
const allLines = content.split("\n").filter(Boolean);
return allLines.slice(-lines);
};
const printLines = async (): Promise<void> => {
const lastLines = await readLastLines();
for (const line of lastLines) {
console.log(line);
}
};
if (options.follow) {
const logFile = Bun.file(LOG_FILE);
const initialContent = await logFile.text();
let lastSize = initialContent.length;
await printLines();
const interval = setInterval(async () => {
const content = await Bun.file(LOG_FILE).text();
const currentSize = content.length;
if (currentSize > lastSize) {
const allLines = content.split("\n").filter(Boolean);
const newLines = allLines.slice(Math.floor(lastSize === 0 ? 0 : -1), -1);
for (const line of newLines) {
console.log(line);
}
lastSize = currentSize;
}
}, 1000);
process.on("SIGINT", () => {
clearInterval(interval);
process.exit(0);
});
} else {
await printLines();
}
});
export function cli(args: string[]) {
program.parse(args);
}