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/README.md +50 -8
- package/dist/agent-config.js +104 -77
- package/dist/binary.js +18 -1
- package/dist/cli.js +171 -11
- package/dist/hook-template.js +0 -1
- package/dist/license.js +263 -16
- package/dist/update-check.js +0 -1
- package/dist/version.js +0 -1
- package/mcp/mcp-server.cjs +7 -43274
- package/package.json +10 -9
- package/dist/agent-config.d.ts +0 -37
- package/dist/agent-config.js.map +0 -1
- package/dist/binary.d.ts +0 -8
- package/dist/binary.js.map +0 -1
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js.map +0 -1
- package/dist/hook-template.d.ts +0 -6
- package/dist/hook-template.js.map +0 -1
- package/dist/license.d.ts +0 -24
- package/dist/license.js.map +0 -1
- package/dist/update-check.d.ts +0 -1
- package/dist/update-check.js.map +0 -1
- package/dist/version.d.ts +0 -1
- package/dist/version.js.map +0 -1
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"
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/hook-template.js
CHANGED
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
|
-
|
|
63
|
-
export function readLicenseLimits() {
|
|
64
|
-
const licensePath = getLicensePath();
|
|
65
|
-
let jwt;
|
|
264
|
+
removeFreshToken();
|
|
265
|
+
clearDeviceBlocked();
|
|
66
266
|
try {
|
|
67
|
-
|
|
267
|
+
if (fs.existsSync(getLastCheckPath()))
|
|
268
|
+
fs.unlinkSync(getLastCheckPath());
|
|
68
269
|
}
|
|
69
270
|
catch {
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/update-check.js
CHANGED
package/dist/version.js
CHANGED