@vibecheckai/cli 3.0.7 → 3.0.9

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.
@@ -1,18 +1,25 @@
1
1
  /**
2
- * Entitlements v2 - Signed Receipts (CANONICAL)
2
+ * Entitlements v2 - CANONICAL Tier Enforcement
3
3
  *
4
- * This is the canonical entitlements module. Use this for all new code.
4
+ * SINGLE SOURCE OF TRUTH for all tier gating in vibecheck CLI.
5
+ * Every command runner MUST use this module for access control.
5
6
  *
6
- * Proper server-side metering with signed receipts:
7
- * 1. Every ship check requests a signed usage receipt from API
8
- * 2. Receipt includes: {userId, tier, remainingShipChecks, expiresAt, signature}
9
- * 3. CLI caches receipt in .vibecheck/.entitlements.json
10
- * 4. Offline grace: 24h using last valid receipt
7
+ * NO BYPASS ALLOWED:
8
+ * - No owner-mode env vars
9
+ * - No offline fallback that grants paid features
10
+ * - No silent feature access
11
11
  *
12
- * Spec tiers:
13
- * - Free: $0, 10 ship checks/month
14
- * - Pro: $39/mo, unlimited ship + reality + fix + share
15
- * - Team: $99/mo, + PR gating + CI automation
12
+ * Exit Codes:
13
+ * - 0: Success
14
+ * - 2: BLOCK verdict (CI failure)
15
+ * - 3: Feature not allowed (upgrade required)
16
+ * - 4: Misconfiguration/env error
17
+ *
18
+ * Tiers:
19
+ * - FREE ($0): Basic scanning and validation
20
+ * - STARTER ($29/repo/mo): CI gates, launch, PR, badge, MCP
21
+ * - PRO ($99/repo/mo): Full fix, prove, ai-test, share, advanced reality
22
+ * - ENTERPRISE: Custom (placeholder only)
16
23
  */
17
24
 
18
25
  "use strict";
@@ -22,362 +29,465 @@ const path = require("path");
22
29
  const os = require("os");
23
30
  const crypto = require("crypto");
24
31
 
25
- // Constants
26
- const FREE_SHIP_CHECKS_PER_MONTH = 10;
27
- const RECEIPT_GRACE_HOURS = 24;
28
- const API_BASE_URL = process.env.VIBECHECK_API_URL || "https://api.vibecheckai.dev";
32
+ // ═══════════════════════════════════════════════════════════════════════════════
33
+ // EXIT CODES
34
+ // ═══════════════════════════════════════════════════════════════════════════════
35
+ const EXIT_SUCCESS = 0;
36
+ const EXIT_BLOCK_VERDICT = 2;
37
+ const EXIT_FEATURE_NOT_ALLOWED = 3;
38
+ const EXIT_MISCONFIG = 4;
29
39
 
30
- // Public key for receipt verification (would be fetched from API in production)
31
- const VIBECHECK_PUBLIC_KEY = process.env.VIBECHECK_PUBLIC_KEY || `-----BEGIN PUBLIC KEY-----
32
- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0Z3VS5JJcds3xfn/ygWb
33
- rAnr6J6WvLW3YNHQZ8vJqUvBcl3L9S2gKj3wBi3M8N9pYhEj5Y5h5gJq5v5l5n5o
34
- placeholder-key-for-development
35
- AQIDAQAB
36
- -----END PUBLIC KEY-----`;
40
+ // ═══════════════════════════════════════════════════════════════════════════════
41
+ // TIER DEFINITIONS - SOURCE OF TRUTH
42
+ // ═══════════════════════════════════════════════════════════════════════════════
43
+ const TIERS = {
44
+ free: { name: "FREE", price: 0, order: 0 },
45
+ starter: { name: "STARTER", price: 29, order: 1 },
46
+ pro: { name: "PRO", price: 99, order: 2 },
47
+ enterprise: { name: "ENTERPRISE", price: 499, order: 3 },
48
+ };
37
49
 
38
- /**
39
- * Get entitlements cache path (project-local)
40
- */
50
+ // ═══════════════════════════════════════════════════════════════════════════════
51
+ // ENTITLEMENTS MATRIX - SOURCE OF TRUTH
52
+ // Format: feature -> { minTier, caps?, downgrade? }
53
+ // ═══════════════════════════════════════════════════════════════════════════════
54
+ const ENTITLEMENTS = {
55
+ // Core commands
56
+ "scan": { minTier: "free" },
57
+ "ship": { minTier: "free", caps: { free: "static-only" } },
58
+ "ship.static": { minTier: "free" },
59
+ "ship.full": { minTier: "starter" },
60
+
61
+ // Reality testing
62
+ "reality": { minTier: "free", downgrade: "reality.preview" },
63
+ "reality.preview": { minTier: "free", caps: { free: { maxPages: 5, maxClicks: 20, noAuthBoundary: true } } },
64
+ "reality.full": { minTier: "starter" },
65
+ "reality.advanced_auth_boundary": { minTier: "pro" },
66
+
67
+ // Prove command
68
+ "prove": { minTier: "pro" },
69
+
70
+ // Fix command
71
+ "fix": { minTier: "free", downgrade: "fix.plan_only" },
72
+ "fix.plan_only": { minTier: "free" },
73
+ "fix.apply_patches": { minTier: "pro" },
74
+
75
+ // Report formats
76
+ "report": { minTier: "free", downgrade: "report.html_md" },
77
+ "report.html_md": { minTier: "free" },
78
+ "report.sarif_csv": { minTier: "starter" },
79
+ "report.compliance_packs": { minTier: "pro" },
80
+
81
+ // Setup & DX
82
+ "install": { minTier: "free" },
83
+ "init": { minTier: "free" },
84
+ "doctor": { minTier: "free" },
85
+ "status": { minTier: "free" },
86
+ "watch": { minTier: "free" },
87
+
88
+ // AI Truth
89
+ "ctx": { minTier: "free" },
90
+ "guard": { minTier: "free" },
91
+ "context": { minTier: "free" },
92
+ "mdc": { minTier: "free" },
93
+
94
+ // CI & Collaboration (STARTER+)
95
+ "gate": { minTier: "starter" },
96
+ "launch": { minTier: "starter" },
97
+ "pr": { minTier: "starter" },
98
+ "badge": { minTier: "starter" },
99
+ "mcp": { minTier: "starter" },
100
+
101
+ // PRO only
102
+ "share": { minTier: "pro" },
103
+ "ai-test": { minTier: "pro" },
104
+
105
+ // Account (always free)
106
+ "login": { minTier: "free" },
107
+ "logout": { minTier: "free" },
108
+ "whoami": { minTier: "free" },
109
+
110
+ // Labs/experimental
111
+ "labs": { minTier: "free" },
112
+ };
113
+
114
+ // ═══════════════════════════════════════════════════════════════════════════════
115
+ // LIMITS BY TIER
116
+ // ═══════════════════════════════════════════════════════════════════════════════
117
+ const LIMITS = {
118
+ free: {
119
+ realityMaxPages: 5,
120
+ realityMaxClicks: 20,
121
+ realityAuthBoundary: false,
122
+ reportFormats: ["html", "md"],
123
+ fixApplyPatches: false,
124
+ scansPerMonth: 50,
125
+ shipChecksPerMonth: 20,
126
+ },
127
+ starter: {
128
+ realityMaxPages: 50,
129
+ realityMaxClicks: 500,
130
+ realityAuthBoundary: true,
131
+ reportFormats: ["html", "md", "sarif", "csv"],
132
+ fixApplyPatches: false,
133
+ scansPerMonth: -1, // unlimited
134
+ shipChecksPerMonth: -1,
135
+ },
136
+ pro: {
137
+ realityMaxPages: -1, // unlimited
138
+ realityMaxClicks: -1,
139
+ realityAuthBoundary: true,
140
+ realityAdvancedAuth: true,
141
+ reportFormats: ["html", "md", "sarif", "csv", "compliance"],
142
+ fixApplyPatches: true,
143
+ scansPerMonth: -1,
144
+ shipChecksPerMonth: -1,
145
+ },
146
+ enterprise: {
147
+ realityMaxPages: -1,
148
+ realityMaxClicks: -1,
149
+ realityAuthBoundary: true,
150
+ realityAdvancedAuth: true,
151
+ reportFormats: ["html", "md", "sarif", "csv", "compliance", "custom"],
152
+ fixApplyPatches: true,
153
+ scansPerMonth: -1,
154
+ shipChecksPerMonth: -1,
155
+ },
156
+ };
157
+
158
+ const API_BASE_URL = process.env.VIBECHECK_API_URL || "https://api.vibecheckai.dev";
159
+
160
+ // ═══════════════════════════════════════════════════════════════════════════════
161
+ // CACHE PATHS
162
+ // ═══════════════════════════════════════════════════════════════════════════════
41
163
  function getEntitlementsCachePath(projectPath) {
42
164
  return path.join(projectPath || process.cwd(), ".vibecheck", ".entitlements.json");
43
165
  }
44
166
 
45
- /**
46
- * Get global config path (user home)
47
- */
48
167
  function getGlobalConfigPath() {
49
168
  const home = os.homedir();
50
169
  if (process.platform === "win32") {
51
- return path.join(
52
- process.env.APPDATA || path.join(home, "AppData", "Roaming"),
53
- "vibecheck",
54
- "config.json"
55
- );
170
+ return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "vibecheck", "config.json");
56
171
  }
57
172
  return path.join(home, ".config", "vibecheck", "config.json");
58
173
  }
59
174
 
60
- /**
61
- * Load cached entitlements receipt
62
- */
63
- function loadCachedReceipt(projectPath) {
64
- const cachePath = getEntitlementsCachePath(projectPath);
65
- try {
66
- if (fs.existsSync(cachePath)) {
67
- return JSON.parse(fs.readFileSync(cachePath, "utf8"));
68
- }
69
- } catch {}
70
- return null;
175
+ // ═══════════════════════════════════════════════════════════════════════════════
176
+ // TIER COMPARISON
177
+ // ═══════════════════════════════════════════════════════════════════════════════
178
+ function tierMeetsMinimum(currentTier, requiredTier) {
179
+ const current = TIERS[currentTier]?.order ?? -1;
180
+ const required = TIERS[requiredTier]?.order ?? 999;
181
+ return current >= required;
71
182
  }
72
183
 
73
- /**
74
- * Save entitlements receipt to cache
75
- */
76
- function saveCachedReceipt(projectPath, receipt) {
77
- const cachePath = getEntitlementsCachePath(projectPath);
78
- const dir = path.dirname(cachePath);
79
- if (!fs.existsSync(dir)) {
80
- fs.mkdirSync(dir, { recursive: true });
81
- }
82
- fs.writeFileSync(cachePath, JSON.stringify(receipt, null, 2), { mode: 0o600 });
184
+ function getTierLabel(tier) {
185
+ return TIERS[tier]?.name || tier.toUpperCase();
83
186
  }
84
187
 
85
- /**
86
- * Verify receipt signature (HMAC-SHA256 for simplicity, RSA in production)
87
- */
88
- function verifyReceiptSignature(receipt) {
89
- if (!receipt || !receipt.signature || !receipt.data) {
90
- return false;
91
- }
92
-
93
- // In production: use RSA signature verification with public key
94
- // For now: use HMAC with a shared secret (server would use same secret)
95
- const secret = process.env.VIBECHECK_RECEIPT_SECRET || "vibecheck-receipt-v1";
96
- const dataStr = JSON.stringify(receipt.data);
97
- const expectedSig = crypto.createHmac("sha256", secret).update(dataStr).digest("hex");
98
-
99
- return receipt.signature === expectedSig;
100
- }
188
+ // ═══════════════════════════════════════════════════════════════════════════════
189
+ // CORE API: getTier()
190
+ // ═══════════════════════════════════════════════════════════════════════════════
191
+ let _cachedTier = null;
192
+ let _cachedTierExpiry = 0;
101
193
 
102
- /**
103
- * Check if receipt is within grace period
104
- */
105
- function isReceiptInGrace(receipt) {
106
- if (!receipt || !receipt.data || !receipt.data.issuedAt) {
107
- return false;
194
+ async function getTier(options = {}) {
195
+ const { apiKey, projectPath, forceRefresh = false } = options;
196
+
197
+ // Check cache (5 minute TTL)
198
+ if (!forceRefresh && _cachedTier && Date.now() < _cachedTierExpiry) {
199
+ return _cachedTier;
108
200
  }
109
201
 
110
- const issuedAt = new Date(receipt.data.issuedAt).getTime();
111
- const graceMs = RECEIPT_GRACE_HOURS * 3600 * 1000;
112
- return Date.now() - issuedAt < graceMs;
113
- }
114
-
115
- /**
116
- * Get current month key (YYYY-MM)
117
- */
118
- function getCurrentMonthKey() {
119
- const now = new Date();
120
- return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
121
- }
122
-
123
- /**
124
- * Request ship usage from server
125
- * Returns signed receipt with updated usage
126
- */
127
- async function requestShipUsage(apiKey, projectPath) {
202
+ // No API key = free tier
128
203
  if (!apiKey) {
129
- return { error: "No API key" };
204
+ _cachedTier = "free";
205
+ _cachedTierExpiry = Date.now() + 300000;
206
+ return "free";
130
207
  }
131
208
 
209
+ // Fetch from API
132
210
  try {
133
- const res = await fetch(`${API_BASE_URL}/v1/ship/use`, {
134
- method: "POST",
135
- headers: {
136
- "Authorization": `Bearer ${apiKey}`,
137
- "Content-Type": "application/json"
138
- },
139
- body: JSON.stringify({
140
- projectPath: projectPath ? path.basename(projectPath) : "unknown",
141
- timestamp: new Date().toISOString()
142
- }),
143
- signal: AbortSignal.timeout(10000)
211
+ const res = await fetch(`${API_BASE_URL}/v1/entitlements`, {
212
+ method: "GET",
213
+ headers: { "Authorization": `Bearer ${apiKey}` },
214
+ signal: AbortSignal.timeout(5000),
144
215
  });
145
216
 
146
- if (!res.ok) {
147
- if (res.status === 429) {
148
- return { error: "rate_limited", message: "Ship check limit reached" };
149
- }
150
- if (res.status === 401) {
151
- return { error: "unauthorized", message: "Invalid API key" };
152
- }
153
- return { error: "api_error", message: `API returned ${res.status}` };
154
- }
155
-
156
- const receipt = await res.json();
157
-
158
- // Validate receipt signature
159
- if (!verifyReceiptSignature(receipt)) {
160
- console.warn("Warning: Receipt signature invalid, proceeding with caution");
217
+ if (res.ok) {
218
+ const data = await res.json();
219
+ _cachedTier = data.tier || "free";
220
+ _cachedTierExpiry = Date.now() + 300000;
221
+
222
+ // Cache locally
223
+ try {
224
+ const cachePath = getEntitlementsCachePath(projectPath);
225
+ const dir = path.dirname(cachePath);
226
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
227
+ fs.writeFileSync(cachePath, JSON.stringify({ tier: _cachedTier, fetchedAt: new Date().toISOString() }, null, 2));
228
+ } catch {}
229
+
230
+ return _cachedTier;
161
231
  }
162
232
 
163
- // Cache receipt
164
- saveCachedReceipt(projectPath, receipt);
165
-
166
- return receipt;
167
- } catch (error) {
168
- // Network error - try to use cached receipt
169
- const cached = loadCachedReceipt(projectPath);
170
- if (cached && isReceiptInGrace(cached)) {
171
- console.log(" (offline mode - using cached entitlements)");
172
- return { ...cached, offline: true };
233
+ if (res.status === 401) {
234
+ return "free"; // Invalid key = free tier
173
235
  }
174
-
175
- return { error: "network", message: error.message };
236
+ } catch {
237
+ // Network error - check local cache
238
+ try {
239
+ const cachePath = getEntitlementsCachePath(projectPath);
240
+ if (fs.existsSync(cachePath)) {
241
+ const cached = JSON.parse(fs.readFileSync(cachePath, "utf8"));
242
+ // Only use cache if less than 24 hours old
243
+ const fetchedAt = new Date(cached.fetchedAt).getTime();
244
+ if (Date.now() - fetchedAt < 24 * 3600 * 1000) {
245
+ return cached.tier || "free";
246
+ }
247
+ }
248
+ } catch {}
176
249
  }
250
+
251
+ // Default to free (no offline bypass to paid features)
252
+ return "free";
253
+ }
254
+
255
+ // ═══════════════════════════════════════════════════════════════════════════════
256
+ // CORE API: getLimits()
257
+ // ═══════════════════════════════════════════════════════════════════════════════
258
+ function getLimits(tier) {
259
+ return LIMITS[tier] || LIMITS.free;
177
260
  }
178
261
 
262
+ // ═══════════════════════════════════════════════════════════════════════════════
263
+ // CORE API: enforce() - THE GATEKEEPER
264
+ // ═══════════════════════════════════════════════════════════════════════════════
179
265
  /**
180
- * Check if user can run ship (main entitlement check)
266
+ * Enforce feature access. Returns enforcement result.
267
+ *
268
+ * @param {string} feature - Feature key (e.g., "prove", "fix.apply_patches")
269
+ * @param {object} options - { apiKey?, projectPath?, silent? }
270
+ * @returns {object} - { allowed, tier, downgrade?, exitCode, message }
181
271
  */
182
- async function canShip(projectPath, options = {}) {
183
- const { apiKey, skipMeter = false } = options;
272
+ async function enforce(feature, options = {}) {
273
+ const { apiKey, projectPath, silent = false } = options;
184
274
 
185
- // Demo/owner mode bypass
186
- if (process.env.VIBECHECK_DEMO === "1" || process.env.VIBECHECK_OWNER === "1") {
275
+ const tier = await getTier({ apiKey, projectPath });
276
+ const entitlement = ENTITLEMENTS[feature];
277
+
278
+ if (!entitlement) {
279
+ // Unknown feature - block by default
187
280
  return {
188
- allowed: true,
189
- tier: "enterprise",
190
- reason: "Demo/owner mode",
191
- remaining: Infinity
281
+ allowed: false,
282
+ tier,
283
+ exitCode: EXIT_MISCONFIG,
284
+ message: `Unknown feature: ${feature}`,
192
285
  };
193
286
  }
194
287
 
195
- // If no API key, use free tier with local metering
196
- if (!apiKey) {
197
- return checkFreeTierLocal(projectPath, skipMeter);
288
+ const hasAccess = tierMeetsMinimum(tier, entitlement.minTier);
289
+
290
+ if (hasAccess) {
291
+ // Full access
292
+ return {
293
+ allowed: true,
294
+ tier,
295
+ limits: getLimits(tier),
296
+ caps: entitlement.caps?.[tier] || null,
297
+ };
198
298
  }
199
299
 
200
- // Request usage from server (meters the ship check)
201
- if (!skipMeter) {
202
- const receipt = await requestShipUsage(apiKey, projectPath);
203
-
204
- if (receipt.error) {
205
- // If network error but within grace, allow
206
- if (receipt.offline && receipt.data) {
207
- return {
208
- allowed: true,
209
- tier: receipt.data.tier || "free",
210
- reason: "Offline grace period",
211
- remaining: receipt.data.remainingShipChecks || 0,
212
- offline: true
213
- };
214
- }
300
+ // Check for downgrade option
301
+ if (entitlement.downgrade) {
302
+ const downgradeEntitlement = ENTITLEMENTS[entitlement.downgrade];
303
+ if (downgradeEntitlement && tierMeetsMinimum(tier, downgradeEntitlement.minTier)) {
304
+ // Downgrade allowed
305
+ const caps = downgradeEntitlement.caps?.[tier] || null;
306
+ const message = formatDowngradeMessage(feature, entitlement.downgrade, tier, entitlement.minTier, caps);
215
307
 
216
- // Rate limited
217
- if (receipt.error === "rate_limited") {
218
- return {
219
- allowed: false,
220
- tier: "free",
221
- reason: "Ship check limit reached for this month",
222
- remaining: 0
223
- };
308
+ if (!silent) {
309
+ console.log(message);
224
310
  }
225
311
 
226
- // Other errors - fall back to free tier
227
- return checkFreeTierLocal(projectPath, skipMeter);
312
+ return {
313
+ allowed: true,
314
+ tier,
315
+ downgrade: entitlement.downgrade,
316
+ limits: getLimits(tier),
317
+ caps,
318
+ message,
319
+ };
228
320
  }
229
-
230
- // Valid receipt from server
231
- const data = receipt.data || receipt;
232
- return {
233
- allowed: true,
234
- tier: data.tier || "pro",
235
- reason: "Authenticated",
236
- remaining: data.remainingShipChecks,
237
- receipt
238
- };
239
321
  }
240
322
 
241
- // Skip meter - just check tier
242
- const cached = loadCachedReceipt(projectPath);
243
- if (cached && cached.data) {
244
- return {
245
- allowed: true,
246
- tier: cached.data.tier || "pro",
247
- reason: "Cached entitlements",
248
- remaining: cached.data.remainingShipChecks
249
- };
323
+ // Not allowed - generate upgrade message
324
+ const message = formatUpgradeMessage(feature, tier, entitlement.minTier);
325
+
326
+ if (!silent) {
327
+ console.error(message);
250
328
  }
251
329
 
252
- return { allowed: true, tier: "pro", reason: "API key provided" };
330
+ return {
331
+ allowed: false,
332
+ tier,
333
+ requiredTier: entitlement.minTier,
334
+ exitCode: EXIT_FEATURE_NOT_ALLOWED,
335
+ message,
336
+ };
253
337
  }
254
338
 
255
- /**
256
- * Check free tier with local metering (fallback)
257
- */
258
- function checkFreeTierLocal(projectPath, skipMeter = false) {
259
- const cachePath = getEntitlementsCachePath(projectPath);
260
- const currentMonth = getCurrentMonthKey();
339
+ // ═══════════════════════════════════════════════════════════════════════════════
340
+ // MESSAGING
341
+ // ═══════════════════════════════════════════════════════════════════════════════
342
+ const c = {
343
+ reset: "\x1b[0m",
344
+ bold: "\x1b[1m",
345
+ dim: "\x1b[2m",
346
+ red: "\x1b[31m",
347
+ green: "\x1b[32m",
348
+ yellow: "\x1b[33m",
349
+ cyan: "\x1b[36m",
350
+ magenta: "\x1b[35m",
351
+ };
352
+
353
+ function formatUpgradeMessage(feature, currentTier, requiredTier) {
354
+ const tierColors = { starter: c.cyan, pro: c.magenta, enterprise: c.yellow };
355
+ const reqColor = tierColors[requiredTier] || c.yellow;
261
356
 
262
- let usage = { month: currentMonth, shipChecks: 0 };
357
+ return `
358
+ ${c.red}${c.bold}⛔ Feature Not Available${c.reset}
359
+
360
+ ${c.yellow}${feature}${c.reset} requires ${reqColor}${getTierLabel(requiredTier)}${c.reset} plan.
361
+ Your current plan: ${c.dim}${getTierLabel(currentTier)}${c.reset}
362
+
363
+ ${c.cyan}Upgrade at:${c.reset} https://vibecheckai.dev/pricing
364
+
365
+ ${c.dim}Exit code: ${EXIT_FEATURE_NOT_ALLOWED}${c.reset}
366
+ `;
367
+ }
368
+
369
+ function formatDowngradeMessage(feature, downgradeTo, currentTier, requiredTier, caps) {
370
+ const tierColors = { starter: c.cyan, pro: c.magenta, enterprise: c.yellow };
371
+ const reqColor = tierColors[requiredTier] || c.yellow;
263
372
 
264
- try {
265
- const cached = loadCachedReceipt(projectPath);
266
- if (cached && cached.freeUsage && cached.freeUsage.month === currentMonth) {
267
- usage = cached.freeUsage;
373
+ let capsStr = "";
374
+ if (caps) {
375
+ if (typeof caps === "string") {
376
+ capsStr = ` ${c.dim}Mode: ${caps}${c.reset}\n`;
377
+ } else if (typeof caps === "object") {
378
+ const entries = Object.entries(caps).map(([k, v]) => `${k}: ${v}`).join(", ");
379
+ capsStr = ` ${c.dim}Limits: ${entries}${c.reset}\n`;
268
380
  }
269
- } catch {}
270
-
271
- const remaining = FREE_SHIP_CHECKS_PER_MONTH - usage.shipChecks;
272
-
273
- if (remaining <= 0) {
274
- return {
275
- allowed: false,
276
- tier: "free",
277
- reason: `Free tier limit reached (${FREE_SHIP_CHECKS_PER_MONTH}/month)`,
278
- remaining: 0,
279
- upgradeUrl: "https://vibecheckai.dev/pricing"
280
- };
281
- }
282
-
283
- // Increment usage if not skipping meter
284
- if (!skipMeter) {
285
- usage.shipChecks++;
286
- try {
287
- saveCachedReceipt(projectPath, {
288
- freeUsage: usage,
289
- data: { tier: "free", remainingShipChecks: remaining - 1 },
290
- issuedAt: new Date().toISOString()
291
- });
292
- } catch {}
293
381
  }
294
382
 
295
- return {
296
- allowed: true,
297
- tier: "free",
298
- reason: "Free tier",
299
- remaining: remaining - (skipMeter ? 0 : 1)
300
- };
383
+ return `
384
+ ${c.yellow}${c.bold}⚠ Running in Preview Mode${c.reset}
385
+
386
+ Full ${c.yellow}${feature}${c.reset} requires ${reqColor}${getTierLabel(requiredTier)}${c.reset} plan.
387
+ Running ${c.green}${downgradeTo}${c.reset} instead.
388
+ ${capsStr}
389
+ ${c.cyan}Upgrade for full access:${c.reset} https://vibecheckai.dev/pricing
390
+ `;
301
391
  }
302
392
 
393
+ // ═══════════════════════════════════════════════════════════════════════════════
394
+ // CONVENIENCE HELPERS
395
+ // ═══════════════════════════════════════════════════════════════════════════════
396
+
303
397
  /**
304
- * Check if user has access to a specific feature
398
+ * Check if a command is allowed (does not print messages)
305
399
  */
306
- async function hasFeatureAccess(feature, projectPath, apiKey) {
307
- const entitlement = await canShip(projectPath, { apiKey, skipMeter: true });
308
-
309
- const featureTiers = {
310
- // Free features
311
- ship: ["free", "pro", "team", "enterprise"],
312
- scan: ["free", "pro", "team", "enterprise"],
313
- ctx: ["free", "pro", "team", "enterprise"],
314
- install: ["free", "pro", "team", "enterprise"],
315
-
316
- // Pro features
317
- reality: ["pro", "team", "enterprise"],
318
- fix_autopilot: ["pro", "team", "enterprise"],
319
- share: ["pro", "team", "enterprise"],
320
-
321
- // Team features
322
- pr_gate: ["team", "enterprise"],
323
- ci_automation: ["team", "enterprise"]
324
- };
325
-
326
- const allowedTiers = featureTiers[feature] || ["enterprise"];
327
- const hasAccess = allowedTiers.includes(entitlement.tier);
328
-
329
- return {
330
- allowed: hasAccess,
331
- tier: entitlement.tier,
332
- requiredTier: allowedTiers[0],
333
- upgradeUrl: hasAccess ? null : "https://vibecheckai.dev/pricing"
334
- };
400
+ async function checkCommand(command, options = {}) {
401
+ return enforce(command, { ...options, silent: true });
335
402
  }
336
403
 
337
404
  /**
338
- * Get usage stats for display
405
+ * Enforce and exit if not allowed
339
406
  */
340
- function getUsageStats(projectPath) {
341
- const cached = loadCachedReceipt(projectPath);
342
- const currentMonth = getCurrentMonthKey();
343
-
344
- if (!cached) {
345
- return {
346
- tier: "free",
347
- shipChecksUsed: 0,
348
- shipChecksRemaining: FREE_SHIP_CHECKS_PER_MONTH,
349
- month: currentMonth
350
- };
407
+ async function enforceOrExit(feature, options = {}) {
408
+ const result = await enforce(feature, options);
409
+ if (!result.allowed) {
410
+ process.exit(result.exitCode);
351
411
  }
352
-
353
- if (cached.freeUsage && cached.freeUsage.month === currentMonth) {
354
- return {
355
- tier: "free",
356
- shipChecksUsed: cached.freeUsage.shipChecks,
357
- shipChecksRemaining: FREE_SHIP_CHECKS_PER_MONTH - cached.freeUsage.shipChecks,
358
- month: currentMonth
359
- };
412
+ return result;
413
+ }
414
+
415
+ /**
416
+ * Get the minimum tier required for a feature
417
+ */
418
+ function getMinTierForFeature(feature) {
419
+ return ENTITLEMENTS[feature]?.minTier || "enterprise";
420
+ }
421
+
422
+ /**
423
+ * Check if tier has access to feature (sync, for help display)
424
+ */
425
+ function tierHasFeature(tier, feature) {
426
+ const entitlement = ENTITLEMENTS[feature];
427
+ if (!entitlement) return false;
428
+ return tierMeetsMinimum(tier, entitlement.minTier);
429
+ }
430
+
431
+ /**
432
+ * Get all features for a tier
433
+ */
434
+ function getFeaturesForTier(tier) {
435
+ const features = [];
436
+ for (const [feature, def] of Object.entries(ENTITLEMENTS)) {
437
+ if (tierMeetsMinimum(tier, def.minTier)) {
438
+ features.push(feature);
439
+ }
360
440
  }
361
-
362
- if (cached.data) {
363
- return {
364
- tier: cached.data.tier || "unknown",
365
- shipChecksRemaining: cached.data.remainingShipChecks,
366
- month: currentMonth,
367
- issuedAt: cached.data.issuedAt
368
- };
441
+ return features;
442
+ }
443
+
444
+ // ═══════════════════════════════════════════════════════════════════════════════
445
+ // COMMAND GROUPING FOR HELP DISPLAY
446
+ // ═══════════════════════════════════════════════════════════════════════════════
447
+ const COMMAND_GROUPS = {
448
+ "Proof Loop": ["scan", "ship", "reality", "prove", "fix", "report"],
449
+ "Setup & DX": ["install", "init", "doctor", "status", "watch", "launch"],
450
+ "AI Truth": ["ctx", "guard", "context", "mdc"],
451
+ "CI & Collaboration": ["gate", "pr", "badge"],
452
+ "Reporting": ["report"],
453
+ "Automation": ["ai-test", "mcp", "share"],
454
+ };
455
+
456
+ function getCommandGroup(command) {
457
+ for (const [group, commands] of Object.entries(COMMAND_GROUPS)) {
458
+ if (commands.includes(command)) return group;
369
459
  }
370
-
371
- return { tier: "unknown", month: currentMonth };
460
+ return "Other";
372
461
  }
373
462
 
463
+ // ═══════════════════════════════════════════════════════════════════════════════
464
+ // EXPORTS
465
+ // ═══════════════════════════════════════════════════════════════════════════════
374
466
  module.exports = {
375
- canShip,
376
- hasFeatureAccess,
377
- getUsageStats,
378
- loadCachedReceipt,
379
- saveCachedReceipt,
380
- requestShipUsage,
381
- FREE_SHIP_CHECKS_PER_MONTH,
382
- RECEIPT_GRACE_HOURS
467
+ // Core API
468
+ getTier,
469
+ getLimits,
470
+ enforce,
471
+ enforceOrExit,
472
+ checkCommand,
473
+
474
+ // Tier helpers
475
+ tierMeetsMinimum,
476
+ getTierLabel,
477
+ getMinTierForFeature,
478
+ tierHasFeature,
479
+ getFeaturesForTier,
480
+
481
+ // Command grouping
482
+ COMMAND_GROUPS,
483
+ getCommandGroup,
484
+
485
+ // Constants
486
+ TIERS,
487
+ ENTITLEMENTS,
488
+ LIMITS,
489
+ EXIT_SUCCESS,
490
+ EXIT_BLOCK_VERDICT,
491
+ EXIT_FEATURE_NOT_ALLOWED,
492
+ EXIT_MISCONFIG,
383
493
  };