feat: check for version updates before installing; show update banner in TUI

- 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
This commit is contained in:
redshift
2026-07-20 20:52:34 +01:00
parent 755dabbc3c
commit e07098ae8c
6 changed files with 221 additions and 32 deletions
+2 -2
View File
@@ -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",
+48 -25
View File
@@ -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
+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,
};
}