mirror of
https://github.com/Routstr/routstrd.git
synced 2026-07-30 15:46:14 +00:00
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:
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user