routstrd 0.3.10 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,10 @@ Routstr daemon - A CLI tool for managing routstr processes, similar to `cocod` (
6
6
 
7
7
  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.
8
8
 
9
+ ## Routstr for Teams
10
+
11
+ For team-based routing, see [routstrd-auth](https://github.com/Routstr/routstrd-auth).
12
+
9
13
  ## Features
10
14
 
11
15
  - **Daemon Mode**: Run routstrd as a background HTTP server
package/bun.lock CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "routstrd",
6
6
  "dependencies": {
7
7
  "@cashu/cashu-ts": "^4.3.0",
8
- "@routstr/sdk": "^0.3.17",
8
+ "@routstr/sdk": "^0.3.18",
9
9
  "applesauce-core": "^5.1.0",
10
10
  "applesauce-relay": "^5.1.0",
11
11
  "applesauce-wallet-connect": "^6.0.0",
@@ -88,7 +88,7 @@
88
88
 
89
89
  "@panva/hpke-noble": ["@panva/hpke-noble@1.1.2", "", { "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-Gr/jKr/ErpRow8aHK+SAp0sAaWy7iJGHlFsXsdTjD8ULcN4vvsVVWR3fiD6Ogf+VoSB28XY/xWRDUEIGXw+9aA=="],
90
90
 
91
- "@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=="],
91
+ "@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=="],
92
92
 
93
93
  "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="],
94
94
 
package/dist/index.js CHANGED
@@ -20769,6 +20769,85 @@ var require_browser = __commonJS((exports) => {
20769
20769
  });
20770
20770
  });
20771
20771
 
20772
+ // src/utils/update-checker.ts
20773
+ async function getLatestNpmVersion(packageName) {
20774
+ try {
20775
+ const response = await fetch(`${NPM_REGISTRY}/${encodeURIComponent(packageName)}/latest`);
20776
+ if (!response.ok)
20777
+ return null;
20778
+ const data = await response.json();
20779
+ return data.version ?? null;
20780
+ } catch {
20781
+ return null;
20782
+ }
20783
+ }
20784
+ async function getGlobalPackageVersion(packageName) {
20785
+ try {
20786
+ const proc = Bun.spawn(["bun", "pm", "ls", "-g"], {
20787
+ stdout: "pipe",
20788
+ stderr: "ignore"
20789
+ });
20790
+ const output = await new Response(proc.stdout).text();
20791
+ await proc.exited;
20792
+ const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20793
+ const match = output.match(new RegExp(`${escaped}@([^\\s]+)`));
20794
+ if (!match)
20795
+ return null;
20796
+ const version = match[1];
20797
+ if (!version || !/^\d+\.\d+\.\d+/.test(version))
20798
+ return null;
20799
+ return version;
20800
+ } catch {
20801
+ return null;
20802
+ }
20803
+ }
20804
+ function compareVersions(a, b) {
20805
+ const parse5 = (v) => {
20806
+ const match = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
20807
+ if (!match?.[1] || !match[2] || !match[3])
20808
+ return null;
20809
+ return [
20810
+ parseInt(match[1], 10),
20811
+ parseInt(match[2], 10),
20812
+ parseInt(match[3], 10)
20813
+ ];
20814
+ };
20815
+ const va = parse5(a);
20816
+ const vb = parse5(b);
20817
+ if (!va || !vb)
20818
+ return null;
20819
+ const [a1, a2, a3] = va;
20820
+ const [b1, b2, b3] = vb;
20821
+ if (a1 !== b1)
20822
+ return a1 - b1;
20823
+ if (a2 !== b2)
20824
+ return a2 - b2;
20825
+ if (a3 !== b3)
20826
+ return a3 - b3;
20827
+ return 0;
20828
+ }
20829
+ async function checkForUpdates() {
20830
+ const packages = await Promise.all(UPDATE_PACKAGES.map(async ({ name, label }) => {
20831
+ const [current, latest] = await Promise.all([
20832
+ getGlobalPackageVersion(name),
20833
+ getLatestNpmVersion(name)
20834
+ ]);
20835
+ const hasUpdate = !!(current && latest && (compareVersions(current, latest) ?? -1) < 0);
20836
+ return { name, label, current, latest, hasUpdate };
20837
+ }));
20838
+ return {
20839
+ hasUpdate: packages.some((p) => p.hasUpdate),
20840
+ packages
20841
+ };
20842
+ }
20843
+ var NPM_REGISTRY = "https://registry.npmjs.org", UPDATE_PACKAGES;
20844
+ var init_update_checker = __esm(() => {
20845
+ UPDATE_PACKAGES = [
20846
+ { name: "routstrd", label: "routstrd" },
20847
+ { name: "@routstr/cocod", label: "cocod" }
20848
+ ];
20849
+ });
20850
+
20772
20851
  // src/tui/usage/constants.ts
20773
20852
  function getVisibleTabs(hasNpubs) {
20774
20853
  const tabs = ALL_TABS.filter((t) => t.id !== "npubs" || hasNpubs);
@@ -21036,13 +21115,20 @@ function formatReqs(value) {
21036
21115
  return (value / 1000).toFixed(1) + "k";
21037
21116
  return value.toString();
21038
21117
  }
21039
- function renderHeader(activeTab, width, visibleTabs) {
21118
+ function renderHeader(activeTab, width, visibleTabs, updateInfo) {
21040
21119
  const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
21041
21120
  const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
21042
21121
  const maxKey = visibleTabs.length;
21043
21122
  const help = `${COLORS.dim}[Q] Quit [\u2191\u2193] Scroll [\u2190\u2192] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
21044
21123
  const fill = width - title.length - help.length - vimIndicator.length - 6;
21045
- return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}
21124
+ const headerLine = `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}`;
21125
+ if (updateInfo?.hasUpdate) {
21126
+ const banner = `${COLORS.bold}${COLORS.yellow} \u21BB UPDATE AVAILABLE \u2014 run ${COLORS.green}routstrd update${COLORS.yellow} to update${COLORS.reset}`;
21127
+ return `${headerLine}
21128
+ ${banner}
21129
+ `;
21130
+ }
21131
+ return `${headerLine}
21046
21132
  `;
21047
21133
  }
21048
21134
  function renderSearchBar() {
@@ -21550,6 +21636,31 @@ async function runUsageTui() {
21550
21636
  let autoRefresh = true;
21551
21637
  let cleanedUp = false;
21552
21638
  let fetching = false;
21639
+ const UPDATE_CHECK_INTERVAL_MS = 210 * 60 * 1000;
21640
+ const UPDATE_CHECK_DELAY_MS = 3000;
21641
+ let updateInfo = null;
21642
+ let lastUpdateCheck = 0;
21643
+ let updateCheckInProgress = false;
21644
+ async function maybeCheckForUpdates() {
21645
+ if (updateCheckInProgress)
21646
+ return;
21647
+ const now2 = Date.now();
21648
+ if (updateInfo && now2 - lastUpdateCheck < UPDATE_CHECK_INTERVAL_MS)
21649
+ return;
21650
+ updateCheckInProgress = true;
21651
+ lastUpdateCheck = now2;
21652
+ try {
21653
+ const result = await checkForUpdates();
21654
+ const outdated = result.packages.filter((p) => p.hasUpdate);
21655
+ updateInfo = {
21656
+ hasUpdate: result.hasUpdate,
21657
+ text: outdated.length > 0 ? `Update available: ${outdated.map((p) => p.label).join(", ")} ${outdated.map((p) => p.latest).join(", ")}` : ""
21658
+ };
21659
+ render2();
21660
+ } catch {} finally {
21661
+ updateCheckInProgress = false;
21662
+ }
21663
+ }
21553
21664
  if (isInteractive) {
21554
21665
  stdout.write(enterAlternateScreen() + hideCursor());
21555
21666
  stdin.setRawMode?.(true);
@@ -21620,7 +21731,7 @@ Press Q to quit.`);
21620
21731
  }
21621
21732
  const content = renderTabContent(currentTab, stats, balance, status, width, clients);
21622
21733
  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}` : ""}`;
21623
- const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
21734
+ const chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
21624
21735
  const chromeLines = chrome.split(`
21625
21736
  `).length - 1;
21626
21737
  const footerSeparator = renderSeparator(width);
@@ -21786,10 +21897,12 @@ Press Q to quit.`);
21786
21897
  if (isInteractive)
21787
21898
  stdin.on("data", handleKey);
21788
21899
  await fetchData();
21900
+ setTimeout(() => void maybeCheckForUpdates(), UPDATE_CHECK_DELAY_MS);
21789
21901
  refreshInterval = setInterval(() => {
21790
21902
  if (autoRefresh) {
21791
21903
  fetchData();
21792
21904
  }
21905
+ maybeCheckForUpdates();
21793
21906
  }, 2000);
21794
21907
  }
21795
21908
  var init_app = __esm(() => {
@@ -21798,6 +21911,7 @@ var init_app = __esm(() => {
21798
21911
  init_state();
21799
21912
  init_constants();
21800
21913
  init_render();
21914
+ init_update_checker();
21801
21915
  });
21802
21916
 
21803
21917
  // src/tui/usage/index.ts
@@ -22609,7 +22723,7 @@ async function isCocodInstalled(cocodPath) {
22609
22723
  // package.json
22610
22724
  var package_default = {
22611
22725
  name: "routstrd",
22612
- version: "0.3.10",
22726
+ version: "0.3.11",
22613
22727
  module: "src/index.ts",
22614
22728
  type: "module",
22615
22729
  private: false,
@@ -22633,7 +22747,7 @@ var package_default = {
22633
22747
  },
22634
22748
  dependencies: {
22635
22749
  "@cashu/cashu-ts": "^4.3.0",
22636
- "@routstr/sdk": "^0.3.17",
22750
+ "@routstr/sdk": "^0.3.18",
22637
22751
  "applesauce-core": "^5.1.0",
22638
22752
  "applesauce-relay": "^5.1.0",
22639
22753
  "applesauce-wallet-connect": "^6.0.0",
@@ -22647,6 +22761,7 @@ var package_default = {
22647
22761
  };
22648
22762
 
22649
22763
  // src/cli.ts
22764
+ init_update_checker();
22650
22765
  function parsePositiveIntOrExit(value, fieldName) {
22651
22766
  const parsed = Number.parseInt(value, 10);
22652
22767
  if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -22878,32 +22993,41 @@ To ensure routstrd persists across system restarts, run: 'routstrd service insta
22878
22993
  }
22879
22994
  program.name("routstrd").description("Routstr daemon - Manage routstr processes").version(package_default.version, "--version", "output the version number");
22880
22995
  program.command("update").description("Update routstrd and cocod to the latest versions").action(async () => {
22881
- console.log("Updating routstrd...");
22882
- const routstrdProc = Bun.spawn(["bun", "install", "-g", "routstrd"], {
22883
- stdout: "inherit",
22884
- stderr: "inherit"
22885
- });
22886
- const routstrdCode = await routstrdProc.exited;
22887
- if (routstrdCode !== 0) {
22888
- console.error("Failed to update routstrd.");
22889
- process.exit(1);
22890
- }
22891
- console.log(`routstrd updated successfully.
22996
+ const packages = [
22997
+ { name: "routstrd", label: "routstrd" },
22998
+ { name: "@routstr/cocod", label: "cocod" }
22999
+ ];
23000
+ let updatedAny = false;
23001
+ for (const { name, label } of packages) {
23002
+ const installed = await getGlobalPackageVersion(name);
23003
+ const latest = await getLatestNpmVersion(name);
23004
+ if (installed && latest && (compareVersions(installed, latest) ?? -1) >= 0) {
23005
+ console.log(`${label} is already up to date (v${installed}).`);
23006
+ continue;
23007
+ }
23008
+ const fromPart = installed ? ` from v${installed}` : "";
23009
+ const toPart = latest ? ` to v${latest}` : "";
23010
+ console.log(`Updating ${label}${fromPart}${toPart}...`);
23011
+ const proc = Bun.spawn(["bun", "install", "-g", name], {
23012
+ stdout: "inherit",
23013
+ stderr: "inherit"
23014
+ });
23015
+ const code = await proc.exited;
23016
+ if (code !== 0) {
23017
+ console.error(`Failed to update ${label}.`);
23018
+ process.exit(1);
23019
+ }
23020
+ console.log(`${label} updated successfully.
22892
23021
  `);
22893
- console.log("Updating cocod...");
22894
- const cocodProc = Bun.spawn(["bun", "install", "-g", "@routstr/cocod"], {
22895
- stdout: "inherit",
22896
- stderr: "inherit"
22897
- });
22898
- const cocodCode = await cocodProc.exited;
22899
- if (cocodCode !== 0) {
22900
- console.error("Failed to update cocod.");
22901
- process.exit(1);
23022
+ updatedAny = true;
23023
+ }
23024
+ if (updatedAny) {
23025
+ console.log("All requested updates have been applied!");
23026
+ await restartDaemonsAfterUpdate();
23027
+ } else {
23028
+ console.log(`
23029
+ All packages are already up to date \u2014 nothing to do.`);
22902
23030
  }
22903
- console.log(`cocod updated successfully.
22904
- `);
22905
- console.log("Both routstrd and cocod have been updated!");
22906
- await restartDaemonsAfterUpdate();
22907
23031
  });
22908
23032
  program.command("refund").description("Refund pending tokens and API keys to a specified mint").option("-m, --mint-url <mintUrl>", "Mint URL to refund to (defaults to first mint in wallet)").option("-y, --yes", "Skip confirmation prompt", false).option("--xcashu", "Refund xcashu tokens only (uses refundXcashuTokens)", false).action(async (options) => {
22909
23033
  await ensureDaemonRunning();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routstrd",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "module": "src/index.ts",
5
5
  "type": "module",
6
6
  "private": false,
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@cashu/cashu-ts": "^4.3.0",
27
- "@routstr/sdk": "^0.3.17",
27
+ "@routstr/sdk": "^0.3.18",
28
28
  "applesauce-core": "^5.1.0",
29
29
  "applesauce-relay": "^5.1.0",
30
30
  "applesauce-wallet-connect": "^6.0.0",
package/src/cli.ts CHANGED
@@ -36,6 +36,11 @@ import {
36
36
  resolveCocodExecutable,
37
37
  } from "./daemon/wallet/cocod-client";
38
38
  import packageJson from "../package.json" with { type: "json" };
39
+ import {
40
+ compareVersions,
41
+ getGlobalPackageVersion,
42
+ getLatestNpmVersion,
43
+ } from "./utils/update-checker.ts";
39
44
 
40
45
  type RoutstrModel = {
41
46
  id: string;
@@ -363,34 +368,52 @@ program
363
368
  .command("update")
364
369
  .description("Update routstrd and cocod to the latest versions")
365
370
  .action(async () => {
366
- console.log("Updating routstrd...");
367
- const routstrdProc = Bun.spawn(["bun", "install", "-g", "routstrd"], {
368
- stdout: "inherit",
369
- stderr: "inherit",
370
- });
371
- const routstrdCode = await routstrdProc.exited;
372
- if (routstrdCode !== 0) {
373
- console.error("Failed to update routstrd.");
374
- process.exit(1);
375
- }
376
- console.log("routstrd updated successfully.\n");
371
+ const packages = [
372
+ { name: "routstrd", label: "routstrd" },
373
+ { name: "@routstr/cocod", label: "cocod" },
374
+ ];
377
375
 
378
- console.log("Updating cocod...");
379
- const cocodProc = Bun.spawn(["bun", "install", "-g", "@routstr/cocod"], {
380
- stdout: "inherit",
381
- stderr: "inherit",
382
- });
383
- const cocodCode = await cocodProc.exited;
384
- if (cocodCode !== 0) {
385
- console.error("Failed to update cocod.");
386
- process.exit(1);
387
- }
388
- console.log("cocod updated successfully.\n");
376
+ let updatedAny = false;
389
377
 
390
- console.log("Both routstrd and cocod have been updated!");
378
+ for (const { name, label } of packages) {
379
+ const installed = await getGlobalPackageVersion(name);
380
+ const latest = await getLatestNpmVersion(name);
391
381
 
392
- // Restart daemons so the new binaries take effect immediately.
393
- await restartDaemonsAfterUpdate();
382
+ // Only skip when we're confident the installed version is current.
383
+ // If we can't determine either version we fall through to installing.
384
+ if (
385
+ installed &&
386
+ latest &&
387
+ (compareVersions(installed, latest) ?? -1) >= 0
388
+ ) {
389
+ console.log(`${label} is already up to date (v${installed}).`);
390
+ continue;
391
+ }
392
+
393
+ const fromPart = installed ? ` from v${installed}` : "";
394
+ const toPart = latest ? ` to v${latest}` : "";
395
+ console.log(`Updating ${label}${fromPart}${toPart}...`);
396
+
397
+ const proc = Bun.spawn(["bun", "install", "-g", name], {
398
+ stdout: "inherit",
399
+ stderr: "inherit",
400
+ });
401
+ const code = await proc.exited;
402
+ if (code !== 0) {
403
+ console.error(`Failed to update ${label}.`);
404
+ process.exit(1);
405
+ }
406
+ console.log(`${label} updated successfully.\n`);
407
+ updatedAny = true;
408
+ }
409
+
410
+ if (updatedAny) {
411
+ console.log("All requested updates have been applied!");
412
+ // Restart daemons so the new binaries take effect immediately.
413
+ await restartDaemonsAfterUpdate();
414
+ } else {
415
+ console.log("\nAll packages are already up to date — nothing to do.");
416
+ }
394
417
  });
395
418
 
396
419
  program
@@ -28,7 +28,8 @@ import {
28
28
  } from "./terminal.ts";
29
29
  import { COLORS } from "./constants.ts";
30
30
  import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
31
- import type { TabId, UsageStats } from "./types.ts";
31
+ import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
32
+ import { checkForUpdates } from "../../utils/update-checker.ts";
32
33
 
33
34
  export async function runUsageTui(): Promise<void> {
34
35
  const running = await isDaemonRunning();
@@ -53,6 +54,39 @@ export async function runUsageTui(): Promise<void> {
53
54
  let cleanedUp = false;
54
55
  let fetching = false;
55
56
 
57
+ // Update-check state — re-checked at most every 210 minutes to avoid
58
+ // spamming the npm registry. The first check happens shortly after
59
+ // startup so the TUI doesn't block on a network call.
60
+ const UPDATE_CHECK_INTERVAL_MS = 210 * 60 * 1000;
61
+ const UPDATE_CHECK_DELAY_MS = 3000;
62
+ let updateInfo: UpdateInfo | null = null;
63
+ let lastUpdateCheck = 0;
64
+ let updateCheckInProgress = false;
65
+
66
+ async function maybeCheckForUpdates(): Promise<void> {
67
+ if (updateCheckInProgress) return;
68
+ const now = Date.now();
69
+ if (updateInfo && now - lastUpdateCheck < UPDATE_CHECK_INTERVAL_MS) return;
70
+
71
+ updateCheckInProgress = true;
72
+ lastUpdateCheck = now;
73
+ try {
74
+ const result = await checkForUpdates();
75
+ const outdated = result.packages.filter((p) => p.hasUpdate);
76
+ updateInfo = {
77
+ hasUpdate: result.hasUpdate,
78
+ text: outdated.length > 0
79
+ ? `Update available: ${outdated.map((p) => p.label).join(", ")} ${outdated.map((p) => p.latest).join(", ")}`
80
+ : "",
81
+ };
82
+ render();
83
+ } catch {
84
+ // Silently ignore — update checks are best-effort
85
+ } finally {
86
+ updateCheckInProgress = false;
87
+ }
88
+ }
89
+
56
90
  if (isInteractive) {
57
91
  stdout.write(enterAlternateScreen() + hideCursor());
58
92
  stdin.setRawMode?.(true);
@@ -138,7 +172,7 @@ export async function runUsageTui(): Promise<void> {
138
172
 
139
173
  const content = renderTabContent(currentTab, stats, balance, status, width, clients);
140
174
  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}` : ""}`;
141
- const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
175
+ const chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
142
176
  const chromeLines = chrome.split("\n").length - 1;
143
177
  const footerSeparator = renderSeparator(width);
144
178
  const footerLines = footerSeparator.split("\n").length - 1;
@@ -265,9 +299,14 @@ export async function runUsageTui(): Promise<void> {
265
299
 
266
300
  await fetchData();
267
301
 
302
+ // Kick off the first update check after a short delay so the initial
303
+ // data fetch isn't blocked by a network round-trip to npm.
304
+ setTimeout(() => void maybeCheckForUpdates(), UPDATE_CHECK_DELAY_MS);
305
+
268
306
  refreshInterval = setInterval(() => {
269
307
  if (autoRefresh) {
270
308
  void fetchData();
271
309
  }
310
+ void maybeCheckForUpdates();
272
311
  }, 2000);
273
312
  }
@@ -8,7 +8,7 @@ import {
8
8
  import { vimState } from "./state.ts";
9
9
  import { stripAnsi } from "./terminal.ts";
10
10
  import type { BalanceInfo, StatusInfo } from "./data.ts";
11
- import type { TabId, UsageStats } from "./types.ts";
11
+ import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
12
12
 
13
13
  /** Format a cost value: 0.12, 1.23, 12.34, 123.45, 1.23k, 1.23m */
14
14
  function formatCost(value: number): string {
@@ -24,13 +24,20 @@ function formatReqs(value: number): string {
24
24
  return value.toString();
25
25
  }
26
26
 
27
- export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[]): string {
27
+ export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[], updateInfo?: UpdateInfo): string {
28
28
  const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
29
29
  const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
30
30
  const maxKey = visibleTabs.length;
31
31
  const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
32
32
  const fill = width - title.length - help.length - vimIndicator.length - 6;
33
- return `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}\n`;
33
+ const headerLine = `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}`;
34
+
35
+ if (updateInfo?.hasUpdate) {
36
+ const banner = `${COLORS.bold}${COLORS.yellow} ↻ UPDATE AVAILABLE — run ${COLORS.green}routstrd update${COLORS.yellow} to update${COLORS.reset}`;
37
+ return `${headerLine}\n${banner}\n`;
38
+ }
39
+
40
+ return `${headerLine}\n`;
34
41
  }
35
42
 
36
43
  export function renderSearchBar(): string {
@@ -1,8 +1,14 @@
1
1
  import type { UsageTrackingEntry } from "../../daemon/types.ts";
2
2
  import type { UsageSummary } from "../../daemon/http/usage-summary.ts";
3
+ import type { UpdateCheckResult } from "../../utils/update-checker.ts";
3
4
 
4
5
  export type { UsageSummary };
5
6
 
7
+ export interface UpdateInfo {
8
+ hasUpdate: boolean;
9
+ text: string;
10
+ }
11
+
6
12
  export interface UsageStats {
7
13
  entries: UsageTrackingEntry[];
8
14
  totalEntries: number;
@@ -0,0 +1,114 @@
1
+ const NPM_REGISTRY = "https://registry.npmjs.org";
2
+
3
+ /** Packages that `routstrd update` manages. */
4
+ export const UPDATE_PACKAGES = [
5
+ { name: "routstrd", label: "routstrd" },
6
+ { name: "@routstr/cocod", label: "cocod" },
7
+ ] as const;
8
+
9
+ /**
10
+ * Fetch the latest published version of a package from the npm registry.
11
+ * Returns null if the version cannot be determined (e.g. offline, not found).
12
+ */
13
+ export async function getLatestNpmVersion(
14
+ packageName: string,
15
+ ): Promise<string | null> {
16
+ try {
17
+ const response = await fetch(
18
+ `${NPM_REGISTRY}/${encodeURIComponent(packageName)}/latest`,
19
+ );
20
+ if (!response.ok) return null;
21
+ const data = (await response.json()) as { version?: string };
22
+ return data.version ?? null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Get the version of a globally-installed bun package.
30
+ * Returns null when the package is not installed globally or the version
31
+ * cannot be parsed as semver (e.g. installed from a git URL).
32
+ */
33
+ export async function getGlobalPackageVersion(
34
+ packageName: string,
35
+ ): Promise<string | null> {
36
+ try {
37
+ const proc = Bun.spawn(["bun", "pm", "ls", "-g"], {
38
+ stdout: "pipe",
39
+ stderr: "ignore",
40
+ });
41
+ const output = await new Response(proc.stdout).text();
42
+ await proc.exited;
43
+ // Lines look like: ├── routstrd@0.3.10 or └── @routstr/cocod@0.0.24
44
+ const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45
+ const match = output.match(new RegExp(`${escaped}@([^\\s]+)`));
46
+ if (!match) return null;
47
+ const version = match[1];
48
+ // Reject non-semver versions (e.g. github:routstr/cocod#3f6ac14)
49
+ if (!version || !/^\d+\.\d+\.\d+/.test(version)) return null;
50
+ return version;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Compare two semver version strings.
58
+ * Returns a positive number if `a` is newer, negative if `b` is newer,
59
+ * 0 if equal, or null if either value is not parseable semver.
60
+ */
61
+ export function compareVersions(a: string, b: string): number | null {
62
+ const parse = (v: string): [number, number, number] | null => {
63
+ const match = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
64
+ if (!match?.[1] || !match[2] || !match[3]) return null;
65
+ return [
66
+ parseInt(match[1], 10),
67
+ parseInt(match[2], 10),
68
+ parseInt(match[3], 10),
69
+ ];
70
+ };
71
+ const va = parse(a);
72
+ const vb = parse(b);
73
+ if (!va || !vb) return null;
74
+ const [a1, a2, a3] = va;
75
+ const [b1, b2, b3] = vb;
76
+ if (a1 !== b1) return a1 - b1;
77
+ if (a2 !== b2) return a2 - b2;
78
+ if (a3 !== b3) return a3 - b3;
79
+ return 0;
80
+ }
81
+
82
+ export interface PackageUpdate {
83
+ name: string;
84
+ label: string;
85
+ current: string | null;
86
+ latest: string | null;
87
+ hasUpdate: boolean;
88
+ }
89
+
90
+ export interface UpdateCheckResult {
91
+ hasUpdate: boolean;
92
+ packages: PackageUpdate[];
93
+ }
94
+
95
+ /**
96
+ * Check both routstrd and cocod for available updates.
97
+ * Returns a result with per-package details and an overall `hasUpdate` flag.
98
+ */
99
+ export async function checkForUpdates(): Promise<UpdateCheckResult> {
100
+ const packages = await Promise.all(
101
+ UPDATE_PACKAGES.map(async ({ name, label }) => {
102
+ const [current, latest] = await Promise.all([
103
+ getGlobalPackageVersion(name),
104
+ getLatestNpmVersion(name),
105
+ ]);
106
+ const hasUpdate = !!(current && latest && (compareVersions(current, latest) ?? -1) < 0);
107
+ return { name, label, current, latest, hasUpdate } satisfies PackageUpdate;
108
+ }),
109
+ );
110
+ return {
111
+ hasUpdate: packages.some((p) => p.hasUpdate),
112
+ packages,
113
+ };
114
+ }