vexp-cli 1.3.8 → 2.0.3

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/cli.js CHANGED
@@ -2,14 +2,14 @@
2
2
  import { Command } from "commander";
3
3
  import chalk from "chalk";
4
4
  import ora from "ora";
5
- import { spawn, spawnSync } from "child_process";
5
+ import { spawn, spawnSync, execFileSync } from "child_process";
6
6
  import * as path from "path";
7
7
  import * as fs from "fs";
8
8
  import { checkbox } from "@inquirer/prompts";
9
9
  import { getBinaryPath, getInstalledVersion, getMcpServerPath } from "./binary.js";
10
10
  import { detectAgents, getAgentList, configureSelectedAgents } from "./agent-config.js";
11
11
  import { CLI_VERSION } from "./version.js";
12
- import { activateLicense, deactivateLicense, readLicenseLimits, } from "./license.js";
12
+ import { activateLicense, deactivateLicense, readLicenseLimits, readDeviceBlocked, } from "./license.js";
13
13
  import { checkForUpdate } from "./update-check.js";
14
14
  const program = new Command();
15
15
  program
@@ -163,14 +163,57 @@ program
163
163
  .command("savings")
164
164
  .description("Show token savings statistics")
165
165
  .option("--days <n>", "Number of days to analyze", "30")
166
+ .option("--today", "Show savings for today only (since local midnight)")
166
167
  .option("--export", "Export report to .vexp/token-savings-report.md")
167
168
  .action(async (opts) => {
168
169
  const binaryPath = ensureBinary();
169
- const args = ["savings", "--days", opts.days];
170
+ const args = ["savings"];
171
+ if (opts.today) {
172
+ // Compute local midnight in unix seconds so the Rust core aggregates
173
+ // only events from today in the caller's timezone.
174
+ const midnight = new Date();
175
+ midnight.setHours(0, 0, 0, 0);
176
+ const sinceTs = Math.floor(midnight.getTime() / 1000);
177
+ args.push("--since-ts", String(sinceTs), "--label", "today");
178
+ }
179
+ else {
180
+ args.push("--days", opts.days);
181
+ }
170
182
  if (opts.export)
171
183
  args.push("--export");
172
184
  runBinary(binaryPath, args);
173
185
  });
186
+ program
187
+ .command("compress <file>")
188
+ .description("Compress a markdown/text file using caveman-style prose compression (backup to <name>.original.md)")
189
+ .option("--level <lvl>", "Compression level: off | lite | full | ultra", "full")
190
+ .action(async (file, opts) => {
191
+ const binaryPath = ensureBinary();
192
+ runBinary(binaryPath, ["compress", file, "--level", opts.level]);
193
+ });
194
+ program
195
+ .command("setup-llm")
196
+ .description("Configure optional local LLM compression (vexp-devmind). Runs interactive wizard by default.")
197
+ .option("--status", "Print current LLM config and readiness")
198
+ .option("--disable", "Disable LLM compression without removing model files")
199
+ .option("--install", "Non-interactive install (for CI / first-run)")
200
+ .option("--check-updates", "Check for model updates without downloading")
201
+ .option("--enable", "Enable a previously-installed LLM without re-downloading anything")
202
+ .action(async (opts) => {
203
+ const binaryPath = ensureBinary();
204
+ const args = ["setup-llm"];
205
+ if (opts.status)
206
+ args.push("--status");
207
+ if (opts.disable)
208
+ args.push("--disable");
209
+ if (opts.install)
210
+ args.push("--install");
211
+ if (opts.checkUpdates)
212
+ args.push("--check-updates");
213
+ if (opts.enable)
214
+ args.push("--enable");
215
+ runBinary(binaryPath, args);
216
+ });
174
217
  program
175
218
  .command("hooks <action>")
176
219
  .description("Manage git hooks (install, check, remove)")
@@ -273,6 +316,46 @@ program
273
316
  catch {
274
317
  spinnerDaemon.warn("Daemon already running or failed to start (non-fatal)");
275
318
  }
319
+ // Step 2.6: Start MCP HTTP server (needed for Codex and HTTP-based agents)
320
+ const mcpPath = getMcpServerPath();
321
+ if (mcpPath) {
322
+ const spinnerMcp = ora("Starting MCP HTTP server...").start();
323
+ try {
324
+ const { randomUUID } = await import("crypto");
325
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
326
+ let mcpToken = "";
327
+ const tokenPath = home ? path.join(home, ".vexp", "mcp.token") : "";
328
+ if (tokenPath) {
329
+ try {
330
+ mcpToken = fs.readFileSync(tokenPath, "utf-8").trim();
331
+ }
332
+ catch { }
333
+ }
334
+ if (!mcpToken) {
335
+ mcpToken = randomUUID();
336
+ if (tokenPath) {
337
+ fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
338
+ fs.writeFileSync(tokenPath, mcpToken, { mode: 0o600 });
339
+ }
340
+ }
341
+ const mcpChild = spawn("node", [mcpPath, "--http", "--port=7821"], {
342
+ detached: true,
343
+ stdio: "ignore",
344
+ env: {
345
+ ...process.env,
346
+ VEXP_WORKSPACE: workspaceRoot,
347
+ VEXP_HTTP: "1",
348
+ VEXP_PORT: "7821",
349
+ VEXP_MCP_TOKEN: mcpToken,
350
+ },
351
+ });
352
+ mcpChild.unref();
353
+ spinnerMcp.succeed("MCP HTTP server started (port 7821)");
354
+ }
355
+ catch {
356
+ spinnerMcp.warn("MCP HTTP server failed to start (non-fatal)");
357
+ }
358
+ }
276
359
  // In personal mode, make .vexp/ fully gitignored
277
360
  if (isPersonal) {
278
361
  const vexpGitignore = path.join(workspaceRoot, ".vexp", ".gitignore");
@@ -541,6 +624,12 @@ program
541
624
  console.log(` All tools: ${limits.allTools ? "yes" : "no (7/10)"}`);
542
625
  if (limits.renewsAt)
543
626
  console.log(` Renews: ${limits.renewsAt.toLocaleDateString()}`);
627
+ const blocked = readDeviceBlocked();
628
+ if (blocked) {
629
+ console.log("");
630
+ console.log(chalk.yellow(` ⚠ This machine is not registered (${blocked.maxDevices}/${blocked.maxDevices} slots used).`));
631
+ console.log(chalk.dim(` Free a slot at ${blocked.manageUrl} and run a vexp command\n to re-register this device.`));
632
+ }
544
633
  console.log("");
545
634
  });
546
635
  // ────────────────────────────────────────────────────
@@ -562,6 +651,7 @@ const MENU_CONFIG = [
562
651
  { key: "3", label: "setup", hint: "Full setup (index + agents + hooks)", description: "One-command bootstrap: indexes the codebase, detects AI agents,\n writes MCP configs, installs git hooks." },
563
652
  { key: "4", label: "setup-agents", hint: "Choose which AI agents to configure", description: "Select agents (Claude, Cursor, Windsurf, Copilot, …) to configure.\n Creates config directories and writes MCP settings for each." },
564
653
  { key: "5", label: "daemon-cmd", hint: "Manage daemon (start/stop/status/logs)", description: "Controls the background daemon:\n start → launches the daemon (indexes + watches for changes)\n stop → stops the running daemon\n status → shows node/edge/file counts\n logs → prints .vexp/vexp.log" },
654
+ { key: "6", label: "setup-llm", hint: "Configure local LLM compression (vexp-devmind)", description: "Install, update, or disable the optional local LLM (vexp-devmind).\n Runs entirely on your machine (~3.5 GB download).\n Enables smart prompt preprocessing and higher-quality memory compression." },
565
655
  ];
566
656
  // ── Sub-menu: Explore & Analyze ──
567
657
  const MENU_EXPLORE = [
@@ -592,10 +682,30 @@ function printBanner() {
592
682
  console.log(chalk.dim(" ╚═══╝ ╚══════╝╚═╝ ╚═╝╚═╝") + chalk.dim(` v${version}`));
593
683
  console.log("");
594
684
  console.log(chalk.dim(" Context Engine for AI Coding Agents"));
685
+ // One-liner LLM status
686
+ try {
687
+ const llmBin = ensureBinary();
688
+ const stdout = execFileSync(llmBin, ["setup-llm", "--status"], {
689
+ timeout: 5000,
690
+ encoding: "utf-8",
691
+ stdio: ["pipe", "pipe", "pipe"],
692
+ });
693
+ const operational = /Operational:\s*true/.test(stdout);
694
+ const model = stdout.match(/Model:\s*(.+)/)?.[1]?.trim() ?? "";
695
+ if (operational) {
696
+ console.log(chalk.green(` ● LLM: ${model} (active)`));
697
+ }
698
+ else {
699
+ console.log(chalk.dim(" ○ LLM: not configured — type 'setup-llm' or press 1 > 6"));
700
+ }
701
+ }
702
+ catch {
703
+ // Binary not available or no local_llm feature — skip silently
704
+ }
595
705
  }
596
706
  function printMainMenu() {
597
707
  console.log("");
598
- console.log(` ${chalk.cyan.bold("1")} ${chalk.green("Setup & Config".padEnd(20))} ${chalk.dim("Initialize, index, and configure agents")}`);
708
+ console.log(` ${chalk.cyan.bold("1")} ${chalk.green("Setup & Config".padEnd(20))} ${chalk.dim("Initialize, index, configure agents & LLM")}`);
599
709
  console.log(` ${chalk.cyan.bold("2")} ${chalk.green("Explore & Analyze".padEnd(20))} ${chalk.dim("Query context, skeletons, impact, flow")}`);
600
710
  console.log(` ${chalk.cyan.bold("3")} ${chalk.green("Token Savings".padEnd(20))} ${chalk.dim("View savings stats, export report")}`);
601
711
  console.log(` ${chalk.cyan.bold("4")} ${chalk.green("License".padEnd(20))} ${chalk.dim("View or activate your license")}`);
@@ -732,6 +842,41 @@ async function executeCommand(label, rl) {
732
842
  }
733
843
  break;
734
844
  }
845
+ case "setup-llm": {
846
+ console.log("");
847
+ console.log(chalk.cyan(" Local LLM compression (vexp-devmind)"));
848
+ console.log(chalk.dim(" All processing runs locally — no data leaves your machine.\n"));
849
+ console.log(` ${chalk.bold("1")} Install / Reinstall (~3.5 GB download)`);
850
+ console.log(` ${chalk.bold("2")} Start (enable without re-downloading)`);
851
+ console.log(` ${chalk.bold("3")} Check for updates (no download if up to date)`);
852
+ console.log(` ${chalk.bold("4")} Status (show current config + hardware)`);
853
+ console.log(` ${chalk.bold("5")} Disable (keep files, stop using LLM)`);
854
+ console.log(` ${chalk.dim("b")} ${chalk.dim("Cancel")}`);
855
+ console.log("");
856
+ const llmChoice = await ask(rl, chalk.cyan(" Choose: "));
857
+ const llmBinary = ensureBinary();
858
+ switch (llmChoice.trim()) {
859
+ case "1":
860
+ console.log(chalk.dim(" Starting LLM setup (this may take several minutes)…\n"));
861
+ runBinary(llmBinary, ["setup-llm", "--install"]);
862
+ break;
863
+ case "2":
864
+ runBinary(llmBinary, ["setup-llm", "--enable"]);
865
+ break;
866
+ case "3":
867
+ runBinary(llmBinary, ["setup-llm", "--check-updates"]);
868
+ break;
869
+ case "4":
870
+ runBinary(llmBinary, ["setup-llm", "--status"]);
871
+ break;
872
+ case "5":
873
+ runBinary(llmBinary, ["setup-llm", "--disable"]);
874
+ break;
875
+ default:
876
+ console.log(chalk.dim(" Cancelled."));
877
+ }
878
+ break;
879
+ }
735
880
  // ── Explore commands ──
736
881
  case "capsule": {
737
882
  const query = await ask(rl, chalk.cyan(" Describe your task: "));
@@ -772,17 +917,33 @@ async function executeCommand(label, rl) {
772
917
  }
773
918
  case "savings": // fallthrough from direct command
774
919
  case "summary": {
775
- const daysInput = await ask(rl, chalk.cyan(" Period in days (7/14/30/60/90): "));
776
- const days = parseInt(daysInput.trim(), 10) || 30;
920
+ const input = await ask(rl, chalk.cyan(" Period (today / 7 / 14 / 30 / 60 / 90): "));
777
921
  const binaryPath = ensureBinary();
778
- runBinary(binaryPath, ["savings", "--days", String(days)]);
922
+ const trimmed = input.trim().toLowerCase();
923
+ if (trimmed === "today") {
924
+ const midnight = new Date();
925
+ midnight.setHours(0, 0, 0, 0);
926
+ runBinary(binaryPath, ["savings", "--since-ts", String(Math.floor(midnight.getTime() / 1000)), "--label", "today"]);
927
+ }
928
+ else {
929
+ const days = parseInt(trimmed, 10) || 30;
930
+ runBinary(binaryPath, ["savings", "--days", String(days)]);
931
+ }
779
932
  break;
780
933
  }
781
934
  case "export": {
782
- const daysInput = await ask(rl, chalk.cyan(" Period in days (7/14/30/60/90): "));
783
- const days = parseInt(daysInput.trim(), 10) || 30;
935
+ const input = await ask(rl, chalk.cyan(" Period (today / 7 / 14 / 30 / 60 / 90): "));
784
936
  const binaryPath = ensureBinary();
785
- runBinary(binaryPath, ["savings", "--days", String(days), "--export"]);
937
+ const trimmed = input.trim().toLowerCase();
938
+ if (trimmed === "today") {
939
+ const midnight = new Date();
940
+ midnight.setHours(0, 0, 0, 0);
941
+ runBinary(binaryPath, ["savings", "--since-ts", String(Math.floor(midnight.getTime() / 1000)), "--label", "today", "--export"]);
942
+ }
943
+ else {
944
+ const days = parseInt(trimmed, 10) || 30;
945
+ runBinary(binaryPath, ["savings", "--days", String(days), "--export"]);
946
+ }
786
947
  break;
787
948
  }
788
949
  // ── License commands ──
@@ -838,4 +999,3 @@ if (process.argv.length <= 2) {
838
999
  else {
839
1000
  program.parse();
840
1001
  }
841
- //# sourceMappingURL=cli.js.map
@@ -18,4 +18,3 @@ else
18
18
  fi
19
19
  exit 0
20
20
  `;
21
- //# sourceMappingURL=hook-template.js.map
package/dist/license.js CHANGED
@@ -2,12 +2,215 @@ import * as crypto from "crypto";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import * as os from "os";
5
+ import { CLI_VERSION } from "./version.js";
5
6
  // Ed25519 PUBLIC key (DER, base64) — same key as vexp-vscode/src/license.ts.
6
7
  // Only the server (vexp.dev) holds the matching private key.
7
8
  const VEXP_LICENSE_PUBLIC_KEY = "MCowBQYDK2VwAyEApwiWYGCyhCaGHAHWn/RTBSGo/1MNmGyZUgSNBQ5YE4g=";
9
+ const VEXP_WEB_ORIGIN = process.env.VEXP_WEB_ORIGIN || "https://vexp.dev";
10
+ // 14-day grace window after the last successful online refresh before we
11
+ // stop trusting the cached freshToken and fall back to the long JWT only.
12
+ const GRACE_MS = 14 * 24 * 60 * 60 * 1000;
13
+ // Opportunistic refresh cadence: don't hit the network more than once per 24h.
14
+ const REFRESH_BACKOFF_MS = 24 * 60 * 60 * 1000;
15
+ // 3 second timeout on validate calls — any slower falls back silently.
16
+ const VALIDATE_TIMEOUT_MS = 3000;
8
17
  function getLicensePath() {
9
18
  return path.join(os.homedir(), ".vexp", "license.jwt");
10
19
  }
20
+ function getFreshTokenPath() {
21
+ return path.join(os.homedir(), ".vexp", "fresh.jwt");
22
+ }
23
+ function getLastCheckPath() {
24
+ return path.join(os.homedir(), ".vexp", "last_online_check");
25
+ }
26
+ function getDeviceIdPath() {
27
+ return path.join(os.homedir(), ".vexp", "device.id");
28
+ }
29
+ /**
30
+ * Stable anonymous device identifier. First call creates and persists it.
31
+ * Uses OS hostname + a random salt — no PII, no hardware fingerprinting.
32
+ */
33
+ function getDeviceId() {
34
+ const p = getDeviceIdPath();
35
+ try {
36
+ const existing = fs.readFileSync(p, "utf-8").trim();
37
+ if (existing)
38
+ return existing;
39
+ }
40
+ catch {
41
+ // first run
42
+ }
43
+ const raw = `${os.hostname()}::${crypto.randomBytes(16).toString("hex")}`;
44
+ const id = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 32);
45
+ try {
46
+ fs.mkdirSync(path.dirname(p), { recursive: true });
47
+ fs.writeFileSync(p, id, "utf-8");
48
+ }
49
+ catch {
50
+ // read-only FS: return the ephemeral id, refresh will still work
51
+ }
52
+ return id;
53
+ }
54
+ function readLastCheck() {
55
+ try {
56
+ const s = fs.readFileSync(getLastCheckPath(), "utf-8").trim();
57
+ const n = Number(s);
58
+ return Number.isFinite(n) ? n : 0;
59
+ }
60
+ catch {
61
+ return 0;
62
+ }
63
+ }
64
+ function writeLastCheck(ts) {
65
+ try {
66
+ fs.mkdirSync(path.dirname(getLastCheckPath()), { recursive: true });
67
+ fs.writeFileSync(getLastCheckPath(), String(ts), "utf-8");
68
+ }
69
+ catch {
70
+ // ignore
71
+ }
72
+ }
73
+ function loadFreshToken() {
74
+ try {
75
+ const jwt = fs.readFileSync(getFreshTokenPath(), "utf-8").trim();
76
+ if (!jwt)
77
+ return null;
78
+ return verifyAndDecode(jwt);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ function saveFreshToken(jwt) {
85
+ try {
86
+ fs.mkdirSync(path.dirname(getFreshTokenPath()), { recursive: true });
87
+ fs.writeFileSync(getFreshTokenPath(), jwt, "utf-8");
88
+ }
89
+ catch {
90
+ // ignore
91
+ }
92
+ }
93
+ function removeFreshToken() {
94
+ try {
95
+ fs.unlinkSync(getFreshTokenPath());
96
+ }
97
+ catch {
98
+ // ignore
99
+ }
100
+ }
101
+ function getDeviceBlockedPath() {
102
+ return path.join(os.homedir(), ".vexp", "device_blocked.json");
103
+ }
104
+ /** Returns the active device-blocked marker, if any. */
105
+ export function readDeviceBlocked() {
106
+ try {
107
+ const raw = fs.readFileSync(getDeviceBlockedPath(), "utf-8");
108
+ const parsed = JSON.parse(raw);
109
+ if (parsed &&
110
+ typeof parsed.at === "number" &&
111
+ typeof parsed.maxDevices === "number" &&
112
+ typeof parsed.manageUrl === "string") {
113
+ return parsed;
114
+ }
115
+ return null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ function writeDeviceBlocked(info) {
122
+ try {
123
+ fs.mkdirSync(path.dirname(getDeviceBlockedPath()), { recursive: true });
124
+ fs.writeFileSync(getDeviceBlockedPath(), JSON.stringify(info), "utf-8");
125
+ }
126
+ catch {
127
+ // ignore
128
+ }
129
+ }
130
+ function clearDeviceBlocked() {
131
+ try {
132
+ fs.unlinkSync(getDeviceBlockedPath());
133
+ }
134
+ catch {
135
+ // ignore
136
+ }
137
+ }
138
+ /**
139
+ * Opportunistic online license refresh.
140
+ *
141
+ * Additive-only: any failure (network, 404, 5xx, timeout, server disabled)
142
+ * results in a silent no-op. The caller keeps using the local long JWT
143
+ * exactly as before. This MUST never throw.
144
+ */
145
+ export async function tryOnlineRefresh(longJwt) {
146
+ // Rate limit: at most one network call per REFRESH_BACKOFF_MS
147
+ const last = readLastCheck();
148
+ if (Date.now() - last < REFRESH_BACKOFF_MS)
149
+ return;
150
+ // Global kill switch for users who want to stay fully offline
151
+ if (process.env.VEXP_OFFLINE === "1")
152
+ return;
153
+ const controller = new AbortController();
154
+ const timer = setTimeout(() => controller.abort(), VALIDATE_TIMEOUT_MS);
155
+ try {
156
+ const res = await fetch(`${VEXP_WEB_ORIGIN}/api/license/validate`, {
157
+ method: "POST",
158
+ headers: { "content-type": "application/json" },
159
+ body: JSON.stringify({
160
+ jwt: longJwt,
161
+ deviceId: getDeviceId(),
162
+ clientVersion: CLI_VERSION,
163
+ }),
164
+ signal: controller.signal,
165
+ });
166
+ clearTimeout(timer);
167
+ // 404 = endpoint not deployed yet (old server), 503 = kill switch
168
+ if (res.status === 404 || res.status === 503) {
169
+ return;
170
+ }
171
+ if (!res.ok)
172
+ return;
173
+ const data = (await res.json());
174
+ if (data && data.valid && data.freshToken) {
175
+ // Only save if the freshToken itself verifies locally
176
+ if (verifyAndDecode(data.freshToken)) {
177
+ saveFreshToken(data.freshToken);
178
+ writeLastCheck(Date.now());
179
+ // Any previous device-blocked state is stale once the server issues
180
+ // a freshToken for us — we are back in a good state.
181
+ clearDeviceBlocked();
182
+ }
183
+ }
184
+ else if (data && data.reason === "device_limit_exceeded") {
185
+ // This machine is over the subscription's device cap. We do NOT touch
186
+ // the long JWT on disk: the user still owns a valid subscription, they
187
+ // just need to free a slot on vexp.dev/account/devices. We persist a
188
+ // marker so the CLI can surface the error next time a command runs.
189
+ writeDeviceBlocked({
190
+ at: Date.now(),
191
+ maxDevices: data.maxDevices ?? 4,
192
+ manageUrl: data.manageUrl ?? "https://vexp.dev/account/devices",
193
+ });
194
+ // Drop any stale freshToken so readLicenseLimits downgrades to FREE
195
+ // until the user frees a slot and retries.
196
+ removeFreshToken();
197
+ writeLastCheck(Date.now());
198
+ }
199
+ else if (data && data.revoked) {
200
+ // Server confirmed revocation. Drop the cached freshToken but keep
201
+ // the long JWT on disk — the user will fall back to whatever the
202
+ // long JWT allows until it expires naturally.
203
+ removeFreshToken();
204
+ writeLastCheck(Date.now());
205
+ }
206
+ }
207
+ catch {
208
+ // network error, timeout, abort — silent no-op
209
+ }
210
+ finally {
211
+ clearTimeout(timer);
212
+ }
213
+ }
11
214
  function verifyAndDecode(jwt) {
12
215
  try {
13
216
  const parts = jwt.split(".");
@@ -48,7 +251,7 @@ export function activateLicense(jwt) {
48
251
  fs.writeFileSync(licensePath, jwt, "utf-8");
49
252
  return claims;
50
253
  }
51
- /** Remove the current license file */
254
+ /** Remove the current license file (and any cached freshToken) */
52
255
  export function deactivateLicense() {
53
256
  const licensePath = getLicensePath();
54
257
  try {
@@ -58,24 +261,17 @@ export function deactivateLicense() {
58
261
  catch {
59
262
  // ignore
60
263
  }
61
- }
62
- /** Read license from ~/.vexp/license.jwt, verify, and return tier limits */
63
- export function readLicenseLimits() {
64
- const licensePath = getLicensePath();
65
- let jwt;
264
+ removeFreshToken();
265
+ clearDeviceBlocked();
66
266
  try {
67
- jwt = fs.readFileSync(licensePath, "utf-8").trim();
267
+ if (fs.existsSync(getLastCheckPath()))
268
+ fs.unlinkSync(getLastCheckPath());
68
269
  }
69
270
  catch {
70
- return FREE_LIMITS;
71
- }
72
- if (!jwt)
73
- return FREE_LIMITS;
74
- const claims = verifyAndDecode(jwt);
75
- if (!claims || claims.exp * 1000 < Date.now()) {
76
- return FREE_LIMITS;
271
+ // ignore
77
272
  }
78
- // pe = Stripe period_end (renewal date), fallback to exp (JWT expiry)
273
+ }
274
+ function claimsToLimits(claims) {
79
275
  const renewsAt = new Date((claims.pe ?? claims.exp) * 1000);
80
276
  switch (claims.plan) {
81
277
  case "team":
@@ -86,4 +282,55 @@ export function readLicenseLimits() {
86
282
  return FREE_LIMITS;
87
283
  }
88
284
  }
89
- //# sourceMappingURL=license.js.map
285
+ /**
286
+ * Read license from ~/.vexp/license.jwt, verify, and return tier limits.
287
+ *
288
+ * Phase 3 online validation is additive:
289
+ * 1. If a freshToken exists in ~/.vexp/fresh.jwt and is still valid, trust it
290
+ * (it was signed by the server during the last successful refresh).
291
+ * 2. Otherwise, fall back to the long JWT in ~/.vexp/license.jwt exactly as
292
+ * before (this is the zero-change path for users who never hit the server).
293
+ * 3. Fire a background refresh attempt. Any failure is silent — the caller
294
+ * never sees a network error.
295
+ *
296
+ * The long JWT is NEVER deleted based on server response. The worst a
297
+ * successful server revoke does is remove the cached freshToken, after
298
+ * which the long JWT expires naturally at its own `exp`.
299
+ */
300
+ export function readLicenseLimits() {
301
+ const licensePath = getLicensePath();
302
+ let longJwt;
303
+ try {
304
+ longJwt = fs.readFileSync(licensePath, "utf-8").trim();
305
+ }
306
+ catch {
307
+ return FREE_LIMITS;
308
+ }
309
+ if (!longJwt)
310
+ return FREE_LIMITS;
311
+ const longClaims = verifyAndDecode(longJwt);
312
+ if (!longClaims || longClaims.exp * 1000 < Date.now()) {
313
+ return FREE_LIMITS;
314
+ }
315
+ // Device cap enforcement: if this machine is over-cap, the server told
316
+ // us to downgrade here until a slot is freed. The long JWT stays on disk
317
+ // so that once the slot is freed, the next refresh lifts the block.
318
+ if (readDeviceBlocked()) {
319
+ return FREE_LIMITS;
320
+ }
321
+ // Fire-and-forget opportunistic refresh. Never awaited — we do not block
322
+ // CLI startup on the network.
323
+ void tryOnlineRefresh(longJwt);
324
+ // Prefer a still-valid freshToken within the grace window
325
+ const fresh = loadFreshToken();
326
+ if (fresh && fresh.exp * 1000 > Date.now()) {
327
+ const lastCheck = readLastCheck();
328
+ if (lastCheck === 0 || Date.now() - lastCheck < GRACE_MS) {
329
+ return claimsToLimits(fresh);
330
+ }
331
+ // freshToken is valid but the last successful refresh is older than the
332
+ // grace window → server is unreachable for too long. Fall back to the
333
+ // long JWT (keeps working as today, no downgrade to free).
334
+ }
335
+ return claimsToLimits(longClaims);
336
+ }
@@ -87,4 +87,3 @@ export async function checkForUpdate() {
87
87
  process.exit(1);
88
88
  }
89
89
  }
90
- //# sourceMappingURL=update-check.js.map
package/dist/version.js CHANGED
@@ -2,4 +2,3 @@ import { createRequire } from "module";
2
2
  const require = createRequire(import.meta.url);
3
3
  const pkg = require("../package.json");
4
4
  export const CLI_VERSION = pkg.version || "0.0.0";
5
- //# sourceMappingURL=version.js.map