content-grade 1.0.28 → 1.0.30

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.
@@ -64,7 +64,14 @@ function getLicenseKey() {
64
64
 
65
65
  function getLicenseTier() {
66
66
  const cfg = loadConfig();
67
- const tier = cfg.tier || 'free';
67
+ let tier = cfg.tier || 'free';
68
+ // Fallback: if config has no tier but license file exists, read tier from there
69
+ if (tier === 'free') {
70
+ try {
71
+ const lic = JSON.parse(readFileSync(LICENSE_FILE, 'utf8'));
72
+ if (lic.tier) tier = lic.tier;
73
+ } catch {}
74
+ }
68
75
  // Collapse legacy 'starter' tier to 'pro' — Starter was removed
69
76
  return tier === 'starter' ? 'pro' : tier;
70
77
  }
@@ -91,7 +98,7 @@ function incrementUsage() {
91
98
 
92
99
  // Upgrade links — Free → Pro → Business → Team
93
100
  const UPGRADE_LINKS = {
94
- free: 'https://content-grade.github.io/Content-Grade/#pricing', // Pro $9/mo — pricing page first
101
+ free: 'https://buy.stripe.com/4gM14p87GeCh9vn9ks8k80a', // Pro $9/mo — direct checkout
95
102
  pro: 'https://buy.stripe.com/bJefZjafO2Tz36Z2W48k80b', // Business $29/mo
96
103
  business: 'https://buy.stripe.com/cNiaEZfA8cu9bDv4088k80c', // Team $79/mo
97
104
  };
@@ -145,7 +152,7 @@ function showFreeTierCTA(count) {
145
152
  console.log(` ${MG}→ Pro: unlimited analyses + CI mode + bulk grading. $9/mo: ${CY}${UPGRADE_LINKS.free}${R}`);
146
153
  } else {
147
154
  // 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}`);
155
+ console.log(` ${WH}${count}/${limit} free analyses · ${remaining} left · Unlimited with Pro ($9/mo): ${CY}${UPGRADE_LINKS.free}${R}`);
149
156
  }
150
157
  hr();
151
158
  }
@@ -194,11 +201,23 @@ function showUsageFooter(count) {
194
201
  } else if (remaining === 1) {
195
202
  console.log(` ${YL}[ ${count}/${limit} free runs used · 1 left. Unlimited runs: ${UPGRADE_LINKS.free} ]${R}`);
196
203
  } else {
197
- console.log(` ${D}[ ${count}/${limit} free runs today · ${remaining} left. Unlimited: ${UPGRADE_LINKS.free} ]${R}`);
204
+ console.log(` ${D}[ ${count}/${limit} free runs used · ${remaining} left. Unlimited: ${UPGRADE_LINKS.free} ]${R}`);
198
205
  }
199
206
  maybeShowEarlyAdopterCTA(count);
200
207
  }
201
208
 
209
+ // Returns { ok: boolean, count: number, limit: number } for tier-aware daily limit checks.
210
+ // Used by batch mode to stop processing when the daily limit is reached.
211
+ // Pro tier has Infinity limit and always returns ok: true.
212
+ function checkDailyLimit() {
213
+ const tier = getLicenseTier();
214
+ const limit = TIER_LIMITS[tier] ?? TIER_LIMITS.free;
215
+ if (limit === Infinity) return { ok: true, count: 0, limit: Infinity };
216
+ const usage = getUsage();
217
+ const count = usage.count || 0;
218
+ return { ok: count < limit, count, limit };
219
+ }
220
+
202
221
  // Block a run before it starts if the free tier is exhausted.
203
222
  // Returns true (blocked) and prints a visually distinct upgrade prompt.
204
223
  // Returns false if the user may proceed.
@@ -208,9 +227,12 @@ function checkFreeTierLimit() {
208
227
  const limit = TIER_LIMITS.free;
209
228
  if (usage.count < limit) return false;
210
229
 
230
+ // Track funnel event — user hit the limit and saw the upgrade prompt
231
+ recordEvent({ event: 'free_limit_hit', version: _version });
232
+
211
233
  blank();
212
234
  hr();
213
- console.log(` ${YL}${B}${limit}/${limit} free analyses used today.${R}`);
235
+ console.log(` ${YL}${B}${limit}/${limit} free analyses used — limit reached.${R}`);
214
236
  blank();
215
237
  console.log(` ${D}Pro removes the cap — unlimited analyses, $9/mo:${R}`);
216
238
  console.log(` ${D}├── Unlimited analyses — no cap, ever${R}`);
@@ -1008,7 +1030,13 @@ async function cmdActivate() {
1008
1030
  } else {
1009
1031
  process.stdout.write('\n');
1010
1032
  blank();
1011
- fail(data.message || `Invalid or expired license key. Get a new key: ${UPGRADE_LINKS.free}`);
1033
+ const msg = data.message || `Invalid or expired license key.`;
1034
+ fail(msg);
1035
+ if (/not found/i.test(msg) || /invalid/i.test(msg)) {
1036
+ blank();
1037
+ console.log(` ${D}If you recently purchased, retrieve your key:${R}`);
1038
+ console.log(` ${CY} https://content-grade.onrender.com/my-license${R}`);
1039
+ }
1012
1040
  blank();
1013
1041
  process.exit(1);
1014
1042
  }
@@ -2047,6 +2075,51 @@ const _telem = initTelemetry();
2047
2075
  // Defer first-run telemetry notice so users see value first
2048
2076
  const _showTelemNotice = _telem.isNew;
2049
2077
 
2078
+ // ── License key startup validation ───────────────────────────────────────────
2079
+ // Background check: re-validate stored license key against server every 7 days.
2080
+ // Non-blocking — never delays the CLI. Clears invalid/revoked keys silently.
2081
+ (async () => {
2082
+ const storedKey = getLicenseKey();
2083
+ if (!storedKey) return;
2084
+ const cfg = loadConfig();
2085
+ const lastCheck = cfg?.lastLicenseCheck;
2086
+ const sevenDays = 7 * 24 * 60 * 60 * 1000;
2087
+ if (lastCheck && Date.now() - new Date(lastCheck).getTime() < sevenDays) return;
2088
+ const serverUrl = process.env.CONTENT_GRADE_SERVER_URL || 'https://content-grade.onrender.com';
2089
+ try {
2090
+ const controller = new AbortController();
2091
+ const timer = setTimeout(() => controller.abort(), 3000);
2092
+ const response = await fetch(`${serverUrl}/api/license/validate`, {
2093
+ method: 'POST',
2094
+ headers: { 'Content-Type': 'application/json' },
2095
+ body: JSON.stringify({ key: storedKey }),
2096
+ signal: controller.signal,
2097
+ });
2098
+ clearTimeout(timer);
2099
+ const data = await response.json();
2100
+ const updated = loadConfig();
2101
+ updated.lastLicenseCheck = new Date().toISOString();
2102
+ if (data.valid) {
2103
+ const productKeyToTier = {
2104
+ contentgrade_starter: 'starter',
2105
+ contentgrade_pro: 'pro',
2106
+ contentgrade_team: 'team',
2107
+ };
2108
+ updated.tier = productKeyToTier[data.productKey] || 'pro';
2109
+ } else if (data.reason === 'revoked' || data.reason === 'subscription_expired') {
2110
+ // Only delete the key on confirmed revocation or expired subscription — NOT on 'not_found'
2111
+ // (which can happen after a Render cold start wipes the ephemeral /tmp DB).
2112
+ delete updated.licenseKey;
2113
+ delete updated.tier;
2114
+ try { unlinkSync(LICENSE_FILE); } catch {}
2115
+ }
2116
+ // If reason is 'not_found' or 'format_invalid', keep the stored key — server DB may have been wiped.
2117
+ saveConfig(updated);
2118
+ } catch {
2119
+ // Server unreachable — skip silently
2120
+ }
2121
+ })();
2122
+
2050
2123
  // Smart path detection: first arg is a path if it starts with ., /, ~ or is an existing FS entry
2051
2124
  function looksLikePath(s) {
2052
2125
  if (!s) return false;
package/bin/telemetry.js CHANGED
@@ -124,7 +124,7 @@ export function recordEvent(data) {
124
124
  const event = {
125
125
  ...data,
126
126
  package: 'content-grade',
127
- installId,
127
+ install_id: installId,
128
128
  run_count_bucket: data.run_count_bucket ?? getRunCountBucket(totalRuns),
129
129
  timestamp: new Date().toISOString(),
130
130
  };
@@ -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,13 +39,22 @@ 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, reason: 'not_found', message: 'Key not found. If you are a Pro subscriber, retrieve your key at: https://content-grade.onrender.com/my-license' };
46
+ }
47
+ return { valid: false, reason: 'format_invalid', message: 'Invalid license key format.' };
48
+ }
49
+ if (result.status !== 'active') {
50
+ return { valid: false, reason: 'revoked', message: 'License key has been revoked.' };
43
51
  }
44
- // Also verify Stripe subscription is still active for subscription-based keys
52
+ // Also verify Stripe subscription is still active for subscription-based keys.
53
+ // Uses live Stripe check (with DB fallback) so DB wipes on Render cold start don't invalidate valid subscribers.
45
54
  if (result.productKey === 'contentgrade_pro' && result.email) {
46
- const subActive = hasActiveSubscription(result.email);
55
+ const subActive = await hasActiveSubscriptionLive(result.email);
47
56
  if (!subActive) {
48
- return { valid: false, message: 'Subscription is no longer active. Please renew at content-grade.onrender.com.' };
57
+ return { valid: false, reason: 'subscription_expired', message: 'Subscription is no longer active. Please renew at content-grade.onrender.com.' };
49
58
  }
50
59
  }
51
60
  return { valid: true, email: result.email, productKey: result.productKey };
@@ -110,7 +110,7 @@ export function registerStripeRoutes(app) {
110
110
  try {
111
111
  const data = event.data?.object;
112
112
  if (event.type === 'checkout.session.completed') {
113
- const email = (data.client_reference_id ?? data.customer_email ?? '').toLowerCase();
113
+ const email = (data.client_reference_id ?? data.customer_email ?? data.customer_details?.email ?? '').toLowerCase();
114
114
  const stripeCustomerId = data.customer;
115
115
  if (!email) {
116
116
  console.error('[stripe_webhook] checkout.session.completed missing customer email — session:', data.id, '— license key NOT generated');
@@ -250,7 +250,7 @@ export function registerStripeRoutes(app) {
250
250
  reply.type('text/html').status(402);
251
251
  return successHtml('Payment pending', '<p>Your payment has not completed yet. Please wait a moment and refresh.</p>');
252
252
  }
253
- const email = (session.client_reference_id ?? session.customer_email ?? '').toLowerCase();
253
+ const email = (session.client_reference_id ?? session.customer_email ?? session.customer_details?.email ?? '').toLowerCase();
254
254
  if (!email) {
255
255
  reply.type('text/html').status(400);
256
256
  return successHtml('Email not found', '<p>We couldn\'t identify your account. Please contact support with your Stripe receipt.</p>');
@@ -265,8 +265,7 @@ export function registerStripeRoutes(app) {
265
265
  <p>Activate it in your terminal:</p>
266
266
  <pre style="background:#111;color:#ccc;padding:12px;border-radius:6px">content-grade activate ${licenseKey}</pre>
267
267
  <p style="font-size:0.9em;color:#888">
268
- Save this key — it won't be shown again.<br>
269
- Retrieve it any time: <code>GET /api/stripe/license-key?email=${encodeURIComponent(email)}</code>
268
+ Retrieve it any time: <a href="/my-license?email=${encodeURIComponent(email)}">/my-license?email=${encodeURIComponent(email)}</a>
270
269
  </p>
271
270
  `);
272
271
  }
@@ -276,6 +275,72 @@ export function registerStripeRoutes(app) {
276
275
  return successHtml('Error retrieving order', '<p>We could not load your order details. Please contact support with your Stripe receipt.</p>');
277
276
  }
278
277
  });
278
+ // Self-service license key retrieval page — for users who paid via static Stripe link
279
+ // and weren't redirected to /checkout/success, or who lost their key.
280
+ app.get('/my-license', async (req, reply) => {
281
+ const query = req.query;
282
+ const email = (query.email ?? '').trim().toLowerCase();
283
+ if (email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
284
+ // Email provided — look up their key
285
+ const isActive = await hasActiveSubscriptionLive(email);
286
+ if (!isActive) {
287
+ reply.type('text/html');
288
+ return successHtml('No active subscription found', `
289
+ <p>No active Content-Grade Pro subscription was found for <strong>${email}</strong>.</p>
290
+ <p>If you just paid, it can take up to a minute for the webhook to process. Try again shortly.</p>
291
+ <p>If you believe this is an error, contact support with your Stripe receipt.</p>
292
+ <p><a href="/my-license">Try a different email</a></p>
293
+ `);
294
+ }
295
+ const customerId = getCustomerStripeId(email);
296
+ const licenseKey = upsertLicenseKey(email, customerId ?? undefined, 'contentgrade_pro');
297
+ reply.type('text/html');
298
+ return successHtml('Your Content-Grade Pro License', `
299
+ <p>Active Pro subscription confirmed for <strong>${email}</strong>.</p>
300
+ <p>Your license key:</p>
301
+ <pre style="background:#111;color:#7fff7f;padding:16px;border-radius:6px;font-size:1.1em;letter-spacing:0.05em">${licenseKey}</pre>
302
+ <p>Activate it in your terminal:</p>
303
+ <pre style="background:#111;color:#ccc;padding:12px;border-radius:6px">content-grade activate ${licenseKey}</pre>
304
+ <p style="font-size:0.9em;color:#888">
305
+ Bookmark this page to retrieve your key any time.<br>
306
+ Replace the email in the URL: <code>/my-license?email=${encodeURIComponent(email)}</code>
307
+ </p>
308
+ `);
309
+ }
310
+ // No email — show the form
311
+ reply.type('text/html');
312
+ return `<!DOCTYPE html>
313
+ <html lang="en">
314
+ <head>
315
+ <meta charset="UTF-8"/>
316
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
317
+ <title>Retrieve License Key — Content-Grade</title>
318
+ <style>
319
+ body{font-family:system-ui,sans-serif;background:#0d0d0d;color:#e8e8e8;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
320
+ .card{max-width:480px;width:90%;background:#1a1a1a;border:1px solid #333;border-radius:12px;padding:40px}
321
+ h1{margin-top:0;font-size:1.5em}
322
+ input{width:100%;box-sizing:border-box;padding:10px 14px;background:#111;border:1px solid #444;border-radius:6px;color:#e8e8e8;font-size:1em;margin:8px 0 16px}
323
+ button{width:100%;padding:12px;background:#7fff7f;color:#000;border:none;border-radius:6px;font-size:1em;font-weight:600;cursor:pointer}
324
+ button:hover{background:#5fdf5f}
325
+ p{color:#aaa;font-size:0.9em}
326
+ a{color:#7fc4ff}
327
+ </style>
328
+ </head>
329
+ <body>
330
+ <div class="card">
331
+ <h1>Retrieve Your License Key</h1>
332
+ <p>Enter the email address you used when purchasing Content-Grade Pro.</p>
333
+ <form method="GET" action="/my-license">
334
+ <input type="email" name="email" placeholder="you@example.com" required autofocus/>
335
+ <button type="submit">Get My License Key</button>
336
+ </form>
337
+ <p style="margin-top:24px">
338
+ Don't have Pro yet? <a href="/upgrade">Upgrade for $9/mo →</a>
339
+ </p>
340
+ </div>
341
+ </body>
342
+ </html>`;
343
+ });
279
344
  // Upgrade redirect — CLI rate-limit messages point here for a clean, stable URL
280
345
  app.get('/upgrade', async (_req, reply) => {
281
346
  const proLink = process.env.STRIPE_PAYMENT_LINK_CONTENTGRADE_PRO
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-grade",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
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": {