content-grade 1.0.27 → 1.0.29

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.
@@ -145,7 +145,7 @@ function showFreeTierCTA(count) {
145
145
  console.log(` ${MG}→ Pro: unlimited analyses + CI mode + bulk grading. $9/mo: ${CY}${UPGRADE_LINKS.free}${R}`);
146
146
  } else {
147
147
  // Runs remaining — light nudge with value hook
148
- console.log(` ${D}${count}/${limit} free analyses · ${remaining} left · Unlimited with Pro ($9/mo): ${CY}${UPGRADE_LINKS.free}${R}`);
148
+ console.log(` ${WH}${count}/${limit} free analyses · ${remaining} left · Unlimited with Pro ($9/mo): ${CY}${UPGRADE_LINKS.free}${R}`);
149
149
  }
150
150
  hr();
151
151
  }
@@ -194,7 +194,7 @@ function showUsageFooter(count) {
194
194
  } else if (remaining === 1) {
195
195
  console.log(` ${YL}[ ${count}/${limit} free runs used · 1 left. Unlimited runs: ${UPGRADE_LINKS.free} ]${R}`);
196
196
  } else {
197
- console.log(` ${D}[ ${count}/${limit} free runs today · ${remaining} left. Unlimited: ${UPGRADE_LINKS.free} ]${R}`);
197
+ console.log(` ${D}[ ${count}/${limit} free runs used · ${remaining} left. Unlimited: ${UPGRADE_LINKS.free} ]${R}`);
198
198
  }
199
199
  maybeShowEarlyAdopterCTA(count);
200
200
  }
@@ -210,7 +210,7 @@ function checkFreeTierLimit() {
210
210
 
211
211
  blank();
212
212
  hr();
213
- console.log(` ${YL}${B}${limit}/${limit} free analyses used today.${R}`);
213
+ console.log(` ${YL}${B}${limit}/${limit} free analyses used — limit reached.${R}`);
214
214
  blank();
215
215
  console.log(` ${D}Pro removes the cap — unlimited analyses, $9/mo:${R}`);
216
216
  console.log(` ${D}├── Unlimited analyses — no cap, ever${R}`);
@@ -1008,7 +1008,13 @@ async function cmdActivate() {
1008
1008
  } else {
1009
1009
  process.stdout.write('\n');
1010
1010
  blank();
1011
- fail(data.message || `Invalid or expired license key. Get a new key: ${UPGRADE_LINKS.free}`);
1011
+ const msg = data.message || `Invalid or expired license key.`;
1012
+ fail(msg);
1013
+ if (/not found/i.test(msg) || /invalid/i.test(msg)) {
1014
+ blank();
1015
+ console.log(` ${D}If you recently purchased, retrieve your key:${R}`);
1016
+ console.log(` ${CY} https://content-grade.onrender.com/api/stripe/license-key?email=YOUR_EMAIL${R}`);
1017
+ }
1012
1018
  blank();
1013
1019
  process.exit(1);
1014
1020
  }
@@ -2047,6 +2053,48 @@ const _telem = initTelemetry();
2047
2053
  // Defer first-run telemetry notice so users see value first
2048
2054
  const _showTelemNotice = _telem.isNew;
2049
2055
 
2056
+ // ── License key startup validation ───────────────────────────────────────────
2057
+ // Background check: re-validate stored license key against server every 7 days.
2058
+ // Non-blocking — never delays the CLI. Clears invalid/revoked keys silently.
2059
+ (async () => {
2060
+ const storedKey = getLicenseKey();
2061
+ if (!storedKey) return;
2062
+ const cfg = loadConfig();
2063
+ const lastCheck = cfg?.lastLicenseCheck;
2064
+ const sevenDays = 7 * 24 * 60 * 60 * 1000;
2065
+ if (lastCheck && Date.now() - new Date(lastCheck).getTime() < sevenDays) return;
2066
+ const serverUrl = process.env.CONTENT_GRADE_SERVER_URL || 'https://content-grade.onrender.com';
2067
+ try {
2068
+ const controller = new AbortController();
2069
+ const timer = setTimeout(() => controller.abort(), 3000);
2070
+ const response = await fetch(`${serverUrl}/api/license/validate`, {
2071
+ method: 'POST',
2072
+ headers: { 'Content-Type': 'application/json' },
2073
+ body: JSON.stringify({ key: storedKey }),
2074
+ signal: controller.signal,
2075
+ });
2076
+ clearTimeout(timer);
2077
+ const data = await response.json();
2078
+ const updated = loadConfig();
2079
+ updated.lastLicenseCheck = new Date().toISOString();
2080
+ if (data.valid) {
2081
+ const productKeyToTier = {
2082
+ contentgrade_starter: 'starter',
2083
+ contentgrade_pro: 'pro',
2084
+ contentgrade_team: 'team',
2085
+ };
2086
+ updated.tier = productKeyToTier[data.productKey] || 'pro';
2087
+ } else {
2088
+ delete updated.licenseKey;
2089
+ delete updated.tier;
2090
+ try { unlinkSync(LICENSE_FILE); } catch {}
2091
+ }
2092
+ saveConfig(updated);
2093
+ } catch {
2094
+ // Server unreachable — skip silently
2095
+ }
2096
+ })();
2097
+
2050
2098
  // Smart path detection: first arg is a path if it starts with ., /, ~ or is an existing FS entry
2051
2099
  function looksLikePath(s) {
2052
2100
  if (!s) return false;
package/bin/telemetry.js CHANGED
@@ -58,18 +58,32 @@ export function isOptedOut() {
58
58
  return cfg?.telemetryEnabled === false;
59
59
  }
60
60
 
61
+ /**
62
+ * Return run count bucket label from total lifetime runs.
63
+ * Buckets: 1-5, 6-20, 21-50, 50+
64
+ */
65
+ export function getRunCountBucket(totalRuns) {
66
+ if (totalRuns <= 5) return '1-5';
67
+ if (totalRuns <= 20) return '6-20';
68
+ if (totalRuns <= 50) return '21-50';
69
+ return '50+';
70
+ }
71
+
61
72
  /**
62
73
  * Initialise telemetry on CLI startup.
63
- * Returns { installId, isNew, optedOut }.
74
+ * Increments total run count and returns { installId, totalRuns, runCountBucket, isNew, optedOut }.
64
75
  * isNew=true means this is the first time the CLI has run — show the notice.
65
76
  */
66
77
  export function initTelemetry() {
67
- if (isOptedOut()) return { installId: null, isNew: false, optedOut: true };
78
+ if (isOptedOut()) return { installId: null, totalRuns: 0, runCountBucket: '1-5', isNew: false, optedOut: true };
68
79
 
69
80
  const cfg = loadConfig();
70
81
 
71
82
  if (cfg?.installId) {
72
- return { installId: cfg.installId, isNew: false, optedOut: false };
83
+ // Increment lifetime run counter
84
+ const totalRuns = (cfg.totalRuns ?? 0) + 1;
85
+ saveConfig({ ...cfg, totalRuns });
86
+ return { installId: cfg.installId, totalRuns, runCountBucket: getRunCountBucket(totalRuns), isNew: false, optedOut: false };
73
87
  }
74
88
 
75
89
  // First run — generate anonymous install ID, enabled by default (opt-out available)
@@ -79,9 +93,10 @@ export function initTelemetry() {
79
93
  installId,
80
94
  installedAt: new Date().toISOString(),
81
95
  telemetryEnabled: true,
96
+ totalRuns: 1,
82
97
  });
83
98
 
84
- return { installId, isNew: true, optedOut: false };
99
+ return { installId, totalRuns: 1, runCountBucket: '1-5', isNew: true, optedOut: false };
85
100
  }
86
101
 
87
102
  /**
@@ -105,10 +120,12 @@ export function recordEvent(data) {
105
120
  const installId = cfg?.installId;
106
121
  if (!installId) return; // shouldn't happen after initTelemetry, but guard
107
122
 
123
+ const totalRuns = cfg?.totalRuns ?? 1;
108
124
  const event = {
109
125
  ...data,
110
126
  package: 'content-grade',
111
127
  installId,
128
+ run_count_bucket: data.run_count_bucket ?? getRunCountBucket(totalRuns),
112
129
  timestamp: new Date().toISOString(),
113
130
  };
114
131
 
@@ -130,4 +130,8 @@ function migrate(db) {
130
130
  db.exec(`ALTER TABLE cli_telemetry ADD COLUMN is_pro INTEGER`);
131
131
  }
132
132
  catch { }
133
+ try {
134
+ db.exec(`ALTER TABLE cli_telemetry ADD COLUMN run_count_bucket TEXT`);
135
+ }
136
+ catch { }
133
137
  }
@@ -43,9 +43,9 @@ export function registerAnalyticsRoutes(app) {
43
43
  const db = getDb();
44
44
  db.prepare(`
45
45
  INSERT INTO cli_telemetry
46
- (install_id, event, command, is_pro, duration_ms, success, exit_code, score, content_type, version, platform, node_version)
47
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
48
- `).run(String(body.install_id).slice(0, 64), String(body.event ?? 'unknown').slice(0, 64), body.command ? String(body.command).slice(0, 64) : null, typeof body.is_pro === 'boolean' ? (body.is_pro ? 1 : 0) : null, typeof body.duration_ms === 'number' ? Math.round(body.duration_ms) : null, typeof body.success === 'boolean' ? (body.success ? 1 : 0) : null, typeof body.exit_code === 'number' ? body.exit_code : null, typeof body.score === 'number' ? body.score : null, body.content_type ? String(body.content_type).slice(0, 64) : null, body.version ? String(body.version).slice(0, 32) : null, body.platform ? String(body.platform).slice(0, 32) : null, body.nodeVersion ? String(body.nodeVersion).slice(0, 32) : null);
46
+ (install_id, event, command, is_pro, duration_ms, success, exit_code, score, content_type, version, platform, node_version, run_count_bucket)
47
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
48
+ `).run(String(body.install_id).slice(0, 64), String(body.event ?? 'unknown').slice(0, 64), body.command ? String(body.command).slice(0, 64) : null, typeof body.is_pro === 'boolean' ? (body.is_pro ? 1 : 0) : null, typeof body.duration_ms === 'number' ? Math.round(body.duration_ms) : null, typeof body.success === 'boolean' ? (body.success ? 1 : 0) : null, typeof body.exit_code === 'number' ? body.exit_code : null, typeof body.score === 'number' ? body.score : null, body.content_type ? String(body.content_type).slice(0, 64) : null, body.version ? String(body.version).slice(0, 32) : null, body.platform ? String(body.platform).slice(0, 32) : null, body.nodeVersion ? String(body.nodeVersion).slice(0, 32) : null, body.run_count_bucket ? String(body.run_count_bucket).slice(0, 10) : null);
49
49
  }
50
50
  catch {
51
51
  // never fail — telemetry is non-critical
@@ -1,5 +1,5 @@
1
1
  import { validateLicenseKey, getLicenseKeysForEmail } from '../services/license.js';
2
- import { hasActiveSubscription } from '../services/stripe.js';
2
+ import { hasActiveSubscriptionLive } from '../services/stripe.js';
3
3
  // Simple in-memory rate limiter: max 10 calls per IP per minute
4
4
  const _validateRateLimiter = new Map();
5
5
  function checkRateLimit(ip) {
@@ -39,11 +39,17 @@ export function registerLicenseRoutes(app) {
39
39
  }
40
40
  const result = validateLicenseKey(key);
41
41
  if (!result.valid) {
42
- return { valid: false, message: 'Invalid or expired license key.' };
42
+ // Key not in DB could be Render cold start DB wipe. Guide user to self-serve recovery.
43
+ const isValidFormat = /^CG-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/i.test(key);
44
+ if (isValidFormat) {
45
+ return { valid: false, message: 'Key not found. If you are a Pro subscriber, retrieve a fresh key at: https://content-grade.onrender.com/api/stripe/license-key?email=YOUR_EMAIL' };
46
+ }
47
+ return { valid: false, message: 'Invalid license key format.' };
43
48
  }
44
- // Also verify Stripe subscription is still active for subscription-based keys
49
+ // Also verify Stripe subscription is still active for subscription-based keys.
50
+ // Uses live Stripe check (with DB fallback) so DB wipes on Render cold start don't invalidate valid subscribers.
45
51
  if (result.productKey === 'contentgrade_pro' && result.email) {
46
- const subActive = hasActiveSubscription(result.email);
52
+ const subActive = await hasActiveSubscriptionLive(result.email);
47
53
  if (!subActive) {
48
54
  return { valid: false, message: 'Subscription is no longer active. Please renew at content-grade.onrender.com.' };
49
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-grade",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "AI-powered content analysis CLI. Score any blog post, landing page, or ad copy in under 30 seconds — runs on Claude CLI, no API key needed.",
5
5
  "type": "module",
6
6
  "bin": {