vexp-cli 1.3.11 → 2.0.4
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 +66 -70
- 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 -43269
- 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/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