@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/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
- const s = await client.authStart({ client_name: "mm CLI" });
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
- openUrl(s.verification_url); // best-effort, no error if it fails
44
-
45
- const interval = Math.max(1, s.interval || 3) * 1000;
46
- const deadline = Date.now() + (s.expires_in || 600) * 1000;
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
  }
@@ -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
- setTimeout(tick, 4000);
69
+ if (!mountedRef.current) return;
70
+ pollTimerRef.current = setTimeout(tick, 4000);
56
71
  };
57
- setTimeout(tick, 4000);
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
  }
@@ -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
- return client.deleteRule(cur.id).then(() => {
130
- ui.showToast("rule deleted");
131
- rules.reload();
132
- }).catch((e) => ui.showToast(e.message, "red"));
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(0, ROWS).map((r, i) => {
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(0, ROWS).map((c, i) => {
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
- * - 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 */