vexp-cli 3.2.5 → 3.3.0

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/dist/license.js CHANGED
@@ -16,7 +16,21 @@ function vexpHomeDir() {
16
16
  return os.homedir();
17
17
  }
18
18
  const VEXP_LICENSE_PUBLIC_KEY = "MCowBQYDK2VwAyEApwiWYGCyhCaGHAHWn/RTBSGo/1MNmGyZUgSNBQ5YE4g=";
19
- const VEXP_WEB_ORIGIN = process.env.VEXP_WEB_ORIGIN || "https://vexp.dev";
19
+ let licensePublicKeyDer = VEXP_LICENSE_PUBLIC_KEY;
20
+ /**
21
+ * Tests only: verify tokens against a key the test generated, so the success
22
+ * paths run through real Ed25519 verification without the server's private
23
+ * key. Not reachable from the command line or the environment: only code
24
+ * already running inside this process can call it.
25
+ */
26
+ export function __setLicensePublicKeyForTests(derBase64) {
27
+ licensePublicKeyDer = derBase64 ?? VEXP_LICENSE_PUBLIC_KEY;
28
+ }
29
+ /** Read at call time, not import time, so a test (or a user who exports it
30
+ * after a long-lived process started) always hits the origin it names. */
31
+ function webOrigin() {
32
+ return process.env.VEXP_WEB_ORIGIN || "https://vexp.dev";
33
+ }
20
34
  // 14-day grace window after the last successful online refresh before we
21
35
  // stop trusting the cached freshToken and fall back to the long JWT only.
22
36
  const GRACE_MS = 14 * 24 * 60 * 60 * 1000;
@@ -28,52 +42,193 @@ const REFRESH_BACKOFF_MS = 24 * 60 * 60 * 1000;
28
42
  // fresh.jwt alive quietly never landed and users fell back to the long
29
43
  // JWT — or, once that lapsed, to the free tier.
30
44
  const VALIDATE_TIMEOUT_MS = 10_000;
45
+ /** Where the user gets a current key: the account page re-mints it. */
46
+ export const ACCOUNT_URL = "https://vexp.dev/account";
47
+ function vexpDir() {
48
+ return path.join(vexpHomeDir(), ".vexp");
49
+ }
31
50
  function getLicensePath() {
32
- return path.join(vexpHomeDir(), ".vexp", "license.jwt");
51
+ return path.join(vexpDir(), "license.jwt");
33
52
  }
34
53
  function getFreshTokenPath() {
35
- return path.join(vexpHomeDir(), ".vexp", "fresh.jwt");
54
+ return path.join(vexpDir(), "fresh.jwt");
36
55
  }
37
56
  function getLastCheckPath() {
38
- return path.join(vexpHomeDir(), ".vexp", "last_online_check");
57
+ return path.join(vexpDir(), "last_online_check");
39
58
  }
40
59
  function getDeviceIdPath() {
41
- return path.join(vexpHomeDir(), ".vexp", "device.id");
60
+ return path.join(vexpDir(), "device.id");
42
61
  }
43
62
  /**
44
- * Stable anonymous device identifier. First call creates and persists it.
45
- * Uses OS hostname + a random salt — no PII, no hardware fingerprinting.
63
+ * Write a file so that a reader never sees it half-written: the content goes
64
+ * to a temp file next to the target and is renamed over it. The Rust engine,
65
+ * the VS Code extension and this CLI all write these files, sometimes at the
66
+ * same moment; with a plain writeFileSync one of them could read a truncated
67
+ * token and fall to FREE. When the rename is refused (a Windows reader
68
+ * holding the target open), fall back to a direct write rather than lose the
69
+ * update. Throws only when neither works.
46
70
  */
47
- function getDeviceId() {
48
- const p = getDeviceIdPath();
71
+ export function atomicWriteFile(target, content) {
72
+ fs.mkdirSync(path.dirname(target), { recursive: true });
73
+ const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
49
74
  try {
50
- const existing = fs.readFileSync(p, "utf-8").trim();
51
- if (existing)
52
- return existing;
75
+ fs.writeFileSync(tmp, content, "utf-8");
76
+ try {
77
+ fs.renameSync(tmp, target);
78
+ }
79
+ catch {
80
+ fs.writeFileSync(target, content, "utf-8");
81
+ }
82
+ }
83
+ finally {
84
+ try {
85
+ fs.rmSync(tmp, { force: true });
86
+ }
87
+ catch {
88
+ /* already renamed, or gone */
89
+ }
90
+ }
91
+ }
92
+ function readTrimmed(p) {
93
+ try {
94
+ const s = fs.readFileSync(p, "utf-8").trim();
95
+ return s.length > 0 ? s : null;
53
96
  }
54
97
  catch {
55
- // first run
98
+ return null;
56
99
  }
57
- const raw = `${os.hostname()}::${crypto.randomBytes(16).toString("hex")}`;
58
- const id = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 32);
100
+ }
101
+ function sleepSync(ms) {
59
102
  try {
60
- fs.mkdirSync(path.dirname(p), { recursive: true });
61
- fs.writeFileSync(p, id, "utf-8");
103
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
62
104
  }
63
105
  catch {
64
- // read-only FS: return the ephemeral id, refresh will still work
106
+ /* no Atomics.wait on this thread: retry immediately */
65
107
  }
66
- return id;
67
108
  }
68
- function readLastCheck() {
109
+ /**
110
+ * Create `target` with `content` only if it does not exist yet, and return
111
+ * whatever the file holds afterwards: ours if we created it, the other
112
+ * writer's if they got there first. The content is staged in a temp file and
113
+ * hard-linked into place, so the file never exists empty; filesystems without
114
+ * hard links fall back to an exclusive create. A file that exists but is
115
+ * empty (a writer that died mid-write, or an exclusive create on a filesystem
116
+ * without hard links whose writer never finished) is replaced after a short
117
+ * wait, the Rust engine's rule: otherwise every caller would mint a new id
118
+ * that is never saved, and each check-in would register another device.
119
+ * Returns null when nothing can be written (read-only home).
120
+ */
121
+ export function createFileIfAbsent(target, content) {
122
+ const existing = readTrimmed(target);
123
+ if (existing)
124
+ return existing;
69
125
  try {
70
- const s = fs.readFileSync(getLastCheckPath(), "utf-8").trim();
71
- const n = Number(s);
72
- return Number.isFinite(n) ? n : 0;
126
+ fs.mkdirSync(path.dirname(target), { recursive: true });
73
127
  }
74
128
  catch {
75
- return 0;
129
+ return null;
130
+ }
131
+ const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
132
+ try {
133
+ fs.writeFileSync(tmp, content, "utf-8");
134
+ try {
135
+ fs.linkSync(tmp, target);
136
+ }
137
+ catch (e) {
138
+ const code = e.code;
139
+ if (code !== "EEXIST") {
140
+ try {
141
+ fs.writeFileSync(target, content, { encoding: "utf-8", flag: "wx" });
142
+ }
143
+ catch {
144
+ /* someone else created it, or we cannot write: read below */
145
+ }
146
+ }
147
+ }
148
+ }
149
+ catch {
150
+ /* cannot even stage the temp file */
151
+ }
152
+ finally {
153
+ try {
154
+ fs.rmSync(tmp, { force: true });
155
+ }
156
+ catch {
157
+ /* ignore */
158
+ }
159
+ }
160
+ // An exclusive create by another writer can be visible before its content
161
+ // is: give a writer that is still running a moment rather than mint a
162
+ // second id. A file untouched for longer than that is a dead writer's.
163
+ if (!emptyFileIsStale(target)) {
164
+ for (let i = 0; i < 20; i++) {
165
+ const now = readTrimmed(target);
166
+ if (now)
167
+ return now;
168
+ sleepSync(10);
169
+ }
170
+ }
171
+ const now = readTrimmed(target);
172
+ if (now)
173
+ return now;
174
+ if (!fs.existsSync(target))
175
+ return null;
176
+ // Still empty: replace it, then return what the file holds (a writer that
177
+ // raced us to the same repair may have won; its id is the machine's).
178
+ try {
179
+ atomicWriteFile(target, content);
76
180
  }
181
+ catch {
182
+ return null;
183
+ }
184
+ return readTrimmed(target);
185
+ }
186
+ /** True when `target` exists and was last written over 2 s ago. */
187
+ function emptyFileIsStale(target) {
188
+ try {
189
+ return Date.now() - fs.statSync(target).mtimeMs > 2_000;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
195
+ /** A new random device id: sha256(hostname::16 random bytes), 32 hex chars. */
196
+ export function mintDeviceId() {
197
+ const raw = `${os.hostname()}::${crypto.randomBytes(16).toString("hex")}`;
198
+ return crypto.createHash("sha256").update(raw).digest("hex").slice(0, 32);
199
+ }
200
+ /**
201
+ * Stable anonymous device identifier, shared by every vexp client on the
202
+ * machine through `<home>/.vexp/device.id`. First caller creates it. Uses the
203
+ * OS hostname + a random salt — no PII, no hardware fingerprinting. The server
204
+ * counts these as devices, so two ids for one machine would burn two slots:
205
+ * the file is created only if absent and whoever loses the race adopts the
206
+ * winner's id.
207
+ */
208
+ export function getDeviceId() {
209
+ const p = getDeviceIdPath();
210
+ const existing = readTrimmed(p);
211
+ if (existing)
212
+ return existing;
213
+ const id = mintDeviceId();
214
+ // Read-only FS: return the ephemeral id, refresh will still work.
215
+ return createFileIfAbsent(p, id) ?? id;
216
+ }
217
+ function readLastCheck() {
218
+ const s = readTrimmed(getLastCheckPath());
219
+ if (!s)
220
+ return 0;
221
+ const n = Number(s);
222
+ return Number.isFinite(n) ? n : 0;
223
+ }
224
+ /**
225
+ * When this machine last heard back from vexp.dev (a registration, a device
226
+ * cap refusal or a revocation), or null if it never has. Read from the same
227
+ * stamp that throttles the check-in, so every client reports the same date.
228
+ */
229
+ export function lastCheckIn() {
230
+ const ts = readLastCheck();
231
+ return ts > 0 ? new Date(ts) : null;
77
232
  }
78
233
  /** Forget when we last checked in, so the next refresh is not throttled. */
79
234
  export function clearLastCheck() {
@@ -86,28 +241,19 @@ export function clearLastCheck() {
86
241
  }
87
242
  function writeLastCheck(ts) {
88
243
  try {
89
- fs.mkdirSync(path.dirname(getLastCheckPath()), { recursive: true });
90
- fs.writeFileSync(getLastCheckPath(), String(ts), "utf-8");
244
+ atomicWriteFile(getLastCheckPath(), String(ts));
91
245
  }
92
246
  catch {
93
247
  // ignore
94
248
  }
95
249
  }
96
250
  function loadFreshToken() {
97
- try {
98
- const jwt = fs.readFileSync(getFreshTokenPath(), "utf-8").trim();
99
- if (!jwt)
100
- return null;
101
- return verifyAndDecode(jwt);
102
- }
103
- catch {
104
- return null;
105
- }
251
+ const jwt = readTrimmed(getFreshTokenPath());
252
+ return jwt ? verifyAndDecode(jwt) : null;
106
253
  }
107
254
  function saveFreshToken(jwt) {
108
255
  try {
109
- fs.mkdirSync(path.dirname(getFreshTokenPath()), { recursive: true });
110
- fs.writeFileSync(getFreshTokenPath(), jwt, "utf-8");
256
+ atomicWriteFile(getFreshTokenPath(), jwt);
111
257
  }
112
258
  catch {
113
259
  // ignore
@@ -121,8 +267,34 @@ function removeFreshToken() {
121
267
  // ignore
122
268
  }
123
269
  }
270
+ /**
271
+ * Replace the long JWT on disk, but never with an older one. The Rust engine
272
+ * and the VS Code extension roll this file too; a slower writer holding a
273
+ * token minted earlier must not wind the machine back. Returns true when the
274
+ * file now holds `jwt`.
275
+ */
276
+ function writeLongTokenIfNewer(jwt) {
277
+ const next = verifyAndDecode(jwt);
278
+ if (!next)
279
+ return false;
280
+ const currentJwt = readTrimmed(getLicensePath());
281
+ const current = currentJwt ? verifyAndDecode(currentJwt) : null;
282
+ if (current && current.exp > next.exp)
283
+ return false;
284
+ if (currentJwt === jwt)
285
+ return true;
286
+ try {
287
+ atomicWriteFile(getLicensePath(), jwt);
288
+ return true;
289
+ }
290
+ catch {
291
+ // read-only FS: the freshToken still carries the new entitlement for this
292
+ // session; we just can't persist the long JWT.
293
+ return false;
294
+ }
295
+ }
124
296
  function getDeviceBlockedPath() {
125
- return path.join(vexpHomeDir(), ".vexp", "device_blocked.json");
297
+ return path.join(vexpDir(), "device_blocked.json");
126
298
  }
127
299
  /** Returns the active device-blocked marker, if any. */
128
300
  export function readDeviceBlocked() {
@@ -143,8 +315,7 @@ export function readDeviceBlocked() {
143
315
  }
144
316
  function writeDeviceBlocked(info) {
145
317
  try {
146
- fs.mkdirSync(path.dirname(getDeviceBlockedPath()), { recursive: true });
147
- fs.writeFileSync(getDeviceBlockedPath(), JSON.stringify(info), "utf-8");
318
+ atomicWriteFile(getDeviceBlockedPath(), JSON.stringify(info));
148
319
  }
149
320
  catch {
150
321
  // ignore
@@ -158,33 +329,22 @@ function clearDeviceBlocked() {
158
329
  // ignore
159
330
  }
160
331
  }
332
+ function num(v) {
333
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
334
+ }
161
335
  /**
162
- * Opportunistic online license refresh.
163
- *
164
- * Additive-only: any failure (network, 404, 5xx, timeout, server disabled)
165
- * results in a silent no-op. The caller keeps using the local long JWT
166
- * exactly as before. This MUST never throw.
336
+ * One POST to /api/license/validate, classified. No file is touched here:
337
+ * the caller decides whether to apply the answer ({@link applyValidateResult}),
338
+ * which lets a provisional activation ask first and change nothing on refusal.
339
+ * Never throws.
167
340
  */
168
- export async function tryOnlineRefresh(longJwt) {
169
- // Global kill switch for users who want to stay fully offline
341
+ async function callValidate(longJwt) {
170
342
  if (process.env.VEXP_OFFLINE === "1")
171
- return;
172
- // Rate limit: at most one network call per REFRESH_BACKOFF_MS,
173
- // UNLESS the long JWT is within the last 7 days of validity.
174
- // That window is where expiry is imminent — bypass backoff so any
175
- // CLI invocation in the danger zone attempts a refresh.
176
- const last = readLastCheck();
177
- const longClaims = verifyAndDecode(longJwt);
178
- const nearExpiryMs = 7 * 24 * 60 * 60 * 1000;
179
- const nearExpiry = longClaims
180
- ? longClaims.exp * 1000 - Date.now() < nearExpiryMs
181
- : false;
182
- if (!nearExpiry && Date.now() - last < REFRESH_BACKOFF_MS)
183
- return;
343
+ return { outcome: { kind: "offline" } };
184
344
  const controller = new AbortController();
185
345
  const timer = setTimeout(() => controller.abort(), VALIDATE_TIMEOUT_MS);
186
346
  try {
187
- const res = await fetch(`${VEXP_WEB_ORIGIN}/api/license/validate`, {
347
+ const res = await fetch(`${webOrigin()}/api/license/validate`, {
188
348
  method: "POST",
189
349
  headers: { "content-type": "application/json" },
190
350
  body: JSON.stringify({
@@ -194,70 +354,216 @@ export async function tryOnlineRefresh(longJwt) {
194
354
  }),
195
355
  signal: controller.signal,
196
356
  });
197
- clearTimeout(timer);
198
- // 404 = endpoint not deployed yet (old server), 503 = kill switch
199
- if (res.status === 404 || res.status === 503) {
200
- return;
201
- }
357
+ // 404 = endpoint not deployed (old server), 503 = kill switch.
202
358
  if (!res.ok)
203
- return;
204
- const data = (await res.json());
205
- if (data && data.valid && data.freshToken) {
206
- // Only save if the freshToken itself verifies locally
207
- if (verifyAndDecode(data.freshToken)) {
208
- saveFreshToken(data.freshToken);
209
- // The server re-issued the 30-day long token (rolled forward on every
210
- // refresh since vexp-web 3.1; also on entitlement change). Overwrite
211
- // the on-disk long JWT so the current entitlement survives even fully
212
- // offline and a superseded one stops working — without the user
213
- // re-pasting a key. Only persist if it verifies locally.
214
- if (data.newLongToken && verifyAndDecode(data.newLongToken)) {
215
- try {
216
- const licensePath = getLicensePath();
217
- fs.mkdirSync(path.dirname(licensePath), { recursive: true });
218
- fs.writeFileSync(licensePath, data.newLongToken, "utf-8");
219
- }
220
- catch {
221
- // read-only FS: the freshToken still carries the new entitlement
222
- // for this session; we just can't persist the long JWT.
223
- }
224
- }
225
- writeLastCheck(Date.now());
226
- // Any previous device-blocked state is stale once the server issues
227
- // a freshToken for us — we are back in a good state.
228
- clearDeviceBlocked();
359
+ return { outcome: { kind: "http", status: res.status } };
360
+ let data;
361
+ try {
362
+ data = (await res.json());
363
+ }
364
+ catch {
365
+ return { outcome: { kind: "rejected", reason: "unreadable_response" } };
366
+ }
367
+ if (!data || typeof data !== "object") {
368
+ return { outcome: { kind: "rejected", reason: "unreadable_response" } };
369
+ }
370
+ if (data.valid && data.freshToken) {
371
+ // Only trust a freshToken that verifies locally.
372
+ if (!verifyAndDecode(data.freshToken)) {
373
+ return { outcome: { kind: "rejected", reason: "unverifiable_token" }, data };
229
374
  }
375
+ // An old server never sends deviceRegistered and registers whenever it
376
+ // answers valid; a 3.3+ server says false when the device store did
377
+ // not take this device, and then nothing may claim it is registered.
378
+ if (data.deviceRegistered === false)
379
+ return { outcome: { kind: "unconfirmed" }, data };
380
+ return {
381
+ outcome: {
382
+ kind: "registered",
383
+ currentDevices: num(data.currentDevices),
384
+ maxDevices: num(data.maxDevices),
385
+ },
386
+ data,
387
+ };
230
388
  }
231
- else if (data && data.reason === "device_limit_exceeded") {
389
+ if (data.reason === "device_limit_exceeded") {
390
+ return {
391
+ outcome: {
392
+ kind: "device_limit_exceeded",
393
+ currentDevices: num(data.currentDevices),
394
+ maxDevices: num(data.maxDevices) ?? 4,
395
+ manageUrl: data.manageUrl ?? "https://vexp.dev/account/devices",
396
+ },
397
+ data,
398
+ };
399
+ }
400
+ if (data.revoked) {
401
+ return {
402
+ outcome: { kind: data.reason === "deactivated" ? "deactivated" : "revoked" },
403
+ data,
404
+ };
405
+ }
406
+ return { outcome: { kind: "rejected", reason: data.reason ?? "not_valid" }, data };
407
+ }
408
+ catch (err) {
409
+ // network error, timeout, abort
410
+ return { outcome: { kind: "unreachable", detail: networkErrorDetail(err) } };
411
+ }
412
+ finally {
413
+ clearTimeout(timer);
414
+ }
415
+ }
416
+ /**
417
+ * What actually failed. Node's fetch reports every network failure as
418
+ * "fetch failed" and keeps the cause (ECONNREFUSED, ENOTFOUND, a TLS code
419
+ * such as SELF_SIGNED_CERT_IN_CHAIN) one level down.
420
+ */
421
+ export function networkErrorDetail(err) {
422
+ if (!(err instanceof Error))
423
+ return String(err);
424
+ if (err.name === "AbortError" || err.name === "TimeoutError")
425
+ return "timed out";
426
+ const cause = err.cause;
427
+ if (cause && typeof cause === "object") {
428
+ if (typeof cause.code === "string" && cause.code)
429
+ return cause.code;
430
+ if (typeof cause.message === "string" && cause.message)
431
+ return cause.message;
432
+ }
433
+ return err.message;
434
+ }
435
+ const CERT_ERROR = /CERT|SELF_SIGNED|UNABLE_TO_GET_ISSUER|UNABLE_TO_VERIFY|DEPTH_ZERO|ERR_TLS/i;
436
+ /**
437
+ * One line of advice for an unreachable vexp.dev, or null. A proxy or an
438
+ * antivirus that re-signs HTTPS (common on company Windows machines) makes
439
+ * every check-in fail the same way, so "it will register on its next
440
+ * check-in" never comes true until Node trusts that certificate.
441
+ */
442
+ export function unreachableHint(detail) {
443
+ if (!detail)
444
+ return null;
445
+ if (CERT_ERROR.test(detail)) {
446
+ return `The HTTPS certificate was not trusted (${detail}): a proxy or antivirus is probably inspecting HTTPS. Point NODE_EXTRA_CA_CERTS at its root certificate, or set NODE_USE_SYSTEM_CA=1 (Node 23.8 or later).`;
447
+ }
448
+ return `Cause: ${detail}.`;
449
+ }
450
+ /**
451
+ * Persist what the server said, exactly as every vexp client does, and
452
+ * return what the check-in came to. `sentJwt` is the long token that was
453
+ * posted: if license.jwt no longer holds it (another client activated a
454
+ * different key while the call was in flight), the answer is about the old
455
+ * key and nothing is written, or its tokens would replace the key just
456
+ * activated (the Rust engine's rule).
457
+ */
458
+ function applyValidateResult(outcome, data, sentJwt) {
459
+ const writes = outcome.kind === "registered" ||
460
+ outcome.kind === "unconfirmed" ||
461
+ outcome.kind === "device_limit_exceeded" ||
462
+ outcome.kind === "revoked" ||
463
+ outcome.kind === "deactivated";
464
+ if (writes && readTrimmed(getLicensePath()) !== sentJwt) {
465
+ return { kind: "rejected", reason: "license_changed" };
466
+ }
467
+ switch (outcome.kind) {
468
+ case "registered":
469
+ case "unconfirmed": {
470
+ if (!data?.freshToken)
471
+ return outcome;
472
+ saveFreshToken(data.freshToken);
473
+ // The server re-issued the 30-day long token (rolled forward on every
474
+ // refresh since vexp-web 3.1; also on entitlement change). Replace the
475
+ // on-disk long JWT so the current entitlement survives even fully
476
+ // offline and a superseded one stops working — without the user
477
+ // re-pasting a key. Only if it verifies, and never with an older one.
478
+ if (data.newLongToken)
479
+ writeLongTokenIfNewer(data.newLongToken);
480
+ // Any previous device-blocked state is stale once the server issues
481
+ // a freshToken for us — we are back in a good state.
482
+ clearDeviceBlocked();
483
+ // The stamp says "this machine checked in and is on the device list".
484
+ // An unconfirmed answer is not that: no stamp, so the next check-in
485
+ // (any client) asks again instead of waiting a day.
486
+ if (outcome.kind === "registered")
487
+ writeLastCheck(Date.now());
488
+ return outcome;
489
+ }
490
+ case "device_limit_exceeded":
232
491
  // This machine is over the subscription's device cap. We do NOT touch
233
492
  // the long JWT on disk: the user still owns a valid subscription, they
234
493
  // just need to free a slot on vexp.dev/account/devices. We persist a
235
494
  // marker so the CLI can surface the error next time a command runs.
236
495
  writeDeviceBlocked({
237
496
  at: Date.now(),
238
- maxDevices: data.maxDevices ?? 4,
239
- manageUrl: data.manageUrl ?? "https://vexp.dev/account/devices",
497
+ maxDevices: outcome.maxDevices,
498
+ manageUrl: outcome.manageUrl,
240
499
  });
241
500
  // Drop any stale freshToken so readLicenseLimits downgrades to FREE
242
501
  // until the user frees a slot and retries.
243
502
  removeFreshToken();
244
503
  writeLastCheck(Date.now());
245
- }
246
- else if (data && data.revoked) {
504
+ return outcome;
505
+ case "revoked":
506
+ case "deactivated":
247
507
  // Server confirmed revocation. Drop the cached freshToken but keep
248
508
  // the long JWT on disk — the user will fall back to whatever the
249
509
  // long JWT allows until it expires naturally.
250
510
  removeFreshToken();
251
511
  writeLastCheck(Date.now());
252
- }
512
+ return outcome;
513
+ default:
514
+ // offline / unreachable / http / rejected: nothing changes, and no
515
+ // stamp, so the next command tries again.
516
+ return outcome;
253
517
  }
254
- catch {
255
- // network error, timeout, abort — silent no-op
518
+ }
519
+ /**
520
+ * Opportunistic online license refresh: the check-in that registers this
521
+ * device and rolls the tokens.
522
+ *
523
+ * Additive-only: any failure (network, 404, 5xx, timeout, server disabled)
524
+ * leaves the local long JWT in charge exactly as before. It now RETURNS what
525
+ * happened instead of swallowing it, so the commands a person runs can say
526
+ * whether this machine is registered. Never throws.
527
+ *
528
+ * `force` skips the 24 h throttle (activation, an explicit status check).
529
+ */
530
+ export async function tryOnlineRefresh(longJwt, opts = {}) {
531
+ try {
532
+ // Global kill switch for users who want to stay fully offline
533
+ if (process.env.VEXP_OFFLINE === "1")
534
+ return { kind: "offline" };
535
+ const longClaims = verifyAndDecode(longJwt);
536
+ if (!longClaims)
537
+ return { kind: "no_license" };
538
+ // Rate limit: at most one network call per REFRESH_BACKOFF_MS,
539
+ // UNLESS the long JWT is within the last 7 days of validity.
540
+ // That window is where expiry is imminent — bypass backoff so any
541
+ // CLI invocation in the danger zone attempts a refresh. A machine the
542
+ // server refused for the device cap is not throttled either: the user
543
+ // was told to free a slot and run a command, and the refusal itself
544
+ // stamped the throttle, so the retry would otherwise wait a day.
545
+ if (!opts.force && !readDeviceBlocked()) {
546
+ const last = readLastCheck();
547
+ const nearExpiryMs = 7 * 24 * 60 * 60 * 1000;
548
+ const nearExpiry = longClaims.exp * 1000 - Date.now() < nearExpiryMs;
549
+ if (!nearExpiry && Date.now() - last < REFRESH_BACKOFF_MS) {
550
+ return { kind: "throttled" };
551
+ }
552
+ }
553
+ const { outcome, data } = await callValidate(longJwt);
554
+ return applyValidateResult(outcome, data, longJwt);
256
555
  }
257
- finally {
258
- clearTimeout(timer);
556
+ catch (err) {
557
+ return { kind: "unreachable", detail: networkErrorDetail(err) };
259
558
  }
260
559
  }
560
+ /** Check in with the long JWT on disk (throttled unless `force`). */
561
+ export async function refreshFromDisk(opts = {}) {
562
+ const longJwt = readTrimmed(getLicensePath());
563
+ if (!longJwt)
564
+ return { kind: "no_license" };
565
+ return tryOnlineRefresh(longJwt, opts);
566
+ }
261
567
  function verifyAndDecode(jwt) {
262
568
  try {
263
569
  const parts = jwt.split(".");
@@ -267,7 +573,7 @@ function verifyAndDecode(jwt) {
267
573
  const signingInput = `${headerB64}.${payloadB64}`;
268
574
  const signature = Buffer.from(signatureB64, "base64url");
269
575
  const publicKey = crypto.createPublicKey({
270
- key: Buffer.from(VEXP_LICENSE_PUBLIC_KEY, "base64"),
576
+ key: Buffer.from(licensePublicKeyDer, "base64"),
271
577
  format: "der",
272
578
  type: "spki",
273
579
  });
@@ -286,16 +592,9 @@ const FREE_LIMITS = {
286
592
  allTools: false,
287
593
  plan: "free",
288
594
  };
289
- /** Activate a license key: verify and save to ~/.vexp/license.jwt */
290
- export function activateLicense(jwt) {
291
- const claims = verifyAndDecode(jwt);
292
- if (!claims)
293
- throw new Error("Invalid license key (signature verification failed)");
294
- if (claims.exp * 1000 < Date.now())
295
- throw new Error("License key has expired");
296
- const licensePath = getLicensePath();
297
- fs.mkdirSync(path.dirname(licensePath), { recursive: true });
298
- fs.writeFileSync(licensePath, jwt, "utf-8");
595
+ /** Install `jwt` as this machine's license (no network). */
596
+ function installLicense(jwt) {
597
+ atomicWriteFile(getLicensePath(), jwt);
299
598
  // The freshly activated long JWT is authoritative. Drop any cached
300
599
  // freshToken: it may be a 7-day token minted at the PREVIOUS tier, and both
301
600
  // readLicenseLimits() and the Rust daemon prefer fresh.jwt over license.jwt —
@@ -310,8 +609,166 @@ export function activateLicense(jwt) {
310
609
  // to register it" to someone who just did exactly that. Reported by a tier-4
311
610
  // user whose dashboard read 0 of 10 right after activating.
312
611
  clearLastCheck();
612
+ }
613
+ /** Activate a license key: verify and save to ~/.vexp/license.jwt (no
614
+ * network; {@link activateAndRegister} is what the commands call). */
615
+ export function activateLicense(jwt) {
616
+ const claims = verifyAndDecode(jwt);
617
+ if (!claims)
618
+ throw new Error("Invalid license key (signature verification failed)");
619
+ if (claims.exp * 1000 < Date.now())
620
+ throw new Error("License key has expired");
621
+ installLicense(jwt);
313
622
  return claims;
314
623
  }
624
+ /**
625
+ * Activate a key AND register this device, awaiting the check-in: the one
626
+ * path both `vexp activate` and the interactive menu take, so neither can
627
+ * forget the registration again (the menu did, for months).
628
+ *
629
+ * A key whose signature verifies but whose 30-day token has expired (the
630
+ * AppSumo key from the purchase email, used after a month) is accepted
631
+ * provisionally: the server is asked first, and only its renewal installs
632
+ * it. Revoked, deactivated or unreachable: nothing on disk changes and the
633
+ * error says where to get the current key.
634
+ */
635
+ export async function activateAndRegister(jwt) {
636
+ const key = jwt.trim();
637
+ const claims = verifyAndDecode(key);
638
+ if (!claims)
639
+ throw new Error("Invalid license key (signature verification failed)");
640
+ if (claims.exp * 1000 >= Date.now()) {
641
+ installLicense(key);
642
+ const outcome = await tryOnlineRefresh(key, { force: true });
643
+ return { claims, outcome, renewed: false };
644
+ }
645
+ // Expired: ask before touching anything.
646
+ const { outcome, data } = await callValidate(key);
647
+ switch (outcome.kind) {
648
+ case "registered":
649
+ case "unconfirmed":
650
+ case "device_limit_exceeded":
651
+ // The key is good (a full device list does not make it bad): install
652
+ // it, then let the answer roll it forward and register the device.
653
+ installLicense(key);
654
+ return { claims, outcome: applyValidateResult(outcome, data, key), renewed: true };
655
+ case "revoked":
656
+ throw new Error(`This license key has expired and vexp.dev reports it as revoked. Get your current key from ${ACCOUNT_URL}`);
657
+ case "deactivated":
658
+ throw new Error(`This license key has expired and vexp.dev reports it as deactivated. Get your current key from ${ACCOUNT_URL}`);
659
+ case "offline":
660
+ throw new Error(`This license key has expired and VEXP_OFFLINE=1 stops vexp from renewing it. Get your current key from ${ACCOUNT_URL}`);
661
+ case "unreachable":
662
+ case "http":
663
+ throw new Error(`This license key has expired and vexp.dev could not be reached to renew it. Get your current key from ${ACCOUNT_URL}`);
664
+ default:
665
+ throw new Error(`This license key has expired and vexp.dev did not renew it (${outcome.kind === "rejected" ? outcome.reason : outcome.kind}). Get your current key from ${ACCOUNT_URL}`);
666
+ }
667
+ }
668
+ /**
669
+ * The one line a person reads after activating or checking the license: is
670
+ * this machine registered, and if not, what happens next. `ok` is false when
671
+ * something needs their attention; `hint`, when present, is a second line
672
+ * with the cause (an unreachable vexp.dev says why).
673
+ */
674
+ export function registrationLine(outcome) {
675
+ switch (outcome.kind) {
676
+ case "registered": {
677
+ const counts = outcome.currentDevices !== undefined && outcome.maxDevices !== undefined
678
+ ? ` (${outcome.currentDevices} of ${outcome.maxDevices} devices)`
679
+ : "";
680
+ return { ok: true, text: `This device is registered with your license.${counts}` };
681
+ }
682
+ case "unconfirmed":
683
+ return {
684
+ ok: false,
685
+ text: "vexp.dev renewed your license but could not confirm this device on your account yet; run `vexp license` later to check again.",
686
+ };
687
+ case "throttled":
688
+ return { ok: true, text: "This device checked in with vexp.dev less than a day ago." };
689
+ case "unreachable": {
690
+ const hint = unreachableHint(outcome.detail);
691
+ return {
692
+ ok: false,
693
+ text: "Could not reach vexp.dev: vexp works offline and will register this device on its next check-in.",
694
+ ...(hint ? { hint } : {}),
695
+ };
696
+ }
697
+ case "http":
698
+ return {
699
+ ok: false,
700
+ text: `vexp.dev answered HTTP ${outcome.status}: vexp works offline and will register this device on its next check-in.`,
701
+ };
702
+ case "offline":
703
+ return {
704
+ ok: false,
705
+ text: "VEXP_OFFLINE=1 is set: this device is not registered with your license until vexp can check in with vexp.dev.",
706
+ };
707
+ case "revoked":
708
+ return {
709
+ ok: false,
710
+ text: `vexp.dev reports this license as revoked, so this device was not registered. Get your current key from ${ACCOUNT_URL}`,
711
+ };
712
+ case "deactivated":
713
+ return {
714
+ ok: false,
715
+ text: `vexp.dev reports this license as deactivated, so this device was not registered. Get your current key from ${ACCOUNT_URL}`,
716
+ };
717
+ case "device_limit_exceeded": {
718
+ const used = outcome.currentDevices ?? outcome.maxDevices;
719
+ return {
720
+ ok: false,
721
+ text: `This device is not registered: your license already has ${used} of ${outcome.maxDevices} devices. Free a slot at ${outcome.manageUrl}, then run \`vexp license\`.`,
722
+ };
723
+ }
724
+ case "no_license":
725
+ return { ok: false, text: "No license is active on this machine." };
726
+ case "rejected":
727
+ if (outcome.reason === "license_changed") {
728
+ return {
729
+ ok: false,
730
+ text: "The license key changed while vexp was checking in; the next check-in registers this device with the new key.",
731
+ };
732
+ }
733
+ return {
734
+ ok: false,
735
+ text: `vexp.dev did not register this device (${outcome.reason}). Get your current key from ${ACCOUNT_URL}`,
736
+ };
737
+ }
738
+ }
739
+ export function registrationState() {
740
+ const blocked = readDeviceBlocked();
741
+ if (blocked)
742
+ return { state: "blocked", blocked };
743
+ const at = lastCheckIn();
744
+ const fresh = loadFreshToken();
745
+ const freshValid = fresh !== null && fresh.exp * 1000 > Date.now();
746
+ if (at)
747
+ return freshValid ? { state: "registered", at } : { state: "not_registered", at };
748
+ return freshValid ? { state: "unconfirmed" } : { state: "never" };
749
+ }
750
+ /** A date and time the way the license screens print it. */
751
+ export function formatCheckIn(at) {
752
+ return `${at.toLocaleDateString()} ${at.toLocaleTimeString()}`;
753
+ }
754
+ /** The status sentence for {@link registrationState}; `ok` only when registered. */
755
+ export function registrationStateLine(r) {
756
+ switch (r.state) {
757
+ case "registered":
758
+ return { ok: true, text: `This device is registered with your license (last check-in ${formatCheckIn(r.at)}).` };
759
+ case "not_registered":
760
+ return { ok: false, text: `The last check-in (${formatCheckIn(r.at)}) did not register this device.` };
761
+ case "unconfirmed":
762
+ return { ok: false, text: "vexp.dev has not confirmed this device on your account yet." };
763
+ case "never":
764
+ return { ok: false, text: "This device has never checked in with vexp.dev, so it is not on your account's device list yet." };
765
+ case "blocked":
766
+ return {
767
+ ok: false,
768
+ text: `This device is not registered: the license's ${r.blocked.maxDevices} device slots are full; free one at ${r.blocked.manageUrl}.`,
769
+ };
770
+ }
771
+ }
315
772
  /** Remove the current license file (and any cached freshToken) */
316
773
  export function deactivateLicense() {
317
774
  const licensePath = getLicensePath();
@@ -366,22 +823,16 @@ function claimsToLimits(claims) {
366
823
  * (it was signed by the server during the last successful refresh).
367
824
  * 2. Otherwise, fall back to the long JWT in ~/.vexp/license.jwt exactly as
368
825
  * before (this is the zero-change path for users who never hit the server).
369
- * 3. Fire a background refresh attempt. Any failure is silent — the caller
370
- * never sees a network error.
826
+ * 3. Fire a background refresh attempt (unless `refresh: false`, for callers
827
+ * that already awaited one). Any failure is silent — the caller never sees
828
+ * a network error.
371
829
  *
372
830
  * The long JWT is NEVER deleted based on server response. The worst a
373
831
  * successful server revoke does is remove the cached freshToken, after
374
832
  * which the long JWT expires naturally at its own `exp`.
375
833
  */
376
- export function readLicenseLimits() {
377
- const licensePath = getLicensePath();
378
- let longJwt;
379
- try {
380
- longJwt = fs.readFileSync(licensePath, "utf-8").trim();
381
- }
382
- catch {
383
- return FREE_LIMITS;
384
- }
834
+ export function readLicenseLimits(opts = {}) {
835
+ const longJwt = readTrimmed(getLicensePath());
385
836
  if (!longJwt)
386
837
  return FREE_LIMITS;
387
838
  const longClaims = verifyAndDecode(longJwt);
@@ -399,7 +850,8 @@ export function readLicenseLimits() {
399
850
  // CLI startup on the network. We send the long JWT *even if it's already
400
851
  // past `exp`*, because the server (Fix 3) accepts expired JWTs and
401
852
  // re-issues freshTokens as long as the Stripe subscription (`pe`) holds.
402
- void tryOnlineRefresh(longJwt);
853
+ if (opts.refresh !== false)
854
+ void tryOnlineRefresh(longJwt);
403
855
  // Prefer a still-valid freshToken within the grace window
404
856
  const fresh = loadFreshToken();
405
857
  if (fresh && fresh.exp * 1000 > Date.now()) {