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",
|
"name": "routstrd",
|
||||||
"version": "0.3.10",
|
"version": "0.3.11",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": false,
|
"private": false,
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cashu/cashu-ts": "^4.3.0",
|
"@cashu/cashu-ts": "^4.3.0",
|
||||||
"@routstr/sdk": "^0.3.17",
|
"@routstr/sdk": "^0.3.18",
|
||||||
"applesauce-core": "^5.1.0",
|
"applesauce-core": "^5.1.0",
|
||||||
"applesauce-relay": "^5.1.0",
|
"applesauce-relay": "^5.1.0",
|
||||||
"applesauce-wallet-connect": "^6.0.0",
|
"applesauce-wallet-connect": "^6.0.0",
|
||||||
|
|||||||
+48
-25
@@ -36,6 +36,11 @@ import {
|
|||||||
resolveCocodExecutable,
|
resolveCocodExecutable,
|
||||||
} from "./daemon/wallet/cocod-client";
|
} from "./daemon/wallet/cocod-client";
|
||||||
import packageJson from "../package.json" with { type: "json" };
|
import packageJson from "../package.json" with { type: "json" };
|
||||||
|
import {
|
||||||
|
compareVersions,
|
||||||
|
getGlobalPackageVersion,
|
||||||
|
getLatestNpmVersion,
|
||||||
|
} from "./utils/update-checker.ts";
|
||||||
|
|
||||||
type RoutstrModel = {
|
type RoutstrModel = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -363,34 +368,52 @@ program
|
|||||||
.command("update")
|
.command("update")
|
||||||
.description("Update routstrd and cocod to the latest versions")
|
.description("Update routstrd and cocod to the latest versions")
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
console.log("Updating routstrd...");
|
const packages = [
|
||||||
const routstrdProc = Bun.spawn(["bun", "install", "-g", "routstrd"], {
|
{ name: "routstrd", label: "routstrd" },
|
||||||
stdout: "inherit",
|
{ name: "@routstr/cocod", label: "cocod" },
|
||||||
stderr: "inherit",
|
];
|
||||||
});
|
|
||||||
const routstrdCode = await routstrdProc.exited;
|
let updatedAny = false;
|
||||||
if (routstrdCode !== 0) {
|
|
||||||
console.error("Failed to update routstrd.");
|
for (const { name, label } of packages) {
|
||||||
process.exit(1);
|
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...");
|
if (updatedAny) {
|
||||||
const cocodProc = Bun.spawn(["bun", "install", "-g", "@routstr/cocod"], {
|
console.log("All requested updates have been applied!");
|
||||||
stdout: "inherit",
|
// Restart daemons so the new binaries take effect immediately.
|
||||||
stderr: "inherit",
|
await restartDaemonsAfterUpdate();
|
||||||
});
|
} else {
|
||||||
const cocodCode = await cocodProc.exited;
|
console.log("\nAll packages are already up to date — nothing to do.");
|
||||||
if (cocodCode !== 0) {
|
|
||||||
console.error("Failed to update cocod.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
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
|
program
|
||||||
|
|||||||
+41
-2
@@ -28,7 +28,8 @@ import {
|
|||||||
} from "./terminal.ts";
|
} from "./terminal.ts";
|
||||||
import { COLORS } from "./constants.ts";
|
import { COLORS } from "./constants.ts";
|
||||||
import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.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> {
|
export async function runUsageTui(): Promise<void> {
|
||||||
const running = await isDaemonRunning();
|
const running = await isDaemonRunning();
|
||||||
@@ -53,6 +54,39 @@ export async function runUsageTui(): Promise<void> {
|
|||||||
let cleanedUp = false;
|
let cleanedUp = false;
|
||||||
let fetching = 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) {
|
if (isInteractive) {
|
||||||
stdout.write(enterAlternateScreen() + hideCursor());
|
stdout.write(enterAlternateScreen() + hideCursor());
|
||||||
stdin.setRawMode?.(true);
|
stdin.setRawMode?.(true);
|
||||||
@@ -138,7 +172,7 @@ export async function runUsageTui(): Promise<void> {
|
|||||||
|
|
||||||
const content = renderTabContent(currentTab, stats, balance, status, width, clients);
|
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 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 chromeLines = chrome.split("\n").length - 1;
|
||||||
const footerSeparator = renderSeparator(width);
|
const footerSeparator = renderSeparator(width);
|
||||||
const footerLines = footerSeparator.split("\n").length - 1;
|
const footerLines = footerSeparator.split("\n").length - 1;
|
||||||
@@ -265,9 +299,14 @@ export async function runUsageTui(): Promise<void> {
|
|||||||
|
|
||||||
await fetchData();
|
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(() => {
|
refreshInterval = setInterval(() => {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
void fetchData();
|
void fetchData();
|
||||||
}
|
}
|
||||||
|
void maybeCheckForUpdates();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -8,7 +8,7 @@ import {
|
|||||||
import { vimState } from "./state.ts";
|
import { vimState } from "./state.ts";
|
||||||
import { stripAnsi } from "./terminal.ts";
|
import { stripAnsi } from "./terminal.ts";
|
||||||
import type { BalanceInfo, StatusInfo } from "./data.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 */
|
/** Format a cost value: 0.12, 1.23, 12.34, 123.45, 1.23k, 1.23m */
|
||||||
function formatCost(value: number): string {
|
function formatCost(value: number): string {
|
||||||
@@ -24,13 +24,20 @@ function formatReqs(value: number): string {
|
|||||||
return value.toString();
|
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 title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
|
||||||
const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
|
const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
|
||||||
const maxKey = visibleTabs.length;
|
const maxKey = visibleTabs.length;
|
||||||
const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
|
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;
|
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 {
|
export function renderSearchBar(): string {
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import type { UsageTrackingEntry } from "../../daemon/types.ts";
|
import type { UsageTrackingEntry } from "../../daemon/types.ts";
|
||||||
import type { UsageSummary } from "../../daemon/http/usage-summary.ts";
|
import type { UsageSummary } from "../../daemon/http/usage-summary.ts";
|
||||||
|
import type { UpdateCheckResult } from "../../utils/update-checker.ts";
|
||||||
|
|
||||||
export type { UsageSummary };
|
export type { UsageSummary };
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
hasUpdate: boolean;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsageStats {
|
export interface UsageStats {
|
||||||
entries: UsageTrackingEntry[];
|
entries: UsageTrackingEntry[];
|
||||||
totalEntries: number;
|
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