@modelstatus/cli 0.1.86 → 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 +14 -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/updater.js +160 -39
- package/src/upgrade.js +37 -8
package/src/tui/signin.js
CHANGED
|
@@ -12,6 +12,19 @@ import { h } from "./ui.js";
|
|
|
12
12
|
|
|
13
13
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
14
14
|
|
|
15
|
+
// One in-flight device-auth session per apiBase, held OUTSIDE the component.
|
|
16
|
+
// SignIn is remounted on every signed-out Account-tab visit (and after a game
|
|
17
|
+
// round-trip); without this, each mount minted a NEW device code and popped
|
|
18
|
+
// ANOTHER browser tab — and an approval made in the browser after the user
|
|
19
|
+
// switched tabs landed on a code nobody was polling. A remount now resumes the
|
|
20
|
+
// pending session (same code, same URL, no extra tab) and picks the approval up.
|
|
21
|
+
// Sessions clear on approval/denial/expiry/start-failure (and g re-mints one).
|
|
22
|
+
// apiBase -> { device_code, user_code, verification_url, interval, deadline, opened }
|
|
23
|
+
const authSessions = new Map();
|
|
24
|
+
|
|
25
|
+
/** Test hook: forget all pending device-auth sessions. */
|
|
26
|
+
export function _resetAuthSessions() { authSessions.clear(); }
|
|
27
|
+
|
|
15
28
|
export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
16
29
|
const { exit } = useApp();
|
|
17
30
|
const [phase, setPhase] = React.useState("starting"); // starting | polling | error
|
|
@@ -35,19 +48,38 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
|
35
48
|
|
|
36
49
|
const start = async () => {
|
|
37
50
|
try {
|
|
38
|
-
|
|
51
|
+
// Resume the session a previous mount left in flight (tab switch, game
|
|
52
|
+
// round-trip) — same code, same browser tab, and an approval made while
|
|
53
|
+
// we weren't looking gets picked up on the first poll. Mint a fresh one
|
|
54
|
+
// only when none is pending or the old one passed its deadline.
|
|
55
|
+
let s = authSessions.get(apiBase);
|
|
56
|
+
if (!s || Date.now() > s.deadline) {
|
|
57
|
+
const fresh = await client.authStart({ client_name: "mm CLI" });
|
|
58
|
+
s = {
|
|
59
|
+
device_code: fresh.device_code,
|
|
60
|
+
user_code: fresh.user_code,
|
|
61
|
+
verification_url: fresh.verification_url,
|
|
62
|
+
interval: Math.max(1, fresh.interval || 3) * 1000,
|
|
63
|
+
deadline: Date.now() + (fresh.expires_in || 600) * 1000,
|
|
64
|
+
opened: false,
|
|
65
|
+
};
|
|
66
|
+
// Cache even if this mount was cancelled mid-start — the code is
|
|
67
|
+
// already minted, so let the next visit resume it instead of orphaning it.
|
|
68
|
+
authSessions.set(apiBase, s);
|
|
69
|
+
}
|
|
39
70
|
if (cancelled) return;
|
|
40
71
|
setCode(s.user_code);
|
|
41
72
|
setUrl(s.verification_url);
|
|
42
73
|
setPhase("polling");
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
74
|
+
if (!s.opened) {
|
|
75
|
+
s.opened = true;
|
|
76
|
+
openUrl(s.verification_url); // best-effort, ONCE per session (o re-opens)
|
|
77
|
+
}
|
|
47
78
|
|
|
48
79
|
const poll = async () => {
|
|
49
80
|
if (cancelled) return;
|
|
50
|
-
if (Date.now() > deadline) {
|
|
81
|
+
if (Date.now() > s.deadline) {
|
|
82
|
+
authSessions.delete(apiBase);
|
|
51
83
|
setError("The login request expired.");
|
|
52
84
|
setPhase("error");
|
|
53
85
|
return;
|
|
@@ -60,6 +92,7 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
|
60
92
|
}
|
|
61
93
|
if (cancelled) return;
|
|
62
94
|
if (res?.status === "approved") {
|
|
95
|
+
authSessions.delete(apiBase);
|
|
63
96
|
const cfg = loadConfig();
|
|
64
97
|
cfg.apiKey = res.api_key;
|
|
65
98
|
cfg.apiBase = apiBase;
|
|
@@ -68,20 +101,23 @@ export function SignIn({ apiBase, onSuccess, onSkip }) {
|
|
|
68
101
|
return;
|
|
69
102
|
}
|
|
70
103
|
if (res?.status === "denied") {
|
|
104
|
+
authSessions.delete(apiBase);
|
|
71
105
|
setError("Authorization was denied.");
|
|
72
106
|
setPhase("error");
|
|
73
107
|
return;
|
|
74
108
|
}
|
|
75
109
|
if (res?.status === "expired") {
|
|
110
|
+
authSessions.delete(apiBase);
|
|
76
111
|
setError("The login request expired. Press q and run mm login again.");
|
|
77
112
|
setPhase("error");
|
|
78
113
|
return;
|
|
79
114
|
}
|
|
80
|
-
pollTimer = setTimeout(poll, interval);
|
|
115
|
+
pollTimer = setTimeout(poll, s.interval);
|
|
81
116
|
};
|
|
82
117
|
poll();
|
|
83
118
|
} catch (e) {
|
|
84
119
|
if (cancelled) return;
|
|
120
|
+
authSessions.delete(apiBase); // a failed start isn't resumable
|
|
85
121
|
setError(e?.message || String(e));
|
|
86
122
|
setPhase("error");
|
|
87
123
|
}
|
package/src/tui/views/account.js
CHANGED
|
@@ -28,6 +28,18 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
28
28
|
|
|
29
29
|
React.useEffect(() => ui?.reportStatus?.({ context: `plan: ${me?.plan ?? "…"}` }), [me, ui]);
|
|
30
30
|
|
|
31
|
+
// The upgrade poll below is a self-rescheduling setTimeout chain — without
|
|
32
|
+
// cleanup it outlives the view (keeps hitting /me every 4s for up to 10min,
|
|
33
|
+
// sets state on an unmounted component, and holds the event loop open AFTER
|
|
34
|
+
// quit, hanging the user's shell). Cancel it the moment the view unmounts.
|
|
35
|
+
const pollTimerRef = React.useRef(null);
|
|
36
|
+
const mountedRef = React.useRef(true);
|
|
37
|
+
React.useEffect(() => () => {
|
|
38
|
+
mountedRef.current = false;
|
|
39
|
+
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
|
40
|
+
pollTimerRef.current = null;
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
31
43
|
async function upgrade() {
|
|
32
44
|
// Without a loaded account a checkout would just fire at an unreachable
|
|
33
45
|
// endpoint and die with a raw fetch error — say what to do instead.
|
|
@@ -40,9 +52,11 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
40
52
|
setStatus("Complete checkout in your browser… (polling /me)");
|
|
41
53
|
const deadline = Date.now() + 10 * 60 * 1000;
|
|
42
54
|
const tick = async () => {
|
|
55
|
+
if (!mountedRef.current) return;
|
|
43
56
|
if (Date.now() > deadline) return setStatus("Timed out — press u to retry.");
|
|
44
57
|
try {
|
|
45
58
|
const m = await client.me();
|
|
59
|
+
if (!mountedRef.current) return;
|
|
46
60
|
if (m?.account?.plan && m.account.plan !== "free") {
|
|
47
61
|
setStatus(null);
|
|
48
62
|
ui.showToast(`upgraded to ${m.account.plan}!`);
|
|
@@ -52,9 +66,12 @@ export function AccountView({ client, me, refreshMe, apiBase, ui, active }) {
|
|
|
52
66
|
} catch {
|
|
53
67
|
/* keep polling */
|
|
54
68
|
}
|
|
55
|
-
|
|
69
|
+
if (!mountedRef.current) return;
|
|
70
|
+
pollTimerRef.current = setTimeout(tick, 4000);
|
|
56
71
|
};
|
|
57
|
-
|
|
72
|
+
// A second u press replaces (not stacks) any poll chain already running.
|
|
73
|
+
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
|
74
|
+
pollTimerRef.current = setTimeout(tick, 4000);
|
|
58
75
|
} catch (e) {
|
|
59
76
|
setStatus(e.status === 503 ? "Billing isn't configured on this server." : `checkout failed: ${e.message} — press u to retry.`);
|
|
60
77
|
}
|
package/src/tui/views/alerts.js
CHANGED
|
@@ -125,11 +125,20 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
125
125
|
.patchRule(cur.id, { delivery: DELIVERY[(DELIVERY.indexOf(cur.delivery) + 1) % 3] })
|
|
126
126
|
.then(() => rules.reload())
|
|
127
127
|
.catch((e) => ui.showToast(e.message, "red"));
|
|
128
|
-
if (input === "d")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
128
|
+
if (input === "d") {
|
|
129
|
+
// One stray keypress shouldn't silently delete a rule — confirm with a
|
|
130
|
+
// single y, same pattern as InventoryView's d (inventory.js).
|
|
131
|
+
return ui.askPrompt(`Delete ${ruleLabel(cur)}? type y`, {
|
|
132
|
+
onSubmit: (v) => {
|
|
133
|
+
if (String(v || "").trim().toLowerCase() !== "y") return ui.showToast("delete cancelled");
|
|
134
|
+
client.deleteRule(cur.id).then(() => {
|
|
135
|
+
ui.showToast("rule deleted");
|
|
136
|
+
setCursor((c) => clampCursor(c, ruleList.length - 1));
|
|
137
|
+
rules.reload();
|
|
138
|
+
}).catch((e) => ui.showToast(e.message, "red"));
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
133
142
|
} else {
|
|
134
143
|
if (input === "n") return addChannel();
|
|
135
144
|
if (input === "t" && cur) return testChannel(cur);
|
|
@@ -150,14 +159,18 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
150
159
|
if (!ruleList.length) {
|
|
151
160
|
body = h(EmptyCard, { title: "No alert rules yet", lines: ["Stay ahead of your model timeline — a heads-up 90, 30, 7, and 1 day before anything you use is deprecated or retired.", "Press n to set the sensible default (your models · in-app + email · those lead times)."], width });
|
|
152
161
|
} else {
|
|
162
|
+
// Window the list around the cursor (same as whatsnew.js) — a fixed
|
|
163
|
+
// slice(0, ROWS) lets the selection walk below the visible page and
|
|
164
|
+
// space/c/d act on a row the user can't see.
|
|
153
165
|
const curIdx = clampCursor(cursor, ruleList.length);
|
|
166
|
+
const start = Math.max(0, Math.min(curIdx - ROWS + 1, ruleList.length - ROWS));
|
|
154
167
|
const fixed = 2 + 26 + 1 + 10 + 1; // glyph + name + gap + delivery + gap
|
|
155
168
|
const rest = Math.max(8, width - 1 - fixed);
|
|
156
169
|
body = h(
|
|
157
170
|
Box,
|
|
158
171
|
{ flexDirection: "column" },
|
|
159
|
-
...ruleList.slice(
|
|
160
|
-
const isCur = i === curIdx;
|
|
172
|
+
...ruleList.slice(start, start + ROWS).map((r, i) => {
|
|
173
|
+
const isCur = start + i === curIdx;
|
|
161
174
|
const enabled = !!r.enabled;
|
|
162
175
|
const leads = r.leadTimes || r.lead_times || [];
|
|
163
176
|
const chanText = [(r.channels || []).join(","), leads.length ? leads.join("/") + "d" : ""].filter(Boolean).join(" · ");
|
|
@@ -182,13 +195,15 @@ export function AlertsView({ client, ui, active, width = 78, height = 14 }) {
|
|
|
182
195
|
h(Text, { color: "#d97706" }, " Slack/Discord/SMS/webhook channels need Pro — press 7 → u to upgrade."),
|
|
183
196
|
);
|
|
184
197
|
} else {
|
|
198
|
+
// Windowed around the cursor like the rules list above.
|
|
185
199
|
const curIdx = clampCursor(cursor, chanList.length);
|
|
200
|
+
const start = Math.max(0, Math.min(curIdx - ROWS + 1, chanList.length - ROWS));
|
|
186
201
|
const rest = Math.max(8, width - 1 - 10); // after the 10-wide kind pill
|
|
187
202
|
body = h(
|
|
188
203
|
Box,
|
|
189
204
|
{ flexDirection: "column" },
|
|
190
|
-
...chanList.slice(
|
|
191
|
-
const isCur = i === curIdx;
|
|
205
|
+
...chanList.slice(start, start + ROWS).map((c, i) => {
|
|
206
|
+
const isCur = start + i === curIdx;
|
|
192
207
|
const cells = [
|
|
193
208
|
{ text: cell(c.kind, 10), color: KIND_COLOR[c.kind] || C.FG_DIM, bold: true },
|
|
194
209
|
{ text: cellE(c.label || c.value, rest), color: C.FG },
|
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 */
|