@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/package.json +7 -2
- package/src/api.js +41 -11
- package/src/changelog-data.js +23 -0
- package/src/ci.js +8 -4
- package/src/detect/core.js +17 -6
- package/src/fix.js +132 -46
- package/src/index.js +172 -38
- package/src/integrations.js +18 -3
- package/src/registry/fetch.js +16 -1
- package/src/sources/aws-lambda.js +9 -2
- package/src/sources/aws.js +7 -1
- package/src/sources/filesystem.js +0 -0
- package/src/sources/github-actions.js +9 -2
- package/src/sources/helm.js +9 -3
- package/src/sources/index.js +45 -3
- package/src/sources/k8s.js +6 -2
- package/src/sources/shell.js +126 -14
- package/src/sources/sql.js +6 -2
- package/src/sources/supabase-edge.js +19 -5
- package/src/sources/vercel.js +64 -6
- package/src/telemetry.js +21 -0
- package/src/tui/app.js +55 -13
- package/src/tui/game/launch.js +14 -2
- package/src/tui/signin.js +43 -7
- package/src/tui/views/account.js +19 -2
- package/src/tui/views/alerts.js +24 -9
- package/src/tui/views/whatsnew.js +82 -28
- package/src/updater.js +160 -39
- package/src/upgrade.js +37 -8
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
|
-
* -
|
|
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
|
-
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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(
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
try { fs.unlinkSync(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
|
|
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() +
|
|
43
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
44
|
while (Date.now() < deadline) {
|
|
16
|
-
await sleep(
|
|
45
|
+
await sleep(pollMs);
|
|
17
46
|
try {
|
|
18
47
|
const me = await client.me();
|
|
19
48
|
onTick?.(me);
|
|
20
|
-
if (me?.account
|
|
21
|
-
log(`\n ✓ Upgraded to ${
|
|
22
|
-
return
|
|
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 */
|