@modelstatus/cli 0.1.1 → 0.1.26

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.
@@ -1,12 +1,28 @@
1
+ /* What's New view — registry change feed, alert notifications, and code drift.
2
+ * Three sub-tabs (Registry / Alerts / Drift) switched with ←/→. Renders body-only;
3
+ * the shell (app.js) owns the window chrome, status bar (fed by reportStatus) and
4
+ * keybar. Data loading + drift logic + key handlers are unchanged from the
5
+ * original; only the render is restyled to the LLM Status design system. */
1
6
  import React from "react";
2
7
  import { Box, Text, useInput } from "ink";
3
- import { h, useAsync, cell, clampCursor } from "../ui.js";
8
+ import {
9
+ h, C, GLYPH, SubTabs, ListRow, StateLine, cellE, clampCursor, SPINNER, useTick, useAsync,
10
+ } from "../ui.js";
4
11
  import { collectFrom } from "../../sources/index.js";
5
12
  import { loadConfig, setConfigValue } from "../../config.js";
6
13
 
7
14
  const TABS = ["Registry", "Alerts", "Drift"];
8
15
 
9
- export function WhatsNewView({ client, dir, ui, active }) {
16
+ export const meta = {
17
+ keys: [
18
+ { k: "←→", label: "section" },
19
+ { k: "↑↓", label: "scroll" },
20
+ { k: "m", label: "mark seen" },
21
+ { k: "r", label: "drift" },
22
+ ],
23
+ };
24
+
25
+ export function WhatsNewView({ client, dir, ui, active, width = 78, height = 14 }) {
10
26
  const [tab, setTab] = React.useState(0);
11
27
  const [cursor, setCursor] = React.useState(0);
12
28
  const lastSeen = loadConfig().lastEventsSeenAt || null;
@@ -23,6 +39,11 @@ export function WhatsNewView({ client, dir, ui, active }) {
23
39
  }, []);
24
40
  const notif = useAsync(async () => (await client.listNotifications({})).data || [], []);
25
41
  const [drift, setDrift] = React.useState(null);
42
+ // Gate the spinner tick on actual loading — not tab focus — so an idle
43
+ // What's New tab doesn't re-render ~12×/sec forever.
44
+ const tick = useTick(80, active && (reg.loading || notif.loading || (drift && drift.loading)));
45
+ const spin = SPINNER[tick % SPINNER.length];
46
+ const ROWS = Math.max(3, height - 4); // subtabs + blank + footer overhead
26
47
 
27
48
  async function runDrift() {
28
49
  setDrift({ loading: true });
@@ -90,88 +111,109 @@ export function WhatsNewView({ client, dir, ui, active }) {
90
111
  { isActive: active },
91
112
  );
92
113
 
93
- const tabBar = h(
94
- Box,
95
- {},
96
- ...TABS.map((t, i) =>
97
- h(Text, { key: t, color: i === tab ? "black" : "gray", backgroundColor: i === tab ? "cyan" : undefined }, ` ${t} `),
98
- ),
99
- h(Text, { color: "gray" }, " (←/→ switch)"),
100
- );
114
+ // Push per-tab context up to the shell status bar.
115
+ React.useEffect(() => {
116
+ let context;
117
+ if (tab === 0) context = `${reg.data?.events.length || 0} changes`;
118
+ else if (tab === 1) context = `${(notif.data || []).length} alerts`;
119
+ else context = drift && !drift.loading && !drift.error ? `+${drift.added.length} / -${drift.gone.length}` : "drift";
120
+ ui?.reportStatus?.({ context });
121
+ }, [tab, reg.data, notif.data, drift, ui]);
101
122
 
102
- let body, footer;
123
+ let body;
103
124
  if (tab === 0) {
104
- footer = "←/→ tab · ↑↓ scroll · m mark seen";
105
- if (reg.loading) body = h(Text, { color: "gray" }, "Loading registry changes…");
106
- else if (reg.error) body = h(Text, { color: "red" }, reg.error);
125
+ if (reg.loading) body = h(StateLine, { kind: "loading", spin, text: "loading registry changes…" });
126
+ else if (reg.error) body = h(StateLine, { kind: "error", text: reg.error });
107
127
  else {
108
128
  const events = reg.data.events;
109
- if (!events.length) body = h(Text, { color: "gray" }, "No registry changes recorded yet.");
129
+ if (!events.length) body = h(Text, { color: C.FG_DIM }, " No registry changes recorded yet.");
110
130
  else {
111
131
  const start = clampCursor(cursor, events.length);
112
132
  body = h(
113
133
  Box,
114
134
  { flexDirection: "column" },
115
- ...events.slice(start, start + 12).map((e) => {
135
+ ...events.slice(start, start + ROWS).map((e) => {
116
136
  const name = e.model_id ? reg.data.models.get(e.model_id) : reg.data.provs.get(e.provider_id);
117
137
  const isNew = lastSeen && e.published_at && e.published_at > lastSeen;
118
- return h(
119
- Text,
120
- { key: e.id, color: isNew ? "yellow" : "white" },
121
- `${isNew ? " " : " "}${cell(e.event_type, 18)} ${cell(name || "—", 26)} ${String(e.published_at || "").slice(0, 10)}`,
122
- );
138
+ const cells = [
139
+ { text: `${isNew ? GLYPH.bullet : GLYPH.custom} `, color: isNew ? C.ACCENT : C.FG_FAINT },
140
+ { text: cellE(e.event_type, 18), color: C.FG },
141
+ { text: " ", color: C.FG },
142
+ { text: cellE(name || "—", 26), color: isNew ? C.FG : C.FG_DIM },
143
+ { text: " ", color: C.FG },
144
+ { text: String(e.published_at || "").slice(0, 10).padEnd(10), color: C.FG_DIM },
145
+ ];
146
+ return h(ListRow, { key: e.id, active: false, cells, width });
123
147
  }),
124
148
  );
125
149
  }
126
150
  }
127
151
  } else if (tab === 1) {
128
- footer = "←/→ tab · ↑↓ move · o mark read";
129
152
  const list = notif.data || [];
130
- if (notif.loading) body = h(Text, { color: "gray" }, "Loading alerts…");
131
- else if (!list.length) body = h(Text, { color: "gray" }, "No alerts yet. Configure rules in the Alerts view.");
132
- else
153
+ if (notif.loading) body = h(StateLine, { kind: "loading", spin, text: "loading alerts…" });
154
+ else if (notif.error) body = h(StateLine, { kind: "error", text: notif.error });
155
+ else if (!list.length) body = h(Text, { color: C.FG_DIM }, " No alerts yet. Configure rules in the Alerts view.");
156
+ else {
157
+ const cur = clampCursor(cursor, list.length);
133
158
  body = h(
134
159
  Box,
135
160
  { flexDirection: "column" },
136
- ...list.slice(0, 12).map((n, i) =>
137
- h(
138
- Text,
139
- { key: n.id, inverse: i === clampCursor(cursor, list.length), color: n.unread ? "white" : "gray" },
140
- `${n.unread ? "" : "○"} ${cell(n.title, 40)} ${String(n.when || "").slice(0, 10)}`,
141
- ),
142
- ),
161
+ ...list.slice(0, ROWS).map((n, i) => {
162
+ const cells = [
163
+ { text: `${n.unread ? GLYPH.bullet : GLYPH.custom} `, color: n.unread ? C.ACCENT : C.FG_FAINT },
164
+ { text: cellE(n.title, 40), color: n.unread ? C.FG : C.FG_DIM },
165
+ { text: " ", color: C.FG },
166
+ { text: String(n.when || "").slice(0, 10).padEnd(10), color: C.FG_DIM },
167
+ ];
168
+ return h(ListRow, { key: n.id, active: i === cur, cells, width });
169
+ }),
143
170
  );
171
+ }
144
172
  } else {
145
- footer = "←/→ tab · r scan for drift · ↑↓ move · a archive (gone)";
146
- if (!drift) body = h(Text, { color: "gray" }, `Press r to scan ${dir} and compare against tracked usages.`);
147
- else if (drift.loading) body = h(Text, { color: "gray" }, "Scanning for drift…");
148
- else if (drift.error) body = h(Text, { color: "red" }, drift.error);
173
+ if (!drift) body = h(Text, { color: C.FG_DIM }, ` Press r to scan ${dir} and compare against tracked usages.`);
174
+ else if (drift.loading) body = h(StateLine, { kind: "loading", spin, text: "scanning for drift…" });
175
+ else if (drift.error) body = h(StateLine, { kind: "error", text: drift.error });
149
176
  else {
150
177
  const gone = drift.gone || [];
178
+ const curGone = clampCursor(cursor, gone.length);
151
179
  body = h(
152
180
  Box,
153
181
  { flexDirection: "column" },
154
182
  h(
155
183
  Text,
156
184
  {},
157
- h(Text, { color: "green" }, `${drift.added.length} new in code`),
158
- h(Text, { color: "gray" }, ` · ${drift.present} still present · `),
159
- h(Text, { color: "red" }, `${gone.length} gone from code`),
185
+ h(Text, { color: "#16a34a" }, `${drift.added.length} new in code`),
186
+ h(Text, { color: C.FG_DIM }, ` · ${drift.present} still present · `),
187
+ h(Text, { color: "#dc2626" }, `${gone.length} gone from code`),
160
188
  ),
161
189
  h(Text, {}, ""),
162
- ...drift.added.slice(0, 5).map((c, i) =>
163
- h(Text, { key: "a" + i, color: "green" }, ` + ${cell(c.display || c.model_string, 24)} ${c.location_label}`),
164
- ),
165
- ...gone.slice(0, 7).map((u, i) =>
166
- h(
167
- Text,
168
- { key: "g" + i, inverse: i === clampCursor(cursor, gone.length), color: "red" },
169
- ` - ${cell(u.model_display || u.custom_model_name || "?", 24)} ${u.source_path || "—"}`,
170
- ),
171
- ),
190
+ ...drift.added.slice(0, 5).map((c, i) => {
191
+ const cells = [
192
+ { text: "+ ", color: "#16a34a" },
193
+ { text: cellE(c.display || c.model_string, 24), color: "#16a34a" },
194
+ { text: " ", color: C.FG },
195
+ { text: cellE(c.location_label || "—", width - 28), color: C.FG_DIM },
196
+ ];
197
+ return h(ListRow, { key: "a" + i, active: false, cells, width });
198
+ }),
199
+ ...gone.slice(0, 7).map((u, i) => {
200
+ const cells = [
201
+ { text: "- ", color: "#dc2626" },
202
+ { text: cellE(u.model_display || u.custom_model_name || "?", 24), color: "#dc2626" },
203
+ { text: " ", color: C.FG },
204
+ { text: cellE(u.source_path || "—", width - 28), color: C.FG_DIM },
205
+ ];
206
+ return h(ListRow, { key: "g" + i, active: i === curGone, cells, width });
207
+ }),
172
208
  );
173
209
  }
174
210
  }
175
211
 
176
- return h(Box, { flexDirection: "column" }, tabBar, h(Text, {}, ""), body, h(Text, {}, ""), h(Text, { color: "gray" }, footer));
212
+ return h(
213
+ Box,
214
+ { flexDirection: "column" },
215
+ h(SubTabs, { idx: tab, tabs: TABS }),
216
+ h(Text, {}, ""),
217
+ body,
218
+ );
177
219
  }
package/src/updater.js ADDED
@@ -0,0 +1,170 @@
1
+ /* Background self-updater for the shell-installed binary.
2
+ *
3
+ * Design (see also docs.llmstatus.ai/install):
4
+ * - Only runs when IS_SHELL_INSTALL is true (Bun-compiled binary). npm-managed
5
+ * installs defer to `npm update -g @modelstatus/cli`.
6
+ * - Capped at one check per 24 h (cache file at ~/.config/llmstatus/updater.json).
7
+ * - Opt-out via MM_NO_AUTO_UPDATE=1, or any --json/--ci invocation.
8
+ * - All errors are swallowed silently. The user's command must NEVER break
9
+ * because the updater hiccupped.
10
+ * - Verifies the downloaded binary's sha256 against `cli/latest/version.json`
11
+ * before atomically renaming over process.execPath.
12
+ * - Notification (one stderr line) prints after the user's command finishes —
13
+ * same-run doesn't re-exec, so any in-flight state stays sane.
14
+ */
15
+ import crypto from "node:crypto";
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { BUILD_VERSION, IS_SHELL_INSTALL, UPDATE_CHANNEL } from "./version.js";
20
+
21
+ // Per-channel manifest path on the CDN. Stable lives at /cli/latest/ for
22
+ // backwards-compat with the original layout; experimental at /cli/experimental/.
23
+ const CHANNEL_PATH = UPDATE_CHANNEL === "experimental" ? "experimental" : "latest";
24
+
25
+ const CDN = (process.env.MM_CDN || "https://cdn.llmstatus.ai").replace(/\/$/, "");
26
+ const CACHE_FILE = path.join(os.homedir(), ".config", "llmstatus", "updater.json");
27
+ // Throttle update checks to once per 30 s. Effectively "every run" for normal
28
+ // use, but protects against tight loops (`for i; do mm status; done`) hammering
29
+ // the CDN. version.json itself is 520 bytes and cached at the CF edge, so the
30
+ // network cost is trivial — this throttle is purely about local CPU/IO.
31
+ // Override with MM_UPDATE_INTERVAL_MS for testing.
32
+ const CHECK_INTERVAL_MS = Number(process.env.MM_UPDATE_INTERVAL_MS) || 30 * 1000;
33
+
34
+ /** Tolerant of v-prefixes; splits on dots & dashes (handles 0.1.2-rc1). */
35
+ function parseVer(s) {
36
+ return String(s).replace(/^v/, "").split(/[.-]/).map((p) => /^\d+$/.test(p) ? Number(p) : p);
37
+ }
38
+ /** -1 / 0 / +1 (a < b / a == b / a > b). */
39
+ function compareVer(a, b) {
40
+ const av = parseVer(a);
41
+ const bv = parseVer(b);
42
+ for (let i = 0; i < Math.max(av.length, bv.length); i++) {
43
+ const ap = av[i] ?? 0;
44
+ const bp = bv[i] ?? 0;
45
+ if (ap < bp) return -1;
46
+ if (ap > bp) return 1;
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ function readCache() {
52
+ try {
53
+ return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function writeCache(obj) {
60
+ try {
61
+ fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true, mode: 0o700 });
62
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(obj));
63
+ } catch {
64
+ /* best effort */
65
+ }
66
+ }
67
+
68
+ /** Matches the keys in version.json sha256 map + the binary filename suffix. */
69
+ function platformKey() {
70
+ const osKey =
71
+ process.platform === "darwin" ? "darwin" :
72
+ process.platform === "linux" ? "linux" :
73
+ process.platform === "win32" ? "windows" :
74
+ null;
75
+ if (!osKey) return null;
76
+ const archKey =
77
+ process.arch === "arm64" ? "arm64" :
78
+ process.arch === "x64" ? "x64" :
79
+ null;
80
+ if (!archKey) return null;
81
+ return `${osKey}-${archKey}`;
82
+ }
83
+
84
+ async function fetchJson(url) {
85
+ const res = await fetch(url, { cache: "no-store" });
86
+ if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
87
+ return res.json();
88
+ }
89
+
90
+ /** True if we can create/rename files in `dir` (needed for an atomic swap). */
91
+ function dirWritable(dir) {
92
+ try {
93
+ fs.accessSync(dir, fs.constants.W_OK);
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ /** Replace the executable via the atomic tmp+rename pattern ONLY (a new inode).
101
+ * We must NOT writeFileSync over the live executable's inode: truncating +
102
+ * rewriting a running, memory-mapped Mach-O can make macOS SIGKILL the running
103
+ * process mid-write ("killed: 9"). Requires a writable parent dir; callers
104
+ * pre-check dirWritable() and never reach here otherwise. Throws on failure
105
+ * (the outer catch swallows it — better no update than a half-written binary). */
106
+ function replaceBinary(exe, buf) {
107
+ const tmp = path.join(path.dirname(exe), `.${path.basename(exe)}.new.${process.pid}`);
108
+ try {
109
+ fs.writeFileSync(tmp, buf, { mode: 0o755 });
110
+ fs.renameSync(tmp, exe);
111
+ } catch (e) {
112
+ try { fs.unlinkSync(tmp); } catch { /* tmp may not exist */ }
113
+ throw e;
114
+ }
115
+ }
116
+
117
+ async function downloadAndReplace(version, key, expectedSha) {
118
+ const exe = process.execPath;
119
+ const ext = process.platform === "win32" ? ".exe" : "";
120
+ const url = `${CDN}/cli/${version}/modelstatus-cli-${key}${ext}`;
121
+ const res = await fetch(url);
122
+ if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
123
+ const buf = Buffer.from(await res.arrayBuffer());
124
+ const actualSha = crypto.createHash("sha256").update(buf).digest("hex");
125
+ if (actualSha !== expectedSha) {
126
+ throw new Error(`sha256 mismatch: expected ${expectedSha.slice(0, 12)}…, got ${actualSha.slice(0, 12)}…`);
127
+ }
128
+ replaceBinary(exe, buf);
129
+ }
130
+
131
+ /** Strip an optional leading "v" for display. The manifest writes "v0.1.8" but
132
+ * BUILD_VERSION is baked in as "0.1.8" — without this the notification reads
133
+ * "0.1.8 → v0.1.9" which looks like a typo. */
134
+ function dispVer(v) { return String(v).replace(/^v/, ""); }
135
+
136
+ /** Returns { from, to } if an update was completed, else null. Never throws. */
137
+ export async function maybeCheckForUpdate(flags = {}) {
138
+ try {
139
+ if (!IS_SHELL_INSTALL) return null;
140
+ if (process.env.MM_NO_AUTO_UPDATE) return null;
141
+ if (flags.json || flags.ci) return null;
142
+
143
+ const cache = readCache() || {};
144
+ if (cache.last_check && Date.now() - cache.last_check < CHECK_INTERVAL_MS) return null;
145
+
146
+ const key = platformKey();
147
+ if (!key) return null;
148
+
149
+ const manifest = await fetchJson(`${CDN}/cli/${CHANNEL_PATH}/version.json`);
150
+ // Always update the cache so we don't re-check for 24 h.
151
+ writeCache({ ...cache, last_check: Date.now(), latest_known: manifest.version });
152
+
153
+ if (!manifest.version || compareVer(manifest.version, BUILD_VERSION) <= 0) return null;
154
+ const expectedSha = manifest.sha256?.[key];
155
+ if (!expectedSha) return null;
156
+
157
+ // Only self-update when we can atomically swap the binary (writable parent
158
+ // dir). For root-owned dirs like /usr/local/bin we can't — and overwriting
159
+ // the live binary in place is unsafe — so signal a manual reinstall instead
160
+ // of downloading + corrupting the running process.
161
+ if (!dirWritable(path.dirname(process.execPath))) {
162
+ return { manual: true, from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
163
+ }
164
+
165
+ await downloadAndReplace(manifest.version, key, expectedSha);
166
+ return { from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
package/src/version.js ADDED
@@ -0,0 +1,32 @@
1
+ /* Single source of truth for the CLI's version + install method.
2
+ *
3
+ * - In a Bun-compiled binary: `bun build --define __BUILD_VERSION__='"X.Y.Z"' --define __IS_SHELL_INSTALL__=true`
4
+ * replaces the identifiers at compile time. BUILD_VERSION is the literal,
5
+ * IS_SHELL_INSTALL is true (so the auto-updater runs).
6
+ * - In the npm-published JS: the identifiers stay undefined, so we read the
7
+ * version from package.json at runtime and IS_SHELL_INSTALL defaults to false
8
+ * (npm owns updates for that install path).
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ function readPkgVersion() {
14
+ try {
15
+ const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
16
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ // eslint-disable-next-line no-undef
23
+ export const BUILD_VERSION = typeof __BUILD_VERSION__ !== "undefined" ? __BUILD_VERSION__ : (readPkgVersion() ?? "unknown");
24
+
25
+ // eslint-disable-next-line no-undef
26
+ export const IS_SHELL_INSTALL = typeof __IS_SHELL_INSTALL__ !== "undefined" ? __IS_SHELL_INSTALL__ : false;
27
+
28
+ // Release channel — "stable" or "experimental". Baked at compile time so a
29
+ // channel is sticky to the binary; switching channels means re-installing.
30
+ // (npm-installed JS is always treated as stable.)
31
+ // eslint-disable-next-line no-undef
32
+ export const UPDATE_CHANNEL = typeof __UPDATE_CHANNEL__ !== "undefined" ? __UPDATE_CHANNEL__ : "stable";