@modelstatus/cli 0.1.51 → 0.1.53

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.51",
3
+ "version": "0.1.53",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
5
6
  import { resolveAuth, loadConfig, saveConfig, clearAuth, configFilePath } from "./config.js";
6
7
  import { createClient } from "./api.js";
7
8
  import { collectFrom, availability, ALL_SOURCE_IDS, getSource } from "./sources/index.js";
@@ -12,7 +13,7 @@ import {
12
13
  import { redactValue } from "./redact.js";
13
14
  import { assignProjects, buildUsages } from "./upload.js";
14
15
  import { loginViaBrowser } from "./auth.js";
15
- import { maybeCheckForUpdate } from "./updater.js";
16
+ import { maybeCheckForUpdate, forceUpdate } from "./updater.js";
16
17
  import { track, maybeAnalyticsNotice } from "./telemetry.js";
17
18
  import { BUILD_VERSION } from "./version.js";
18
19
 
@@ -691,7 +692,8 @@ Usage:
691
692
  mm sources List detection sources and whether each can run here
692
693
  mm integrations Manage live integrations (list | enable <id> | disable <id> | env <id> <tag>)
693
694
  mm clear Delete all tracked usages from your inventory (--all also wipes projects/rules; --yes to skip the prompt)
694
- mm upgrade Open Stripe checkout and poll until Pro is active
695
+ mm update Update to the latest version now and relaunch (or add --update to any command)
696
+ mm upgrade Open Stripe checkout and poll until Pro is active (the paid plan, not the binary)
695
697
  mm play [dir] Play Donkey Kong while a background scan walks the dir (just for fun)
696
698
  mm tui Force-launch the TUI (logs you in first if needed)
697
699
 
@@ -705,7 +707,7 @@ Scan sources (--sources; default = filesystem + enabled integrations; "all" for
705
707
  Secret sources shell out to your already-authenticated CLIs, run read-only,
706
708
  and only ever upload model ids — secret VALUES never leave your machine.
707
709
 
708
- Flags: --api <url> · --key <key> · --project <id|name> · --yes · --json · --ci · --dry-run
710
+ Flags: --update · --api <url> · --key <key> · --project <id|name> · --yes · --json · --ci · --dry-run
709
711
  --sources <list> · --region <r> · --namespace <ns> · --kube-context <c> · --db <dsn> · --sql-table <t>
710
712
  --vercel-project <p> · --vercel-team <t> · --gh-repo <owner/name> · --supabase-ref <ref>
711
713
 
@@ -721,13 +723,38 @@ async function maybePrintUpdate(promise) {
721
723
  // be unsafe. Point the user at a one-line reinstall to a user-owned dir.
722
724
  process.stderr.write(`\n✦ mm ${r.to} is available (you're on ${r.from}). Auto-update can't write ${process.execPath} — reinstall:\n curl -fsSL https://llmstatus.ai/install.sh | bash\n`);
723
725
  } else {
724
- process.stderr.write(`\n✓ Updated mm ${r.from} → ${r.to} — your next run uses the new version.\n`);
726
+ process.stderr.write(`\n✓ Updated mm ${r.from} → ${r.to} — your next run uses it (or run \`mm update\` to relaunch on it now).\n`);
725
727
  }
726
728
  } catch {
727
729
  /* swallow */
728
730
  }
729
731
  }
730
732
 
733
+ /**
734
+ * `mm update` (command) or `--update` (flag on any command): apply the in-place
735
+ * self-update NOW and RE-EXEC the fresh binary, so a single run lands on the new
736
+ * version — no opening the app twice. `rerunArgs` is what the new binary should
737
+ * run (the same invocation minus the update trigger). Re-execs + exits on a real
738
+ * update; otherwise prints status and returns (caller continues normally).
739
+ */
740
+ async function applyUpdate(rerunArgs) {
741
+ const r = await forceUpdate();
742
+ if (r.status === "updated") {
743
+ process.stderr.write(`✓ Updated mm ${r.from} → ${r.to}. Relaunching…\n`);
744
+ const child = spawnSync(process.execPath, rerunArgs, {
745
+ stdio: "inherit",
746
+ env: { ...process.env, MM_JUST_UPDATED: r.to },
747
+ });
748
+ process.exit(child.status ?? 0);
749
+ }
750
+ if (r.status === "latest") process.stderr.write(`✓ Already on the latest mm (${r.version}).\n`);
751
+ else if (r.status === "manual")
752
+ process.stderr.write(`✦ mm ${r.to} is available (you're on ${r.from}), but auto-update can't write ${process.execPath}. Reinstall:\n curl -fsSL https://llmstatus.ai/install.sh | bash\n`);
753
+ else if (r.status === "npm") process.stderr.write(`This is an npm-managed install. Update with:\n npm update -g @modelstatus/cli\n`);
754
+ else if (r.status === "unsupported") process.stderr.write(`Self-update isn't supported for this build. Reinstall:\n curl -fsSL https://llmstatus.ai/install.sh | bash\n`);
755
+ else if (r.status === "error") process.stderr.write(`! Update check failed: ${r.message}\n`);
756
+ }
757
+
731
758
  async function main() {
732
759
  const { positional, flags } = parseArgs(process.argv.slice(2));
733
760
  const cmd = positional[0];
@@ -750,6 +777,20 @@ async function main() {
750
777
  maybeAnalyticsNotice();
751
778
  track("cli_command", { command: cmd || "tui" });
752
779
 
780
+ // Explicit self-update: `mm update` (command) or `--update` (flag on any
781
+ // command). Apply the update in place and RE-EXEC the fresh binary so this one
782
+ // run lands on the latest — no opening the app twice. `mm update` with nothing
783
+ // else then opens the TUI on the new version.
784
+ if (cmd === "update" || flags.update) {
785
+ const rerun = process.argv.slice(2).filter((a) => a !== "update" && a !== "--update");
786
+ await applyUpdate(rerun); // re-execs + exits when it actually updates
787
+ if (cmd === "update") {
788
+ await launchTui(positional[1], flags);
789
+ return;
790
+ }
791
+ // A bare `--update` on another command falls through to run that command.
792
+ }
793
+
753
794
  // Kick off the background self-update check — runs in parallel with the
754
795
  // user's command, capped at 1/24h, only for shell-installed binaries.
755
796
  const updatePromise = maybeCheckForUpdate(flags);
package/src/tui/app.js CHANGED
@@ -40,37 +40,39 @@ const GATE_KEYS = [{ k: "1-8", label: "switch" }, { k: "7", label: "sign in" }];
40
40
  // toast, status, keybar, bottomRule) — the body fills whatever rows remain.
41
41
  const CHROME_ROWS = 10;
42
42
 
43
+ // Rows to reserve below the frame (termRows = rows - reserve). POSITIVE shrinks
44
+ // the frame, NEGATIVE grows it past the reported height.
45
+ // - 1 everywhere: a full-height ink frame write+newlines its bottom line and
46
+ // scrolls the buffer up 1/render; the 1-row reserve keeps the last line blank.
47
+ // - -7 in Warp: empirically (the user dialing it live in their Warp), the frame
48
+ // must be GROWN ~7 rows past stdout.rows or the top clips — Warp's block model
49
+ // reports/handles the alt-screen height such that a flush-height frame rides too
50
+ // high. So in Warp we render TALLER, which pushes the window down into view.
51
+ // Gated on Warp (a negative reserve elsewhere would over-grow + clip the top of
52
+ // a normal full-screen terminal).
53
+ const WARP_RESERVE = -7;
54
+
55
+ /** True when running under Warp (TERM_PROGRAM=WarpTerminal, or any WARP_* env, or
56
+ * TERM_PROGRAM merely containing "warp" — broad so detection doesn't silently
57
+ * miss a build/config that doesn't set the canonical value). */
58
+ function isWarp(env) {
59
+ if (String(env.TERM_PROGRAM || "").toLowerCase().includes("warp")) return true;
60
+ for (const k in env) if (k.startsWith("WARP_")) return true;
61
+ return false;
62
+ }
63
+
43
64
  /**
44
- * How many rows to leave UN-rendered below the frame so the window never clips.
45
- *
46
- * - 1 row everywhere: a frame that fills the full height makes ink write+newline
47
- * the bottom line, which scrolls the buffer up by 1 each render; reserving the
48
- * last row keeps it blank so nothing ever scrolls.
49
- * - 6 rows in Warp: Warp keeps ~5 rows of block chrome (sticky command header +
50
- * pinned input) drawn OVER the alternate screen and over-reports stdout.rows by
51
- * that much, so the TOP clips unless we also subtract them (1 guard + 5 chrome;
52
- * measured against the user's Warp build — see Warp issues #6428/#2373, the
53
- * chrome area can't be reclaimed via alt_screen_padding). Gated on TERM_PROGRAM
54
- * so non-Warp terminals keep the plain 1-row reserve (an extra reserve elsewhere
55
- * would leave a blank band).
56
- *
57
- * MM_TUI_RESERVE_ROWS (integer ≥ 0) overrides the whole reserve so a user on a
58
- * different Warp version / input position / timing-line setting can self-tune
59
- * (e.g. =5 or =7) without waiting for a release; 0 opts out entirely.
65
+ * MM_TUI_RESERVE_ROWS (any integer, may be negative) overrides the reserve so a
66
+ * user on a different terminal/version can self-tune in either direction without
67
+ * a release; blank/non-integer falls back to the gated default.
60
68
  */
61
69
  export function rowsReserve(env = process.env) {
62
- // Any non-blank INTEGER string overrides (so `MM_TUI_RESERVE_ROWS=` or stray
63
- // whitespace falls back to the default rather than being read as 0 —
64
- // `Number("")` is 0). The override may be NEGATIVE: a negative reserve GROWS the
65
- // frame past the terminal height (termRows = rows - reserve), so the knob goes
66
- // both ways — positive shrinks the frame, negative grows it — letting a user
67
- // find the exact offset their terminal (e.g. Warp) needs in either direction.
68
70
  const raw = env.MM_TUI_RESERVE_ROWS;
69
71
  if (typeof raw === "string" && raw.trim() !== "") {
70
72
  const n = Number(raw);
71
73
  if (Number.isInteger(n)) return n;
72
74
  }
73
- return env.TERM_PROGRAM === "WarpTerminal" ? 6 : 1;
75
+ return isWarp(env) ? WARP_RESERVE : 1;
74
76
  }
75
77
 
76
78
  /** Frame height for a terminal of `rows` rows: reserve rows, then clamp to a
package/src/updater.js CHANGED
@@ -133,6 +133,41 @@ async function downloadAndReplace(version, key, expectedSha) {
133
133
  * "0.1.8 → v0.1.9" which looks like a typo. */
134
134
  function dispVer(v) { return String(v).replace(/^v/, ""); }
135
135
 
136
+ /**
137
+ * Force an update RIGHT NOW for an explicit `mm update` / `--update` — ignores
138
+ * the 30s throttle AND MM_NO_AUTO_UPDATE (the user asked for it). Swaps the
139
+ * binary in place but never re-execs (the caller does, so this one run can land
140
+ * on the new version without opening the app twice). Never throws; returns:
141
+ * { status: "updated", from, to } binary swapped — caller should re-exec
142
+ * { status: "latest", version } already the newest
143
+ * { status: "manual", from, to } newer exists but the dir isn't writable
144
+ * { status: "npm" } npm-managed install (use npm update -g)
145
+ * { status: "unsupported" } not a shell binary / unknown platform
146
+ * { status: "error", message } the check or download failed
147
+ */
148
+ export async function forceUpdate() {
149
+ try {
150
+ if (!IS_SHELL_INSTALL) return { status: "npm" };
151
+ const key = platformKey();
152
+ if (!key) return { status: "unsupported" };
153
+
154
+ const manifest = await fetchJson(`${CDN}/cli/${CHANNEL_PATH}/version.json`);
155
+ writeCache({ ...(readCache() || {}), last_check: Date.now(), latest_known: manifest.version });
156
+ if (!manifest.version) return { status: "error", message: "manifest has no version" };
157
+ if (compareVer(manifest.version, BUILD_VERSION) <= 0) return { status: "latest", version: dispVer(BUILD_VERSION) };
158
+
159
+ const expectedSha = manifest.sha256?.[key];
160
+ if (!expectedSha) return { status: "error", message: `no sha256 for ${key}` };
161
+ if (!dirWritable(path.dirname(process.execPath))) {
162
+ return { status: "manual", from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
163
+ }
164
+ await downloadAndReplace(manifest.version, key, expectedSha);
165
+ return { status: "updated", from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
166
+ } catch (e) {
167
+ return { status: "error", message: e?.message ?? String(e) };
168
+ }
169
+ }
170
+
136
171
  /** Returns { from, to } if an update was completed, else null. Never throws. */
137
172
  export async function maybeCheckForUpdate(flags = {}) {
138
173
  try {