Initial commit

This commit is contained in:
red
2026-02-24 05:55:21 +00:00
commit dfd9b2e9d2
11 changed files with 1265 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
import { program } from "commander";
import { existsSync } from "fs";
import {
CONFIG_FILE,
DEFAULT_CONFIG,
type RoutstrdConfig,
} from "./utils/config";
export interface CommandResponse {
output?: unknown;
error?: string;
}
async function loadConfig(): Promise<RoutstrdConfig> {
try {
if (existsSync(CONFIG_FILE)) {
const content = await Bun.file(CONFIG_FILE).text();
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
}
} catch (error) {
console.error("Failed to load config:", error);
}
return DEFAULT_CONFIG;
}
async function callDaemon(
path: string,
options: { method?: "GET" | "POST"; body?: object } = {},
): Promise<CommandResponse> {
const { method = "GET", body } = options;
const config = await loadConfig();
const response = await fetch(`http://localhost:${config.port}${path}`, {
method,
headers: body ? { "Content-Type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = (await response.json()) as { error?: string };
throw new Error(errorData.error || `HTTP ${response.status}`);
}
return response.json() as Promise<CommandResponse>;
}
export async function isDaemonRunning(): Promise<boolean> {
try {
const config = await loadConfig();
const response = await fetch(`http://localhost:${config.port}/health`);
return response.ok;
} catch {
return false;
}
}
export async function startDaemonProcess(): Promise<void> {
const proc = Bun.spawn({
cmd: ["bun", "run", `${import.meta.dir}/index.ts`, "daemon"],
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
});
proc.unref();
for (let i = 0; i < 50; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (await isDaemonRunning()) {
return;
}
}
throw new Error("Daemon failed to start within 5 seconds");
}
export async function ensureDaemonRunning(): Promise<void> {
if (await isDaemonRunning()) {
return;
}
console.log("Starting daemon...");
await startDaemonProcess();
}
export async function handleDaemonCommand(
path: string,
options: { method?: "GET" | "POST"; body?: object } = {},
): Promise<CommandResponse> {
try {
await ensureDaemonRunning();
const result = await callDaemon(path, options);
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));
}
}
}
return result;
} catch (error) {
const message = (error as Error).message;
if (message?.includes("fetch failed") || message?.includes("Connection refused")) {
console.error("Daemon is not running and failed to auto-start");
process.exit(1);
}
console.error(message);
process.exit(1);
}
}
export { program, callDaemon };
+159
View File
@@ -0,0 +1,159 @@
import { startDaemon } from "./daemon";
import { program, handleDaemonCommand, callDaemon, ensureDaemonRunning } from "./cli-shared";
import { existsSync, mkdirSync } from "fs";
import { join } from "path";
import {
CONFIG_DIR,
DB_PATH,
CONFIG_FILE,
DEFAULT_CONFIG,
type RoutstrdConfig,
} from "./utils/config";
const cliVersion = "0.1.0";
async function initDaemon(): Promise<void> {
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,
};
Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
console.log(`Created config file: ${CONFIG_FILE}`);
}
console.log(`Database will be stored at: ${DB_PATH}`);
console.log("\nInitializing cocod...");
// Initialize cocod
const initProc = Bun.spawn({
cmd: ["cocod", "init"],
stdout: "inherit",
stderr: "inherit",
});
const initCode = await initProc.exited;
if (initCode !== 0) {
console.error("Failed to initialize cocod. Please run 'cocod init' manually.");
} else {
console.log("cocod initialized successfully.");
}
console.log("\nInitialization complete!");
console.log(`Run 'routstrd daemon' to start the daemon.`);
}
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");
// Init - initialize the daemon
program
.command("init")
.description("Initialize routstrd (creates config directory and initializes cocod)")
.action(() => {
initDaemon();
});
// Daemon - start the background daemon
program
.command("daemon")
.description("Start the background daemon")
.option("--port <port>", "Port to listen on", "8008")
.option("-p, --provider <provider>", "Default provider to use")
.action(async (options: { port?: string; provider?: string }) => {
if (!checkCocodInstalled()) {
console.error("cocod is not installed. Run 'routstrd init' first to install cocod.");
process.exit(1);
}
await startDaemon(options);
});
// Status - check daemon status
program
.command("status")
.description("Check daemon and wallet status")
.action(async () => {
await handleDaemonCommand("/status");
});
// 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("=== Wallet Balance ===");
if (walletResult.output) {
if (typeof walletResult.output === "string") {
console.log(walletResult.output);
} else {
console.log(JSON.stringify(walletResult.output, null, 2));
}
} else if (walletResult.error) {
console.error("Wallet error:", walletResult.error);
}
console.log("\n=== API Key Balances ===");
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;
if (keys.length === 0) {
console.log("No API keys found");
} else {
for (const key of keys) {
console.log(`${key.name}: ${key.balance} sats`);
}
}
} else if (keysResult.error) {
console.error("Keys error:", keysResult.error);
}
});
// Ping
program
.command("ping")
.description("Test connection to the daemon")
.action(async () => {
await handleDaemonCommand("/ping");
});
// Stop
program
.command("stop")
.description("Stop the background daemon")
.action(async () => {
await handleDaemonCommand("/stop", { method: "POST" });
});
export function cli(args: string[]) {
program.parse(args);
}
+480
View File
@@ -0,0 +1,480 @@
import { createServer, IncomingMessage, ServerResponse } from "http";
import { Readable } from "stream";
import { ReadableStream as WebReadableStream } from "stream/web";
import { spawn } from "child_process";
import { getDecodedToken } from "@cashu/cashu-ts";
import { mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
import SQLite from "bun:sqlite";
import {
CONFIG_DIR,
DB_PATH,
SOCKET_PATH,
PID_FILE,
CONFIG_FILE,
DEFAULT_CONFIG,
type RoutstrdConfig,
} from "./utils/config";
import { logger } from "./utils/logger";
const SDK_PATH = "/home/debian/knightclaw/projects/routstr-chat/sdk/dist/index.mjs";
let sdk: any = null;
async function loadSdk() {
if (!sdk) {
sdk = await import(SDK_PATH);
}
return sdk;
}
function createBunSqliteDriver(dbPath: string) {
const db = new SQLite(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS sdk_storage (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
`);
return {
getItem<T>(key: string, defaultValue: T): T {
try {
const row = db.query("SELECT value FROM sdk_storage WHERE key = ?").get(key) as { value: string } | undefined;
if (!row || typeof row.value !== "string") return defaultValue;
try {
return JSON.parse(row.value) as T;
} catch (parseError) {
if (typeof defaultValue === "string") {
return row.value as T;
}
throw parseError;
}
} catch (error) {
logger.error(`SQLite getItem failed for key "${key}":`, error);
return defaultValue;
}
},
setItem<T>(key: string, value: T): void {
try {
db.query(
"INSERT INTO sdk_storage (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
).run(key, JSON.stringify(value));
} catch (error) {
logger.error(`SQLite setItem failed for key "${key}":`, error);
}
},
removeItem(key: string): void {
try {
db.query("DELETE FROM sdk_storage WHERE key = ?").run(key);
} catch (error) {
logger.error(`SQLite removeItem failed for key "${key}":`, error);
}
},
};
}
const REQUESTS_DIR = join(CONFIG_DIR, "requests");
async function ensureDirs(): Promise<void> {
try {
await mkdir(CONFIG_DIR, { recursive: true });
await mkdir(REQUESTS_DIR, { recursive: true });
} catch (error) {
// Directory may already exist
}
}
function parseArgs(argv: string[]): {
port: number;
provider: string | null;
} {
const portFlagIndex = argv.findIndex((arg) => arg === "--port");
const providerFlagIndex = argv.findIndex(
(arg) => arg === "--provider" || arg === "-p"
);
const port =
portFlagIndex !== -1
? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10)
: 8008;
const provider =
providerFlagIndex !== -1 ? argv[providerFlagIndex + 1]?.trim() : null;
return { port, provider };
}
async function loadConfig(): Promise<RoutstrdConfig> {
try {
if (existsSync(CONFIG_FILE)) {
const content = await Bun.file(CONFIG_FILE).text();
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
}
} catch (error) {
logger.error("Failed to load config:", error);
}
return DEFAULT_CONFIG;
}
function saveConfig(config: RoutstrdConfig): void {
Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
}
async function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
req.on("data", (chunk) => {
data += chunk.toString();
});
req.on("end", () => resolve(data));
req.on("error", reject);
});
}
async function runWalletCommand(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn("cocod", args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (error) => reject(error));
child.on("close", (code) => {
if (code && code !== 0) {
reject(
new Error(stderr.trim() || stdout.trim() || "Wallet CLI failed")
);
return;
}
resolve(stdout.trim());
});
});
}
function parseBalances(output: string): Record<string, number> {
const trimmed = output.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed) as Record<
string,
{ sats?: number } | number
>;
if (parsed && typeof parsed === "object") {
return Object.fromEntries(
Object.entries(parsed).map(([mintUrl, value]) => {
if (typeof value === "number") {
return [mintUrl, value];
}
if (value && typeof value === "object" && "sats" in value) {
return [mintUrl, Number(value.sats ?? 0)];
}
return [mintUrl, 0];
})
);
}
} catch {
// Fall back to line parsing.
}
const balances: Record<string, number> = {};
trimmed
.split("\n")
.map((line) => line.trim())
.forEach((line) => {
const match = line.match(/^(\S+):\s+(\d+)\s+s$/);
if (match) {
balances[match[1]] = Number.parseInt(match[2], 10);
}
});
return balances;
}
function parseMints(output: string): Array<{ url: string; trusted: boolean }> {
return output
.split("\n")
.map((line) => line.trim())
.map((line) => {
const urlMatch = line.match(/https?:\/\/\S+/i);
if (!urlMatch) return null;
const trustedMatch = line.match(/trusted:\s*(true|false)/i);
return {
url: urlMatch[0],
trusted: trustedMatch
? trustedMatch[1].toLowerCase() === "true"
: false,
};
})
.filter((entry): entry is { url: string; trusted: boolean } =>
Boolean(entry)
);
}
function pickTokenLine(output: string): string {
const lines = output
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return lines[lines.length - 1] || "";
}
async function main(): Promise<void> {
const args = parseArgs(process.argv);
const config = await loadConfig();
const port = args.port;
const provider = args.provider || config.provider;
await ensureDirs();
// Save updated config
const updatedConfig = { ...config, port, provider };
saveConfig(updatedConfig);
const sdkModule = await loadSdk();
const { ModelManager, getDefaultDiscoveryAdapter, getDefaultProviderRegistry, getDefaultStorageAdapter, createMemoryDriver, createSdkStore } = sdkModule;
// For now, use memory driver (can be upgraded to sqlite later)
const memoryDriver = createMemoryDriver();
const store = createSdkStore({ driver: memoryDriver });
// Get adapters (these use the default store, but we'll work with what we have)
const discoveryAdapter = getDefaultDiscoveryAdapter();
const providerRegistry = getDefaultProviderRegistry();
const storageAdapter = getDefaultStorageAdapter();
logger.log("Bootstrapping providers...");
const modelManager = new ModelManager(discoveryAdapter);
const providers = await modelManager.bootstrapProviders(false);
logger.log(`Bootstrapped ${providers.length} providers`);
await modelManager.fetchModels(providers);
logger.log("Provider bootstrap complete.");
let activeMintUrl: string | null = null;
let mintUnits: Record<string, "sat" | "msat"> = {};
const walletAdapter = {
async getBalances(): Promise<Record<string, number>> {
const output = await runWalletCommand(["balance"]);
const balances = parseBalances(output);
mintUnits = Object.fromEntries(
Object.keys(balances).map((mintUrl) => [mintUrl, "sat"])
);
if (!activeMintUrl) {
activeMintUrl = Object.keys(balances)[0] || null;
}
return balances;
},
getMintUnits(): Record<string, "sat" | "msat"> {
return mintUnits;
},
getActiveMintUrl(): string | null {
return activeMintUrl;
},
async sendToken(mintUrl: string, amount: number): Promise<string> {
const output = await runWalletCommand([
"send",
"cashu",
String(amount),
"--mint-url",
mintUrl,
]);
const token = pickTokenLine(output);
if (!token) {
throw new Error("Wallet CLI did not return a token.");
}
return token;
},
async receiveToken(
token: string
): Promise<{ success: boolean; amount: number; unit: "sat" | "msat" }> {
await runWalletCommand(["receive", "cashu", token]);
const decoded = getDecodedToken(token);
const amount = decoded?.proofs?.reduce(
(sum, proof) => sum + proof.amount,
0
);
const unit = decoded?.unit === "msat" ? "msat" : "sat";
return { success: true, amount: amount ?? 0, unit };
},
isUsingNip60(): boolean {
return false;
},
};
try {
const mintsOutput = await runWalletCommand(["mints", "list"]);
const mints = parseMints(mintsOutput);
activeMintUrl =
mints.find((mint) => mint.trusted)?.url || mints[0]?.url || null;
} catch (error) {
logger.error("Failed to read mints from wallet:", error);
}
const server = createServer(
async (req: IncomingMessage, res: ServerResponse) => {
const host = req.headers.host || "localhost";
const url = new URL(req.url || "/", `http://${host}`);
if (req.method === "GET" && url.pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === "GET" && url.pathname === "/keys/balance") {
try {
const keys: Array<{ id: string; name: string; balance: number }> = [];
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ output: { keys } }));
} catch (error) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: String(error) }));
}
return;
}
if (req.method !== "POST") {
res.writeHead(405, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Only POST is supported." }));
return;
}
let requestBody: unknown = {};
try {
const bodyText = await readBody(req);
requestBody = bodyText ? JSON.parse(bodyText) : {};
} catch (error) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: "Invalid JSON body.",
details: error instanceof Error ? error.message : String(error),
})
);
return;
}
const bodyObj = requestBody as Record<string, unknown>;
const modelId = typeof bodyObj.model === "string" ? bodyObj.model : "";
if (!modelId) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing required 'model' field." }));
return;
}
const forcedProvider =
url.searchParams.get("provider") ||
(req.headers["x-routstr-provider"] as string | undefined) ||
provider ||
undefined;
try {
const { routeRequests, InsufficientBalanceError } = sdkModule;
const response = await routeRequests({
modelId,
requestBody,
forcedProvider,
walletAdapter,
storageAdapter,
providerRegistry,
discoveryAdapter,
modelManager,
});
const isStream = bodyObj.stream === true;
if (isStream) {
const body = response.body;
if (body) {
const nodeReadable = Readable.fromWeb(
body as unknown as WebReadableStream
);
nodeReadable.pipe(res);
} else {
res.end();
}
return;
}
const responseBody = await response.json();
res.writeHead(response.status, {
"Content-Type": "application/json",
});
res.end(JSON.stringify(responseBody));
} catch (error) {
const sdkModuleError = await loadSdk();
const { InsufficientBalanceError } = sdkModuleError;
const message = error instanceof Error ? error.message : String(error);
logger.error(`[daemon] Error: ${message}`);
if (error instanceof InsufficientBalanceError) {
res.writeHead(402, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: message,
error_type: "insufficient_balance",
required: error.required,
available: error.available,
maxMintBalance: error.maxMintBalance,
maxMintUrl: error.maxMintUrl,
})
);
return;
}
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: message }));
}
}
);
// Write PID file
Bun.write(PID_FILE, String(process.pid));
// Remove old socket if exists
try {
if (existsSync(SOCKET_PATH)) {
Bun.spawn(["rm", SOCKET_PATH]);
}
} catch {
// Ignore
}
server.listen(port, async () => {
logger.log(`Routstr daemon listening on http://localhost:${port}`);
});
}
main().catch((error) => {
logger.error("Failed to start Routstr daemon:", error);
process.exit(1);
});
export async function startDaemon(options: { port?: string; provider?: string } = {}): Promise<void> {
const port = options.port ? parseInt(options.port, 10) : 8008;
const args = [...process.argv.slice(0, 2), "daemon"];
if (options.port) {
args.push("--port", options.port);
}
if (options.provider) {
args.push("--provider", options.provider);
}
const proc = Bun.spawn({
cmd: ["bun", "run", `${import.meta.dir}/daemon.ts`, ...args.slice(2)],
stdout: "inherit",
stderr: "inherit",
});
await proc.exited;
}
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bun
import { cli } from "./cli";
cli(process.argv);
+19
View File
@@ -0,0 +1,19 @@
const HOME = process.env.HOME || process.env.USERPROFILE || "";
export const CONFIG_DIR = process.env.ROUTSTRD_DIR || `${HOME}/.routstrd`;
export const SOCKET_PATH = process.env.ROUTSTRD_SOCKET || `${CONFIG_DIR}/routstrd.sock`;
export const PID_FILE = process.env.ROUTSTRD_PID || `${CONFIG_DIR}/routstrd.pid`;
export const DB_PATH = `${CONFIG_DIR}/routstr.db`;
export const CONFIG_FILE = `${CONFIG_DIR}/config.json`;
export interface RoutstrdConfig {
port: number;
provider: string | null;
cocodPath: string | null;
}
export const DEFAULT_CONFIG: RoutstrdConfig = {
port: 8008,
provider: null,
cocodPath: null,
};
+42
View File
@@ -0,0 +1,42 @@
import { appendFile, mkdir } from "fs/promises";
import { existsSync } from "fs";
import { join } from "path";
const HOME = process.env.HOME || process.env.USERPROFILE || "";
const LOG_DIR = process.env.ROUTSTRD_DIR || `${HOME}/.routstrd`;
const LOG_FILE = join(LOG_DIR, "routstrd.log");
async function ensureLogDir() {
if (!existsSync(LOG_DIR)) {
await mkdir(LOG_DIR, { recursive: true });
}
}
async function writeLog(level: string, ...args: unknown[]) {
await ensureLogDir();
const timestamp = new Date().toISOString();
const message = args
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
.join(" ");
const line = `[${timestamp}] [${level}] ${message}\n`;
try {
await appendFile(LOG_FILE, line);
} catch (error) {
console.error("Failed to write log:", error);
}
}
export const logger = {
log: (...args: unknown[]) => {
console.log(...args);
writeLog("INFO", ...args);
},
error: (...args: unknown[]) => {
console.error(...args);
writeLog("ERROR", ...args);
},
info: (...args: unknown[]) => {
console.log(...args);
writeLog("INFO", ...args);
},
};