@modelstatus/cli 0.1.85 → 0.1.87

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/src/updater.js CHANGED
@@ -3,10 +3,13 @@
3
3
  * Design (see also docs.llmstatus.ai/install):
4
4
  * - Only runs when IS_SHELL_INSTALL is true (Bun-compiled binary). npm-managed
5
5
  * installs defer to `npm update -g @modelstatus/cli`.
6
- * - Capped at one check per 24 h (cache file at ~/.config/llmstatus/updater.json).
6
+ * - Throttled via CHECK_INTERVAL_MS (30 s default; cache file at
7
+ * ~/.config/llmstatus/updater.json). A version that failed to install backs
8
+ * off for FAIL_BACKOFF_MS so a bad release can't re-download per command.
7
9
  * - Opt-out via MM_NO_AUTO_UPDATE=1, or any --json/--ci invocation.
8
10
  * - All errors are swallowed silently. The user's command must NEVER break
9
- * because the updater hiccupped.
11
+ * because the updater hiccupped. Every fetch is deadline-capped so the
12
+ * at-exit await can never stall the command on a hung CDN.
10
13
  * - Verifies the downloaded binary's sha256 against `cli/latest/version.json`
11
14
  * before atomically renaming over process.execPath.
12
15
  * - Notification (one stderr line) prints after the user's command finishes —
@@ -31,6 +34,16 @@ const CACHE_FILE = path.join(os.homedir(), ".config", "llmstatus", "updater.json
31
34
  // network cost is trivial — this throttle is purely about local CPU/IO.
32
35
  // Override with MM_UPDATE_INTERVAL_MS for testing.
33
36
  const CHECK_INTERVAL_MS = Number(process.env.MM_UPDATE_INTERVAL_MS) || 30 * 1000;
37
+ // After a download/verify/swap failure for a given version, don't re-attempt
38
+ // that same version for this long — a bad publish must not turn every command
39
+ // into a full binary download. `mm update` ignores this (explicit ask).
40
+ const FAIL_BACKOFF_MS = Number(process.env.MM_UPDATE_FAIL_BACKOFF_MS) || 6 * 60 * 60 * 1000;
41
+ // Deadlines: the check is awaited before process exit, so every network hop
42
+ // must be finite. Manifest legs are tiny (~520 B); the binary is ~60 MB, so it
43
+ // gets a generous — but still finite — cap. On timeout we skip silently and a
44
+ // future run retries.
45
+ const manifestTimeoutMs = () => Number(process.env.MM_UPDATE_FETCH_TIMEOUT_MS) || 5_000;
46
+ const downloadTimeoutMs = () => Number(process.env.MM_UPDATE_DOWNLOAD_TIMEOUT_MS) || 120_000;
34
47
 
35
48
  /** Tolerant of v-prefixes; splits on dots & dashes (handles 0.1.2-rc1). */
36
49
  function parseVer(s) {
@@ -83,7 +96,7 @@ function platformKey() {
83
96
  }
84
97
 
85
98
  async function fetchJson(url) {
86
- const res = await fetch(url, { cache: "no-store" });
99
+ const res = await fetch(url, { cache: "no-store", signal: AbortSignal.timeout(manifestTimeoutMs()) });
87
100
  if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
88
101
  return res.json();
89
102
  }
@@ -101,8 +114,8 @@ MCowBQYDK2VwAyEApu4VP7vB6iwxsPcK5KaosjK7SIp9HrWMt5IvcUwjUOM=
101
114
  * invalid (fail closed — never self-update against an unverifiable manifest). */
102
115
  async function fetchVerifiedManifest(base) {
103
116
  const [mRes, sRes] = await Promise.all([
104
- fetch(`${base}/version.json`, { cache: "no-store" }),
105
- fetch(`${base}/version.json.sig`, { cache: "no-store" }),
117
+ fetch(`${base}/version.json`, { cache: "no-store", signal: AbortSignal.timeout(manifestTimeoutMs()) }),
118
+ fetch(`${base}/version.json.sig`, { cache: "no-store", signal: AbortSignal.timeout(manifestTimeoutMs()) }),
106
119
  ]);
107
120
  if (!mRes.ok) throw new Error(`GET version.json -> ${mRes.status}`);
108
121
  if (!sRes.ok) throw new Error("manifest signature unavailable — refusing to self-update");
@@ -134,21 +147,59 @@ export function isBrewManaged(execPath = process.execPath) {
134
147
  return p.includes("/Cellar/") || p.includes("/homebrew/") || p.includes("/linuxbrew/");
135
148
  }
136
149
 
137
- /** Replace the executable via the atomic tmp+rename pattern ONLY (a new inode).
138
- * We must NOT writeFileSync over the live executable's inode: truncating +
139
- * rewriting a running, memory-mapped Mach-O can make macOS SIGKILL the running
140
- * process mid-write ("killed: 9"). Requires a writable parent dir; callers
141
- * pre-check dirWritable() and never reach here otherwise. Throws on failure
142
- * (the outer catch swallows it — better no update than a half-written binary). */
143
- function replaceBinary(exe, buf) {
144
- const tmp = path.join(path.dirname(exe), `.${path.basename(exe)}.new.${process.pid}`);
150
+ /** Where a downloaded-and-sha-verified binary for `version` is staged (same dir
151
+ * as the exe, so the final rename stays same-filesystem/atomic). Deliberately
152
+ * NOT pid-suffixed: it survives a failed swap so the next attempt skips the
153
+ * ~60 MB re-download. */
154
+ function stagedPath(exe, version) {
155
+ const v = String(version).replace(/[^A-Za-z0-9._-]/g, "_");
156
+ return path.join(path.dirname(exe), `.${path.basename(exe)}.staged.${v}`);
157
+ }
158
+
159
+ /** Best-effort sweep of update leftovers next to the exe: parked `<exe>.old.*`
160
+ * copies from a Windows swap (the running image can't be deleted mid-swap, only
161
+ * renamed aside — see replaceBinary) and staged downloads. `keepStaged` is the
162
+ * path of a staged download to preserve, or `true` to preserve all staged files
163
+ * (used by the routine per-check sweep, which doesn't yet know the pending
164
+ * version). Never throws; a still-running old exe just stays until a later run. */
165
+ function cleanupLeftovers(exe, keepStaged) {
145
166
  try {
146
- fs.writeFileSync(tmp, buf, { mode: 0o755 });
147
- fs.renameSync(tmp, exe);
148
- } catch (e) {
149
- try { fs.unlinkSync(tmp); } catch { /* tmp may not exist */ }
150
- throw e;
167
+ const dir = path.dirname(exe);
168
+ const base = path.basename(exe);
169
+ for (const name of fs.readdirSync(dir)) {
170
+ const full = path.join(dir, name);
171
+ if (name.startsWith(`.${base}.staged.`)) {
172
+ if (keepStaged === true || full === keepStaged) continue;
173
+ } else if (!name.startsWith(`${base}.old.`)) {
174
+ continue;
175
+ }
176
+ try { fs.unlinkSync(full); } catch { /* e.g. old exe still running on Windows */ }
177
+ }
178
+ } catch { /* best effort */ }
179
+ }
180
+
181
+ /** Swap the staged (already-verified) binary into place via rename ONLY — a new
182
+ * inode. We must NOT writeFileSync over the live executable's inode: truncating
183
+ * + rewriting a running, memory-mapped Mach-O can make macOS SIGKILL the running
184
+ * process mid-write ("killed: 9"). On Windows a running .exe's image file can't
185
+ * be deleted or overwritten — but it CAN be renamed — so park the live binary
186
+ * aside first and sweep the parked copy on a later run (cleanupLeftovers).
187
+ * Requires a writable parent dir; callers pre-check dirWritable() and never
188
+ * reach here otherwise. Throws on failure (the caller memoizes it — better no
189
+ * update than a half-written binary). */
190
+ function replaceBinary(exe, staged) {
191
+ if (process.platform === "win32") {
192
+ const parked = `${exe}.old.${process.pid}`;
193
+ fs.renameSync(exe, parked);
194
+ try {
195
+ fs.renameSync(staged, exe);
196
+ } catch (e) {
197
+ try { fs.renameSync(parked, exe); } catch { /* leave parked as a last resort */ }
198
+ throw e;
199
+ }
200
+ return;
151
201
  }
202
+ fs.renameSync(staged, exe);
152
203
  }
153
204
 
154
205
  // Our Developer ID team. A self-update download must be signed by us before we
@@ -171,27 +222,49 @@ function verifyMacSignature(file) {
171
222
 
172
223
  async function downloadAndReplace(version, key, expectedSha) {
173
224
  const exe = process.execPath;
174
- const ext = process.platform === "win32" ? ".exe" : "";
175
- const url = `${CDN}/cli/${version}/modelstatus-cli-${key}${ext}`;
176
- const res = await fetch(url);
177
- if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
178
- const buf = Buffer.from(await res.arrayBuffer());
179
- const actualSha = crypto.createHash("sha256").update(buf).digest("hex");
180
- if (actualSha !== expectedSha) {
181
- throw new Error(`sha256 mismatch: expected ${expectedSha.slice(0, 12)}…, got ${actualSha.slice(0, 12)}…`);
182
- }
183
- // On macOS, verify the SIGNATURE before trusting these bytes as our binary.
184
- // Stage to a temp file, codesign-verify it, then swap — never swap unverified.
185
- if (process.platform === "darwin") {
186
- const staged = path.join(path.dirname(exe), `.${path.basename(exe)}.verify.${process.pid}`);
225
+ const staged = stagedPath(exe, version);
226
+ cleanupLeftovers(exe, staged);
227
+
228
+ // Reuse a previously-downloaded copy of this exact version when its sha256
229
+ // still matches the manifest — a failed swap must not cost a full ~60 MB
230
+ // re-download on the next attempt.
231
+ let haveStaged = false;
232
+ try {
233
+ const existing = fs.readFileSync(staged);
234
+ haveStaged = crypto.createHash("sha256").update(existing).digest("hex") === expectedSha;
235
+ } catch { /* no staged copy yet */ }
236
+
237
+ if (!haveStaged) {
238
+ const ext = process.platform === "win32" ? ".exe" : "";
239
+ const url = `${CDN}/cli/${version}/modelstatus-cli-${key}${ext}`;
240
+ const res = await fetch(url, { signal: AbortSignal.timeout(downloadTimeoutMs()) });
241
+ if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
242
+ const buf = Buffer.from(await res.arrayBuffer());
243
+ const actualSha = crypto.createHash("sha256").update(buf).digest("hex");
244
+ if (actualSha !== expectedSha) {
245
+ throw new Error(`sha256 mismatch: expected ${expectedSha.slice(0, 12)}…, got ${actualSha.slice(0, 12)}…`);
246
+ }
247
+ // Stage via tmp+rename so a concurrent run can't observe a half-written file.
248
+ const tmp = `${staged}.${process.pid}`;
187
249
  try {
188
- fs.writeFileSync(staged, buf, { mode: 0o755 });
189
- verifyMacSignature(staged);
190
- } finally {
191
- try { fs.unlinkSync(staged); } catch { /* best effort */ }
250
+ fs.writeFileSync(tmp, buf, { mode: 0o755 });
251
+ fs.renameSync(tmp, staged);
252
+ } catch (e) {
253
+ try { fs.unlinkSync(tmp); } catch { /* tmp may not exist */ }
254
+ throw e;
192
255
  }
193
256
  }
194
- replaceBinary(exe, buf);
257
+
258
+ // On macOS, verify the SIGNATURE before trusting these bytes as our binary —
259
+ // never swap unverified. A codesign failure discards the staged copy (bad
260
+ // bytes must not be reused).
261
+ try {
262
+ verifyMacSignature(staged);
263
+ } catch (e) {
264
+ try { fs.unlinkSync(staged); } catch { /* best effort */ }
265
+ throw e;
266
+ }
267
+ replaceBinary(exe, staged);
195
268
  }
196
269
 
197
270
  /** Strip an optional leading "v" for display. The manifest writes "v0.1.8" but
@@ -199,6 +272,24 @@ async function downloadAndReplace(version, key, expectedSha) {
199
272
  * "0.1.8 → v0.1.9" which looks like a typo. */
200
273
  function dispVer(v) { return String(v).replace(/^v/, ""); }
201
274
 
275
+ /** Memoize a per-version install failure so the background check backs off
276
+ * (FAIL_BACKOFF_MS) instead of re-attempting a version that keeps failing. */
277
+ function recordFailure(version, e) {
278
+ const cache = readCache() || {};
279
+ const prior = cache.fail?.version === version ? cache.fail.count || 0 : 0;
280
+ writeCache({
281
+ ...cache,
282
+ fail: { version, at: Date.now(), count: prior + 1, reason: String(e?.message ?? e).slice(0, 200) },
283
+ });
284
+ }
285
+
286
+ function clearFailure() {
287
+ const cache = readCache();
288
+ if (!cache?.fail) return;
289
+ delete cache.fail;
290
+ writeCache(cache);
291
+ }
292
+
202
293
  /**
203
294
  * Force an update RIGHT NOW for an explicit `mm update` / `--update` — ignores
204
295
  * the 30s throttle AND MM_NO_AUTO_UPDATE (the user asked for it). Swaps the
@@ -230,7 +321,15 @@ export async function forceUpdate() {
230
321
  if (!dirWritable(path.dirname(process.execPath))) {
231
322
  return { status: "manual", from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
232
323
  }
233
- await downloadAndReplace(manifest.version, key, expectedSha);
324
+ // Explicit ask → no backoff gate, but record/clear the failure memo so the
325
+ // background checks stay in sync with what happened here.
326
+ try {
327
+ await downloadAndReplace(manifest.version, key, expectedSha);
328
+ } catch (e) {
329
+ recordFailure(manifest.version, e);
330
+ throw e;
331
+ }
332
+ clearFailure();
234
333
  return { status: "updated", from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
235
334
  } catch (e) {
236
335
  return { status: "error", message: e?.message ?? String(e) };
@@ -251,14 +350,30 @@ export async function maybeCheckForUpdate(flags = {}) {
251
350
  const key = platformKey();
252
351
  if (!key) return null;
253
352
 
353
+ // Sweep parked .old binaries from a previous (Windows) swap; keep any
354
+ // staged downloads — the pending version isn't known yet.
355
+ cleanupLeftovers(process.execPath, true);
356
+
357
+ // Record the attempt BEFORE the network hop: a hung/unreachable CDN gets
358
+ // probed at most once per interval (each probe deadline-capped), not on
359
+ // every single run.
360
+ writeCache({ ...cache, last_check: Date.now() });
361
+
254
362
  const manifest = await fetchVerifiedManifest(`${CDN}/cli/${CHANNEL_PATH}`);
255
- // Always update the cache so we don't re-check for 24 h.
363
+ // Update the cache so we don't re-check within the throttle interval.
256
364
  writeCache({ ...cache, last_check: Date.now(), latest_known: manifest.version });
257
365
 
258
366
  if (!manifest.version || compareVer(manifest.version, BUILD_VERSION) <= 0) return null;
259
367
  const expectedSha = manifest.sha256?.[key];
260
368
  if (!expectedSha) return null;
261
369
 
370
+ // A version that already failed to install (sha mismatch, codesign, swap
371
+ // error) backs off — don't burn a download per command on a bad release.
372
+ // A newer published version passes this gate and clears the memo naturally.
373
+ if (cache.fail && cache.fail.version === manifest.version && Date.now() - (cache.fail.at || 0) < FAIL_BACKOFF_MS) {
374
+ return null;
375
+ }
376
+
262
377
  // Only self-update when we can atomically swap the binary (writable parent
263
378
  // dir). For root-owned dirs like /usr/local/bin we can't — and overwriting
264
379
  // the live binary in place is unsafe — so signal a manual reinstall instead
@@ -267,7 +382,13 @@ export async function maybeCheckForUpdate(flags = {}) {
267
382
  return { manual: true, from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
268
383
  }
269
384
 
270
- await downloadAndReplace(manifest.version, key, expectedSha);
385
+ try {
386
+ await downloadAndReplace(manifest.version, key, expectedSha);
387
+ } catch (e) {
388
+ recordFailure(manifest.version, e);
389
+ return null;
390
+ }
391
+ clearFailure();
271
392
  return { from: dispVer(BUILD_VERSION), to: dispVer(manifest.version) };
272
393
  } catch {
273
394
  return null;
package/src/upgrade.js CHANGED
@@ -2,24 +2,53 @@ import { openUrl } from "./openUrl.js";
2
2
 
3
3
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4
4
 
5
- /** Open Stripe checkout and poll /me until the plan flips off "free". Mirrors
6
- * the browser-login poll UX. Returns the new plan, or null on timeout. */
7
- export async function upgradeViaBrowser({ client, plan, log = console.error, onTick }) {
5
+ /** Has the account reached the TARGET plan? "pro" = any paid plan; "lifetime"
6
+ * needs an explicit lifetime marker (plan_status/planStatus/plan) a Pro
7
+ * subscriber buying Lifetime already reads plan="pro", so a bare "not free"
8
+ * check would claim success before the Stripe page even loads. Exported for
9
+ * tests. */
10
+ export function planArrived(account, target) {
11
+ if (!account) return false;
12
+ if (target === "lifetime") {
13
+ const status = account.plan_status ?? account.planStatus ?? null;
14
+ return status === "lifetime" || account.plan === "lifetime";
15
+ }
16
+ return !!account.plan && account.plan !== "free";
17
+ }
18
+
19
+ /** Open Stripe checkout and poll /me until the TARGET plan is active. Mirrors
20
+ * the browser-login poll UX. Returns the plan name on success, or null on
21
+ * timeout — the caller must NOT claim success on null. `pollMs`/`timeoutMs`
22
+ * are injectable for tests. */
23
+ export async function upgradeViaBrowser({ client, plan, log = console.error, onTick, pollMs = 4000, timeoutMs = 10 * 60 * 1000 }) {
24
+ const target = plan === "lifetime" ? "lifetime" : "pro";
25
+
26
+ // Snapshot BEFORE opening checkout: already on the target → nothing to buy
27
+ // (and nothing to falsely "confirm" on the first poll tick).
28
+ try {
29
+ const me = await client.me();
30
+ if (planArrived(me?.account, target)) {
31
+ log(`\n ✓ Your plan is already ${target} — nothing to buy.\n`);
32
+ return target;
33
+ }
34
+ } catch { /* can't pre-check — the poll below decides */ }
35
+
8
36
  const { url } = await client.checkout(plan);
9
37
  if (!url) throw new Error("Could not start checkout.");
10
38
  log(`\n Opening checkout in your browser…`);
11
39
  log(` If it doesn't open, visit:\n ${url}\n`);
12
40
  openUrl(url);
41
+ log(` Waiting for checkout to complete… (ctrl-c to stop)`);
13
42
 
14
- const deadline = Date.now() + 10 * 60 * 1000;
43
+ const deadline = Date.now() + timeoutMs;
15
44
  while (Date.now() < deadline) {
16
- await sleep(4000);
45
+ await sleep(pollMs);
17
46
  try {
18
47
  const me = await client.me();
19
48
  onTick?.(me);
20
- if (me?.account?.plan && me.account.plan !== "free") {
21
- log(`\n ✓ Upgraded to ${me.account.plan}. Thank you!\n`);
22
- return me.account.plan;
49
+ if (planArrived(me?.account, target)) {
50
+ log(`\n ✓ Upgraded to ${target}. Thank you!\n`);
51
+ return target;
23
52
  }
24
53
  } catch {
25
54
  /* keep polling */