Merge branch 'main' of github.com-red:Routstr/routstrd into coco-integration

This commit is contained in:
redshift
2026-07-23 13:54:42 +01:00
8 changed files with 227 additions and 23 deletions
+4
View File
@@ -6,6 +6,10 @@ Routstr daemon - A CLI tool for managing routstr processes, similar to `cocod` (
routstrd is a Bun-based CLI tool that provides a background daemon for the Routstr protocol. It integrates with `cocod` for wallet management and uses the Routstr SDK to handle provider routing and model discovery.
## Routstr for Teams
For team-based routing, see [routstrd-auth](https://github.com/Routstr/routstrd-auth).
## Features
- **Daemon Mode**: Run routstrd as a background HTTP server
+2 -3
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "routstrd",
@@ -8,7 +7,7 @@
"@cashu/cashu-ts": "^4.3.0",
"@cashu/coco-core": "^1.0.1",
"@cashu/coco-sqlite-bun": "^1.0.1",
"@routstr/sdk": "^0.3.17",
"@routstr/sdk": "^0.3.18",
"@scure/bip39": "^2.2.0",
"applesauce-core": "^5.1.0",
"applesauce-relay": "^5.1.0",
@@ -96,7 +95,7 @@
"@panva/hpke-noble": ["@panva/hpke-noble@1.1.3", "", { "dependencies": { "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@noble/post-quantum": "^0.6.1" }, "peerDependencies": { "hpke": "^1.0.0" } }, "sha512-zPG7MR9x7QE7+KdYsKBO9H0vp3AdYt9/4AT3ab7T7W6SL0fdRqhgNRu8q4OGTJNLeKpdbkkRb6LhBDaA9+9xWQ=="],
"@routstr/sdk": ["@routstr/sdk@0.3.17", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "ehbp": "^0.2.3", "rxjs": "^7.8.1", "tinfoil": "^1.1.6", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-3rLsVo8bo3pQG+KzACNfRo3dz6XqfsCoFLpvuqkVNgXUyxSQEQPFRiDtUeACf+a6Xp3f9LSLknVoTeL78s28Tg=="],
"@routstr/sdk": ["@routstr/sdk@0.3.18", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "ehbp": "^0.2.3", "rxjs": "^7.8.1", "tinfoil": "^1.1.6", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-OtBZp1iURl4lQOcmKOFw0sGlgEIVJTR+nTg2MCms/fx74j3YxOJrAyQXxsy+vqoiD1KwJ6a3g7TEowluW9Yv4A=="],
"@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "routstrd",
"version": "0.3.10",
"version": "0.3.11",
"module": "src/index.ts",
"type": "module",
"private": false,
+49 -14
View File
@@ -35,6 +35,11 @@ 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;
@@ -249,22 +254,52 @@ program
.command("update")
.description("Update routstrd to the latest version")
.action(async () => {
console.log("Updating routstrd...");
const routstrdProc = Bun.spawn(["bun", "install", "-g", "routstrd"], {
stdout: "inherit",
stderr: "inherit",
});
const routstrdCode = await routstrdProc.exited;
if (routstrdCode !== 0) {
console.error("Failed to update routstrd.");
process.exit(1);
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;
}
console.log("routstrd updated successfully.\n");
console.log("routstrd has been updated!");
// Restart daemon so the new binary takes effect immediately.
await restartDaemonsAfterUpdate();
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
+41 -2
View File
@@ -28,7 +28,8 @@ import {
} from "./terminal.ts";
import { COLORS } from "./constants.ts";
import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
import type { TabId, UsageStats } from "./types.ts";
import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
import { checkForUpdates } from "../../utils/update-checker.ts";
export async function runUsageTui(): Promise<void> {
const running = await isDaemonRunning();
@@ -53,6 +54,39 @@ export async function runUsageTui(): Promise<void> {
let cleanedUp = false;
let fetching = false;
// Update-check state — re-checked at most every 210 minutes to avoid
// spamming the npm registry. The first check happens shortly after
// startup so the TUI doesn't block on a network call.
const UPDATE_CHECK_INTERVAL_MS = 210 * 60 * 1000;
const UPDATE_CHECK_DELAY_MS = 3000;
let updateInfo: UpdateInfo | null = null;
let lastUpdateCheck = 0;
let updateCheckInProgress = false;
async function maybeCheckForUpdates(): Promise<void> {
if (updateCheckInProgress) return;
const now = Date.now();
if (updateInfo && now - lastUpdateCheck < UPDATE_CHECK_INTERVAL_MS) return;
updateCheckInProgress = true;
lastUpdateCheck = now;
try {
const result = await checkForUpdates();
const outdated = result.packages.filter((p) => p.hasUpdate);
updateInfo = {
hasUpdate: result.hasUpdate,
text: outdated.length > 0
? `Update available: ${outdated.map((p) => p.label).join(", ")} ${outdated.map((p) => p.latest).join(", ")}`
: "",
};
render();
} catch {
// Silently ignore — update checks are best-effort
} finally {
updateCheckInProgress = false;
}
}
if (isInteractive) {
stdout.write(enterAlternateScreen() + hideCursor());
stdin.setRawMode?.(true);
@@ -138,7 +172,7 @@ export async function runUsageTui(): Promise<void> {
const content = renderTabContent(currentTab, stats, balance, status, width, clients);
const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
const chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
const chromeLines = chrome.split("\n").length - 1;
const footerSeparator = renderSeparator(width);
const footerLines = footerSeparator.split("\n").length - 1;
@@ -265,9 +299,14 @@ export async function runUsageTui(): Promise<void> {
await fetchData();
// Kick off the first update check after a short delay so the initial
// data fetch isn't blocked by a network round-trip to npm.
setTimeout(() => void maybeCheckForUpdates(), UPDATE_CHECK_DELAY_MS);
refreshInterval = setInterval(() => {
if (autoRefresh) {
void fetchData();
}
void maybeCheckForUpdates();
}, 2000);
}
+10 -3
View File
@@ -8,7 +8,7 @@ import {
import { vimState } from "./state.ts";
import { stripAnsi } from "./terminal.ts";
import type { BalanceInfo, StatusInfo } from "./data.ts";
import type { TabId, UsageStats } from "./types.ts";
import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
/** Format a cost value: 0.12, 1.23, 12.34, 123.45, 1.23k, 1.23m */
function formatCost(value: number): string {
@@ -24,13 +24,20 @@ function formatReqs(value: number): string {
return value.toString();
}
export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[]): string {
export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[], updateInfo?: UpdateInfo): string {
const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
const maxKey = visibleTabs.length;
const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
const fill = width - title.length - help.length - vimIndicator.length - 6;
return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}\n`;
const headerLine = `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}`;
if (updateInfo?.hasUpdate) {
const banner = `${COLORS.bold}${COLORS.yellow} ↻ UPDATE AVAILABLE — run ${COLORS.green}routstrd update${COLORS.yellow} to update${COLORS.reset}`;
return `${headerLine}\n${banner}\n`;
}
return `${headerLine}\n`;
}
export function renderSearchBar(): string {
+6
View File
@@ -1,8 +1,14 @@
import type { UsageTrackingEntry } from "../../daemon/types.ts";
import type { UsageSummary } from "../../daemon/http/usage-summary.ts";
import type { UpdateCheckResult } from "../../utils/update-checker.ts";
export type { UsageSummary };
export interface UpdateInfo {
hasUpdate: boolean;
text: string;
}
export interface UsageStats {
entries: UsageTrackingEntry[];
totalEntries: number;
+114
View File
@@ -0,0 +1,114 @@
const NPM_REGISTRY = "https://registry.npmjs.org";
/** Packages that `routstrd update` manages. */
export const UPDATE_PACKAGES = [
{ name: "routstrd", label: "routstrd" },
{ name: "@routstr/cocod", label: "cocod" },
] as const;
/**
* Fetch the latest published version of a package from the npm registry.
* Returns null if the version cannot be determined (e.g. offline, not found).
*/
export async function getLatestNpmVersion(
packageName: string,
): Promise<string | null> {
try {
const response = await fetch(
`${NPM_REGISTRY}/${encodeURIComponent(packageName)}/latest`,
);
if (!response.ok) return null;
const data = (await response.json()) as { version?: string };
return data.version ?? null;
} catch {
return null;
}
}
/**
* Get the version of a globally-installed bun package.
* Returns null when the package is not installed globally or the version
* cannot be parsed as semver (e.g. installed from a git URL).
*/
export async function getGlobalPackageVersion(
packageName: string,
): Promise<string | null> {
try {
const proc = Bun.spawn(["bun", "pm", "ls", "-g"], {
stdout: "pipe",
stderr: "ignore",
});
const output = await new Response(proc.stdout).text();
await proc.exited;
// Lines look like: ├── routstrd@0.3.10 or └── @routstr/cocod@0.0.24
const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = output.match(new RegExp(`${escaped}@([^\\s]+)`));
if (!match) return null;
const version = match[1];
// Reject non-semver versions (e.g. github:routstr/cocod#3f6ac14)
if (!version || !/^\d+\.\d+\.\d+/.test(version)) return null;
return version;
} catch {
return null;
}
}
/**
* Compare two semver version strings.
* Returns a positive number if `a` is newer, negative if `b` is newer,
* 0 if equal, or null if either value is not parseable semver.
*/
export function compareVersions(a: string, b: string): number | null {
const parse = (v: string): [number, number, number] | null => {
const match = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
if (!match?.[1] || !match[2] || !match[3]) return null;
return [
parseInt(match[1], 10),
parseInt(match[2], 10),
parseInt(match[3], 10),
];
};
const va = parse(a);
const vb = parse(b);
if (!va || !vb) return null;
const [a1, a2, a3] = va;
const [b1, b2, b3] = vb;
if (a1 !== b1) return a1 - b1;
if (a2 !== b2) return a2 - b2;
if (a3 !== b3) return a3 - b3;
return 0;
}
export interface PackageUpdate {
name: string;
label: string;
current: string | null;
latest: string | null;
hasUpdate: boolean;
}
export interface UpdateCheckResult {
hasUpdate: boolean;
packages: PackageUpdate[];
}
/**
* Check both routstrd and cocod for available updates.
* Returns a result with per-package details and an overall `hasUpdate` flag.
*/
export async function checkForUpdates(): Promise<UpdateCheckResult> {
const packages = await Promise.all(
UPDATE_PACKAGES.map(async ({ name, label }) => {
const [current, latest] = await Promise.all([
getGlobalPackageVersion(name),
getLatestNpmVersion(name),
]);
const hasUpdate = !!(current && latest && (compareVersions(current, latest) ?? -1) < 0);
return { name, label, current, latest, hasUpdate } satisfies PackageUpdate;
}),
);
return {
hasUpdate: packages.some((p) => p.hasUpdate),
packages,
};
}