From e07098ae8c788eb6ef10115942e627dbbb381c1c Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:52:34 +0100 Subject: [PATCH] feat: check for version updates before installing; show update banner in TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract version-checking logic into src/utils/update-checker.ts (shared between CLI and TUI) - Updating routstrd... bun add v1.2.22 (6bafe260) installed routstrd@0.3.10 with binaries: - routstrd [649.00ms] done routstrd updated successfully. Updating cocod... bun add v1.2.22 (6bafe260) installed @routstr/cocod@0.0.24 with binaries: - cocod [692.00ms] done cocod updated successfully. Both routstrd and cocod have been updated! Using remote daemon — skipping routstrd daemon restart. cocod daemon was not running — skipping restart. ✓ All daemons restarted successfully. now checks npm for the latest version of each package and only reinstalls when a newer version is available; skips daemon restart when nothing was updated - TUI shows a bold yellow 'UPDATE AVAILABLE' banner below the header on all tabs when a new version is detected - TUI checks for updates at most every 210 minutes to avoid spamming the npm registry; first check fires 3s after startup (non-blocking) - Bump version to 0.3.11 --- package.json | 4 +- src/cli.ts | 73 +++++++++++++++-------- src/tui/usage/app.ts | 43 +++++++++++++- src/tui/usage/render.ts | 13 +++- src/tui/usage/types.ts | 6 ++ src/utils/update-checker.ts | 114 ++++++++++++++++++++++++++++++++++++ 6 files changed, 221 insertions(+), 32 deletions(-) create mode 100644 src/utils/update-checker.ts diff --git a/package.json b/package.json index c5b40d1..c85d9a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "routstrd", - "version": "0.3.10", + "version": "0.3.11", "module": "src/index.ts", "type": "module", "private": false, @@ -24,7 +24,7 @@ }, "dependencies": { "@cashu/cashu-ts": "^4.3.0", - "@routstr/sdk": "^0.3.17", + "@routstr/sdk": "^0.3.18", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-wallet-connect": "^6.0.0", diff --git a/src/cli.ts b/src/cli.ts index 06cdd90..477a175 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,11 @@ import { 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; @@ -363,34 +368,52 @@ program .command("update") .description("Update routstrd and cocod to the latest versions") .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("Updating cocod..."); - const cocodProc = Bun.spawn(["bun", "install", "-g", "@routstr/cocod"], { - stdout: "inherit", - stderr: "inherit", - }); - const cocodCode = await cocodProc.exited; - if (cocodCode !== 0) { - console.error("Failed to update cocod."); - process.exit(1); + 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."); } - console.log("cocod updated successfully.\n"); - - console.log("Both routstrd and cocod have been updated!"); - - // Restart daemons so the new binaries take effect immediately. - await restartDaemonsAfterUpdate(); }); program diff --git a/src/tui/usage/app.ts b/src/tui/usage/app.ts index 6eb9424..9f25b67 100644 --- a/src/tui/usage/app.ts +++ b/src/tui/usage/app.ts @@ -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 { const running = await isDaemonRunning(); @@ -53,6 +54,39 @@ export async function runUsageTui(): Promise { 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 { + 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 { 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 { 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); } diff --git a/src/tui/usage/render.ts b/src/tui/usage/render.ts index 727598e..36a0c0a 100644 --- a/src/tui/usage/render.ts +++ b/src/tui/usage/render.ts @@ -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 { diff --git a/src/tui/usage/types.ts b/src/tui/usage/types.ts index 2c0345d..fd19978 100644 --- a/src/tui/usage/types.ts +++ b/src/tui/usage/types.ts @@ -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; diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts new file mode 100644 index 0000000..465415d --- /dev/null +++ b/src/utils/update-checker.ts @@ -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 { + 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 { + 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 { + 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, + }; +}