content-grade 1.0.37 → 1.0.40
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 +13 -0
- package/bin/content-grade.js +67 -61
- package/bin/telemetry.js +82 -22
- package/dist-server/server/routes/analytics.js +23 -0
- package/dist-server/server/routes/demos.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -229,6 +229,7 @@ Free tier: **5 analyses/day**. [Pro ($9/mo)](https://buy.stripe.com/4gM14p87GeCh
|
|
|
229
229
|
| [Show & Tell](https://github.com/StanislavBG/Content-Grade/discussions/new?category=show-and-tell) | Workflows, CI integrations, results — claim Early Adopter here |
|
|
230
230
|
| [Ideas](https://github.com/StanislavBG/Content-Grade/discussions/new?category=ideas) | Feature requests before they become issues |
|
|
231
231
|
| [Bug reports](https://github.com/StanislavBG/Content-Grade/issues/new?template=bug_report.yml) | Something broken? File it here |
|
|
232
|
+
| [Feedback](https://github.com/StanislavBG/Content-Grade/issues/new?title=Feedback%3A+&labels=feedback) | General feedback — what's working, what isn't |
|
|
232
233
|
|
|
233
234
|
**[All discussions →](https://github.com/StanislavBG/Content-Grade/discussions)**
|
|
234
235
|
|
|
@@ -236,6 +237,18 @@ Free tier: **5 analyses/day**. [Pro ($9/mo)](https://buy.stripe.com/4gM14p87GeCh
|
|
|
236
237
|
|
|
237
238
|
---
|
|
238
239
|
|
|
240
|
+
## Built with Content-Grade?
|
|
241
|
+
|
|
242
|
+
Using ContentGrade in a real workflow? [Open an issue](https://github.com/StanislavBG/Content-Grade/issues/new?title=Built+with+Content-Grade%3A+&labels=use-case) or post in [Show & Tell](https://github.com/StanislavBG/Content-Grade/discussions/new?category=show-and-tell) and tell us:
|
|
243
|
+
|
|
244
|
+
- What are you grading? (blog posts, landing pages, ad copy, emails?)
|
|
245
|
+
- Are you running it in CI? What score threshold do you use?
|
|
246
|
+
- Any workflow, config, or integration worth sharing?
|
|
247
|
+
|
|
248
|
+
Use cases that get shared here get featured in this README. Posting also qualifies you for the [Early Adopter program](EARLY_ADOPTERS.md) — 12 months Pro free.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
239
252
|
## Requirements
|
|
240
253
|
|
|
241
254
|
- **Node.js 18+**
|
package/bin/content-grade.js
CHANGED
|
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'url';
|
|
|
21
21
|
import { promisify } from 'util';
|
|
22
22
|
import { get as httpsGet } from 'https';
|
|
23
23
|
import { get as httpGet } from 'http';
|
|
24
|
-
import { initTelemetry, recordEvent, disableTelemetry, enableTelemetry, telemetryStatus, isOptedOut } from './telemetry.js';
|
|
24
|
+
import { initTelemetry, recordEvent, ping, disableTelemetry, enableTelemetry, telemetryStatus, isOptedOut } from './telemetry.js';
|
|
25
25
|
|
|
26
26
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
27
|
const root = resolve(__dirname, '..');
|
|
@@ -78,22 +78,21 @@ function getLicenseTier() {
|
|
|
78
78
|
|
|
79
79
|
function isProUser() { return Boolean(getLicenseKey()); }
|
|
80
80
|
|
|
81
|
-
function getTodayKey() { return new Date().toISOString().slice(0, 10); }
|
|
82
|
-
|
|
83
81
|
function getUsage() {
|
|
84
82
|
try {
|
|
85
83
|
const d = JSON.parse(readFileSync(USAGE_FILE, 'utf8'));
|
|
86
|
-
|
|
87
|
-
return d;
|
|
88
|
-
|
|
84
|
+
// Migrate legacy daily-reset format: if file has a date key, treat existing count as lifetime
|
|
85
|
+
if (d.date) return { lifetime: d.count || 0 };
|
|
86
|
+
return { lifetime: d.lifetime || 0 };
|
|
87
|
+
} catch { return { lifetime: 0 }; }
|
|
89
88
|
}
|
|
90
89
|
|
|
91
90
|
function incrementUsage() {
|
|
92
91
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
93
92
|
const u = getUsage();
|
|
94
|
-
u.
|
|
93
|
+
u.lifetime = (u.lifetime || 0) + 1;
|
|
95
94
|
writeFileSync(USAGE_FILE, JSON.stringify(u, null, 2), 'utf8');
|
|
96
|
-
return u.
|
|
95
|
+
return u.lifetime;
|
|
97
96
|
}
|
|
98
97
|
|
|
99
98
|
// Upgrade links — Free → Pro → Business → Team
|
|
@@ -111,28 +110,28 @@ const TIER_NAMES = {
|
|
|
111
110
|
};
|
|
112
111
|
|
|
113
112
|
const TIER_LIMITS = {
|
|
114
|
-
free:
|
|
113
|
+
free: 15,
|
|
115
114
|
pro: Infinity,
|
|
116
|
-
business:
|
|
117
|
-
team:
|
|
115
|
+
business: Infinity,
|
|
116
|
+
team: Infinity,
|
|
118
117
|
};
|
|
119
118
|
|
|
120
119
|
function getUpgradeMessage() {
|
|
121
120
|
const tier = getLicenseTier();
|
|
122
|
-
if (tier === 'free') return `
|
|
123
|
-
if (tier === 'pro') return `Upgrade to Business —
|
|
124
|
-
if (tier === 'business') return `Upgrade to Team —
|
|
121
|
+
if (tier === 'free') return `Unlimited analyses with Pro — $9/mo → ${UPGRADE_LINKS.free}`;
|
|
122
|
+
if (tier === 'pro') return `Upgrade to Business — priority support, $29/mo → ${UPGRADE_LINKS.pro}`;
|
|
123
|
+
if (tier === 'business') return `Upgrade to Team — team seats, $79/mo → ${UPGRADE_LINKS.business}`;
|
|
125
124
|
return '';
|
|
126
125
|
}
|
|
127
126
|
|
|
128
127
|
// Show usage-aware upgrade CTA after each free run.
|
|
129
|
-
// count =
|
|
128
|
+
// count = lifetime usage AFTER this run.
|
|
130
129
|
function showFreeTierCTA(count) {
|
|
131
|
-
const limit = TIER_LIMITS.free; //
|
|
130
|
+
const limit = TIER_LIMITS.free; // 15
|
|
132
131
|
const remaining = Math.max(0, limit - count);
|
|
133
132
|
|
|
134
133
|
// Track that an upgrade prompt was shown after a run (post-run CTA, not a hard block)
|
|
135
|
-
const ctaStrength = remaining === 0 ? 'strong' : remaining
|
|
134
|
+
const ctaStrength = remaining === 0 ? 'strong' : remaining <= 2 ? 'warning' : 'nudge';
|
|
136
135
|
recordEvent({ event: 'upgrade_prompt_shown', run_count: count, cta_strength: ctaStrength, cta_context: 'post_run' });
|
|
137
136
|
|
|
138
137
|
blank();
|
|
@@ -140,11 +139,10 @@ function showFreeTierCTA(count) {
|
|
|
140
139
|
|
|
141
140
|
if (remaining === 0) {
|
|
142
141
|
// Limit reached — benefit-first wall + explicit post-purchase path
|
|
143
|
-
console.log(` ${RD}${B}
|
|
142
|
+
console.log(` ${RD}${B}You've used all ${limit} free analyses.${R}`);
|
|
144
143
|
blank();
|
|
145
|
-
console.log(` ${WH}${B}Pro removes the cap —
|
|
146
|
-
console.log(` ${D} Unlimited
|
|
147
|
-
console.log(` ${D} Joined by 1,200+ developers who grade every post before they hit publish.${R}`);
|
|
144
|
+
console.log(` ${WH}${B}Pro removes the cap — unlimited analyses, forever.${R}`);
|
|
145
|
+
console.log(` ${D} Unlimited runs · batch entire /posts/ dirs · priority support · CI exit codes${R}`);
|
|
148
146
|
blank();
|
|
149
147
|
console.log(` ${MG}${B}→ Upgrade ($9/mo): ${CY}${UPGRADE_LINKS.free}${R}`);
|
|
150
148
|
blank();
|
|
@@ -153,16 +151,17 @@ function showFreeTierCTA(count) {
|
|
|
153
151
|
console.log(` ${D} 2. Activate: ${CY}content-grade activate <your-key>${R}`);
|
|
154
152
|
console.log(` ${D} 3. Run again — no cap, no counter.${R}`);
|
|
155
153
|
console.log(` ${D} Already have a key? Skip to step 2.${R}`);
|
|
156
|
-
} else if (remaining
|
|
157
|
-
// 1
|
|
158
|
-
console.log(` ${YL}${B}
|
|
154
|
+
} else if (remaining <= 2) {
|
|
155
|
+
// 1-2 runs left — loss aversion + clear Pro value
|
|
156
|
+
console.log(` ${YL}${B}${count}/${limit} runs used · ${remaining} free analysis${remaining === 1 ? '' : 'es'} remaining${R}`);
|
|
159
157
|
blank();
|
|
160
|
-
console.log(` ${WH}
|
|
161
|
-
console.log(` ${MG}→ ${CY}${UPGRADE_LINKS.free}${R}`);
|
|
158
|
+
console.log(` ${WH}${B}Pro: unlimited runs · batch analysis · priority support${R}`);
|
|
159
|
+
console.log(` ${MG}${B}→ Get Pro ($9/mo): ${CY}${UPGRADE_LINKS.free}${R}`);
|
|
162
160
|
} else {
|
|
163
|
-
// Runs available —
|
|
164
|
-
console.log(` ${
|
|
165
|
-
console.log(` ${
|
|
161
|
+
// Runs available — always visible count + clear Pro benefits + Stripe link
|
|
162
|
+
console.log(` ${WH}${count}/${limit} runs used · ${remaining} remaining${R}`);
|
|
163
|
+
console.log(` ${WH} Pro: unlimited runs · batch analysis · priority support${R}`);
|
|
164
|
+
console.log(` ${MG}→ Upgrade ($9/mo): ${CY}${UPGRADE_LINKS.free}${R}`);
|
|
166
165
|
}
|
|
167
166
|
hr();
|
|
168
167
|
maybeShowFeedbackCTA(count);
|
|
@@ -185,7 +184,7 @@ function markEarlyAdopterCTAShown() {
|
|
|
185
184
|
// Show once, after the first successful run, to non-Pro users who haven't seen it.
|
|
186
185
|
function maybeShowEarlyAdopterCTA(count) {
|
|
187
186
|
if (isProUser()) return;
|
|
188
|
-
if (count !== 1) return; // only after the very first run
|
|
187
|
+
if (count !== 1) return; // only after the very first run
|
|
189
188
|
if (hasShownEarlyAdopterCTA()) return; // only once per install
|
|
190
189
|
markEarlyAdopterCTAShown();
|
|
191
190
|
blank();
|
|
@@ -233,32 +232,32 @@ function showCommunityNudge() {
|
|
|
233
232
|
}
|
|
234
233
|
|
|
235
234
|
// Single-line usage counter appended after every command run.
|
|
236
|
-
// count = total runs used
|
|
235
|
+
// count = total lifetime runs used (after this run).
|
|
237
236
|
function showUsageFooter(count) {
|
|
238
237
|
if (isProUser()) return;
|
|
239
238
|
const limit = TIER_LIMITS.free;
|
|
240
239
|
const remaining = Math.max(0, limit - count);
|
|
241
240
|
blank();
|
|
242
241
|
if (remaining === 0) {
|
|
243
|
-
console.log(` ${RD}[
|
|
244
|
-
} else if (remaining
|
|
245
|
-
console.log(` ${YL}[
|
|
242
|
+
console.log(` ${RD}[ All ${limit} free analyses used · Pro removes the cap — $9/mo: ${UPGRADE_LINKS.free} ]${R}`);
|
|
243
|
+
} else if (remaining <= 2) {
|
|
244
|
+
console.log(` ${YL}[ ${remaining} free analysis${remaining === 1 ? '' : 'es'} remaining · Upgrade to Pro: ${UPGRADE_LINKS.free} ]${R}`);
|
|
246
245
|
} else {
|
|
247
|
-
console.log(` ${D}[ ${count}/${limit} free
|
|
246
|
+
console.log(` ${D}[ ${count}/${limit} free analyses used · ${remaining} remaining · Unlimited with Pro ($9/mo): ${UPGRADE_LINKS.free} ]${R}`);
|
|
248
247
|
}
|
|
249
248
|
maybeShowEarlyAdopterCTA(count);
|
|
250
249
|
maybeShowFeedbackCTA(count);
|
|
251
250
|
}
|
|
252
251
|
|
|
253
|
-
// Returns { ok: boolean, count: number, limit: number } for tier-aware
|
|
254
|
-
// Used by batch mode to stop processing when the
|
|
252
|
+
// Returns { ok: boolean, count: number, limit: number } for tier-aware lifetime limit checks.
|
|
253
|
+
// Used by batch mode to stop processing when the lifetime limit is reached.
|
|
255
254
|
// Pro tier has Infinity limit and always returns ok: true.
|
|
256
|
-
function
|
|
255
|
+
function checkLifetimeLimit() {
|
|
257
256
|
const tier = getLicenseTier();
|
|
258
257
|
const limit = TIER_LIMITS[tier] ?? TIER_LIMITS.free;
|
|
259
258
|
if (limit === Infinity) return { ok: true, count: 0, limit: Infinity };
|
|
260
259
|
const usage = getUsage();
|
|
261
|
-
const count = usage.
|
|
260
|
+
const count = usage.lifetime || 0;
|
|
262
261
|
return { ok: count < limit, count, limit };
|
|
263
262
|
}
|
|
264
263
|
|
|
@@ -269,25 +268,28 @@ function checkFreeTierLimit() {
|
|
|
269
268
|
if (isProUser()) return false;
|
|
270
269
|
const usage = getUsage();
|
|
271
270
|
const limit = TIER_LIMITS.free;
|
|
272
|
-
if (usage.
|
|
271
|
+
if (usage.lifetime < limit) return false;
|
|
273
272
|
|
|
274
273
|
// Track funnel event — user hit the limit and saw the upgrade prompt
|
|
275
274
|
recordEvent({ event: 'free_limit_hit', version: _version });
|
|
276
|
-
recordEvent({ event: 'upgrade_prompt_shown', run_count: usage.
|
|
275
|
+
recordEvent({ event: 'upgrade_prompt_shown', run_count: usage.lifetime });
|
|
277
276
|
|
|
278
277
|
blank();
|
|
279
278
|
hr();
|
|
280
|
-
console.log(` ${
|
|
279
|
+
console.log(` ${WH}${B}You've used all ${limit} free analyses.${R}`);
|
|
281
280
|
blank();
|
|
282
|
-
console.log(` ${
|
|
281
|
+
console.log(` ${WH}${B}Pro removes the limit — unlimited runs, forever.${R}`);
|
|
282
|
+
console.log(` ${WH} · Unlimited runs — no cap, no counter${R}`);
|
|
283
|
+
console.log(` ${WH} · Batch analysis — grade entire /posts/ directories at once${R}`);
|
|
284
|
+
console.log(` ${WH} · Priority support — direct response within 24h${R}`);
|
|
285
|
+
console.log(` ${WH} · CI integration — exit codes, JSON/HTML output${R}`);
|
|
283
286
|
blank();
|
|
284
|
-
console.log(` ${
|
|
285
|
-
console.log(` ${D} 1. Get your key: ${CY}https://content-grade.onrender.com/my-license${R}`);
|
|
286
|
-
console.log(` ${D} 2. Activate: ${CY}content-grade activate <your-key>${R}`);
|
|
287
|
-
console.log(` ${D} 3. Re-run your command — no cap, no counter.${R}`);
|
|
288
|
-
console.log(` ${D} Already have a key? Skip to step 2.${R}`);
|
|
287
|
+
console.log(` ${MG}${B}→ Get Pro ($9/mo): ${CY}${UPGRADE_LINKS.free}${R}`);
|
|
289
288
|
blank();
|
|
290
|
-
console.log(` ${D}
|
|
289
|
+
console.log(` ${D}After purchase — activate in 2 steps:${R}`);
|
|
290
|
+
console.log(` ${D} 1. Get your key: ${CY}https://content-grade.onrender.com/my-license${R}`);
|
|
291
|
+
console.log(` ${D} 2. Run: ${CY}content-grade activate <your-key>${R}`);
|
|
292
|
+
console.log(` ${D} Already have a key? Run step 2 now.${R}`);
|
|
291
293
|
hr();
|
|
292
294
|
blank();
|
|
293
295
|
return true;
|
|
@@ -988,12 +990,12 @@ async function cmdActivate() {
|
|
|
988
990
|
blank();
|
|
989
991
|
console.log(` ${B}Pro features:${R}`);
|
|
990
992
|
console.log(` ${D} content-grade batch ./posts/ ${R}${D}# analyze all files in a directory${R}`);
|
|
991
|
-
console.log(` ${D} Unlimited analyses
|
|
993
|
+
console.log(` ${D} Unlimited analyses (vs 15 free total)${R}`);
|
|
992
994
|
blank();
|
|
993
995
|
return;
|
|
994
996
|
}
|
|
995
997
|
|
|
996
|
-
console.log(` ${D}Pro unlocks unlimited
|
|
998
|
+
console.log(` ${D}Pro unlocks unlimited analyses + batch mode for $9/mo:${R}`);
|
|
997
999
|
blank();
|
|
998
1000
|
console.log(` ${MG}${B}→ Get Pro: ${CY}${UPGRADE_LINKS.free}${R}`);
|
|
999
1001
|
blank();
|
|
@@ -1115,7 +1117,7 @@ async function cmdActivate() {
|
|
|
1115
1117
|
const tierLimit = TIER_LIMITS[activatedTier] || TIER_LIMITS.free;
|
|
1116
1118
|
const tierLimitDisplay = tierLimit === Infinity ? 'unlimited' : `${tierLimit}`;
|
|
1117
1119
|
blank();
|
|
1118
|
-
ok(`${(TIER_NAMES[activatedTier] || activatedTier).split(' —')[0]} activated!
|
|
1120
|
+
ok(`${(TIER_NAMES[activatedTier] || activatedTier).split(' —')[0]} activated! You now have ${tierLimitDisplay} analyses.`);
|
|
1119
1121
|
blank();
|
|
1120
1122
|
console.log(` ${B}Try it:${R}`);
|
|
1121
1123
|
console.log(` ${CY} content-grade batch ./posts/${R} ${D}# analyze all files${R}`);
|
|
@@ -1131,7 +1133,7 @@ async function cmdBatch(dirPath) {
|
|
|
1131
1133
|
blank();
|
|
1132
1134
|
console.log(` ${D}Batch mode grades every file in a directory in one shot — Pro only.${R}`);
|
|
1133
1135
|
blank();
|
|
1134
|
-
console.log(` Pro is $9/mo and
|
|
1136
|
+
console.log(` Pro is $9/mo and unlocks unlimited analyses.`);
|
|
1135
1137
|
blank();
|
|
1136
1138
|
console.log(` ${MG}${B}→ Get Pro: ${CY}${UPGRADE_LINKS.free}${R}`);
|
|
1137
1139
|
blank();
|
|
@@ -1212,12 +1214,12 @@ async function cmdBatch(dirPath) {
|
|
|
1212
1214
|
const rel = f.startsWith(process.cwd()) ? f.slice(process.cwd().length + 1) : f;
|
|
1213
1215
|
process.stdout.write(` ${D}[${i + 1}/${files.length}]${R} ${rel}...`);
|
|
1214
1216
|
|
|
1215
|
-
// Guard: check
|
|
1216
|
-
const batchLimitCheck =
|
|
1217
|
+
// Guard: check lifetime limit before each analysis
|
|
1218
|
+
const batchLimitCheck = checkLifetimeLimit();
|
|
1217
1219
|
if (!batchLimitCheck.ok) {
|
|
1218
1220
|
process.stdout.write(` ${YL}limit reached${R}\n`);
|
|
1219
1221
|
blank();
|
|
1220
|
-
warn(`
|
|
1222
|
+
warn(`Free limit reached (${batchLimitCheck.count}/${batchLimitCheck.limit} lifetime analyses used). Remaining files skipped.`);
|
|
1221
1223
|
break;
|
|
1222
1224
|
}
|
|
1223
1225
|
|
|
@@ -1266,7 +1268,7 @@ async function cmdBatch(dirPath) {
|
|
|
1266
1268
|
|
|
1267
1269
|
if (_jsonMode) process.stdout.write(JSON.stringify(results, null, 2) + '\n');
|
|
1268
1270
|
|
|
1269
|
-
if (!isProUser() && results.length) showUsageFooter(getUsage().
|
|
1271
|
+
if (!isProUser() && results.length) showUsageFooter(getUsage().lifetime);
|
|
1270
1272
|
if (results.length) showCommunityNudge();
|
|
1271
1273
|
}
|
|
1272
1274
|
|
|
@@ -1344,7 +1346,7 @@ function cmdStart() {
|
|
|
1344
1346
|
info(` EmailForge — ${url}/email-forge`);
|
|
1345
1347
|
info(` AudienceDecoder — ${url}/audience`);
|
|
1346
1348
|
blank();
|
|
1347
|
-
info(`Free tier:
|
|
1349
|
+
info(`Free tier: 15 analyses total. Unlimited with Pro ($9/mo) → ${UPGRADE_LINKS.free}`);
|
|
1348
1350
|
info(`Press Ctrl+C to stop`);
|
|
1349
1351
|
blank();
|
|
1350
1352
|
openBrowser(url);
|
|
@@ -1618,10 +1620,10 @@ function cmdHelp() {
|
|
|
1618
1620
|
|
|
1619
1621
|
console.log(` ${B}PRICING${R}`);
|
|
1620
1622
|
blank();
|
|
1621
|
-
console.log(` Free
|
|
1623
|
+
console.log(` Free 15 total $0`);
|
|
1622
1624
|
console.log(` Pro Unlimited $9/mo`);
|
|
1623
|
-
console.log(` Business
|
|
1624
|
-
console.log(` Team
|
|
1625
|
+
console.log(` Business Unlimited $29/mo`);
|
|
1626
|
+
console.log(` Team Unlimited $79/mo`);
|
|
1625
1627
|
blank();
|
|
1626
1628
|
console.log(` Get Pro: ${CY}${UPGRADE_LINKS.free}${R} ${D}# direct checkout${R}`);
|
|
1627
1629
|
console.log(` Activate: ${CY}content-grade activate <your-license-key>${R}`);
|
|
@@ -2161,6 +2163,10 @@ if (_telem.isNew) {
|
|
|
2161
2163
|
recordEvent({ event: 'install' });
|
|
2162
2164
|
}
|
|
2163
2165
|
|
|
2166
|
+
// Fire-and-forget ping on every run — 500ms timeout, never blocks.
|
|
2167
|
+
// Sends { command, version, timestamp, anonymous_id } to /ping.
|
|
2168
|
+
ping(cmd || 'none');
|
|
2169
|
+
|
|
2164
2170
|
// ── License key startup validation ───────────────────────────────────────────
|
|
2165
2171
|
// Background check: re-validate stored license key against server every 7 days.
|
|
2166
2172
|
// Non-blocking — never delays the CLI. Clears invalid/revoked keys silently.
|
package/bin/telemetry.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'fs';
|
|
12
|
-
import { homedir } from 'os';
|
|
12
|
+
import { homedir, hostname } from 'os';
|
|
13
13
|
import { join, resolve, dirname } from 'path';
|
|
14
14
|
import { createHash } from 'crypto';
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
@@ -27,9 +27,11 @@ const EVENTS_FILE = join(CONFIG_DIR, 'events.jsonl');
|
|
|
27
27
|
|
|
28
28
|
// Telemetry endpoint: CLI events are forwarded here when telemetry is enabled.
|
|
29
29
|
// Set CONTENT_GRADE_TELEMETRY_URL to enable remote aggregation (e.g. self-hosted endpoint).
|
|
30
|
-
// Default: local-only (events stored at ~/.content-grade/events.jsonl, no remote send).
|
|
31
30
|
const REMOTE_URL = process.env.CONTENT_GRADE_TELEMETRY_URL || 'https://content-grade.onrender.com/api/telemetry';
|
|
32
31
|
|
|
32
|
+
// Lightweight ping endpoint — one POST per CLI run, 500ms hard timeout.
|
|
33
|
+
const DEFAULT_PING_URL = 'https://content-grade.onrender.com/ping';
|
|
34
|
+
|
|
33
35
|
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
34
36
|
|
|
35
37
|
function ensureDir() {
|
|
@@ -53,6 +55,21 @@ function generateId() {
|
|
|
53
55
|
.slice(0, 16);
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Returns a stable, anonymous machine ID — no PII.
|
|
60
|
+
* Tries /etc/machine-id (Linux), falls back to hashing hostname.
|
|
61
|
+
* Same machine always produces the same ID. Survives reinstalls.
|
|
62
|
+
*/
|
|
63
|
+
function getAnonymousId() {
|
|
64
|
+
let source = '';
|
|
65
|
+
try { source = readFileSync('/etc/machine-id', 'utf8').trim(); } catch {}
|
|
66
|
+
if (!source) {
|
|
67
|
+
try { source = readFileSync('/var/lib/dbus/machine-id', 'utf8').trim(); } catch {}
|
|
68
|
+
}
|
|
69
|
+
if (!source) source = hostname();
|
|
70
|
+
return createHash('sha256').update(source).digest('hex').slice(0, 32);
|
|
71
|
+
}
|
|
72
|
+
|
|
56
73
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
57
74
|
|
|
58
75
|
/**
|
|
@@ -232,46 +249,89 @@ export function telemetryStatus() {
|
|
|
232
249
|
};
|
|
233
250
|
}
|
|
234
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Fire-and-forget ping on each CLI run.
|
|
254
|
+
* Sends { command, version, timestamp, anonymous_id } to /ping.
|
|
255
|
+
* 500ms hard timeout — never blocks the CLI.
|
|
256
|
+
* URL reads from config (telemetryPingUrl), falls back to DEFAULT_PING_URL.
|
|
257
|
+
*/
|
|
258
|
+
export async function ping(command) {
|
|
259
|
+
if (isOptedOut()) return;
|
|
260
|
+
const cfg = loadConfig();
|
|
261
|
+
const url = cfg?.telemetryPingUrl || DEFAULT_PING_URL;
|
|
262
|
+
|
|
263
|
+
// Persist URL in config if not already set (one-time write)
|
|
264
|
+
if (!cfg?.telemetryPingUrl) {
|
|
265
|
+
saveConfig({ ...(cfg || {}), telemetryPingUrl: DEFAULT_PING_URL });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const controller = new AbortController();
|
|
269
|
+
const timer = setTimeout(() => controller.abort(), 500);
|
|
270
|
+
try {
|
|
271
|
+
await fetch(url, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers: { 'Content-Type': 'application/json' },
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
command,
|
|
276
|
+
version: _pkgVersion,
|
|
277
|
+
timestamp: new Date().toISOString(),
|
|
278
|
+
anonymous_id: getAnonymousId(),
|
|
279
|
+
platform: process.platform,
|
|
280
|
+
}),
|
|
281
|
+
signal: controller.signal,
|
|
282
|
+
});
|
|
283
|
+
} catch {
|
|
284
|
+
// Network failure — silently ignore, never interrupt the CLI
|
|
285
|
+
} finally {
|
|
286
|
+
clearTimeout(timer);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
235
290
|
// ── Rate limiting ─────────────────────────────────────────────────────────────
|
|
236
291
|
|
|
237
|
-
const
|
|
238
|
-
const PRO_DAILY_LIMIT = 20;
|
|
239
|
-
const BUSINESS_DAILY_LIMIT = 100;
|
|
240
|
-
const TEAM_DAILY_LIMIT = 500;
|
|
292
|
+
const FREE_LIFETIME_LIMIT = 15;
|
|
241
293
|
|
|
242
294
|
/**
|
|
243
|
-
* Check whether the user is within their
|
|
295
|
+
* Check whether the user is within their lifetime limit for a given command type.
|
|
244
296
|
* Type: 'analyze' | 'headline'
|
|
297
|
+
* Pro/paid users always pass (unlimited).
|
|
298
|
+
* Free users get 15 lifetime total runs across all command types.
|
|
245
299
|
* Returns { ok: boolean, used: number, remaining: number, limit: number, isPro: boolean }
|
|
246
300
|
*/
|
|
247
|
-
export function
|
|
301
|
+
export function checkUsageLimit(type = 'analyze') {
|
|
248
302
|
const cfg = loadConfig();
|
|
249
|
-
const tier = cfg?.tier || 'free';
|
|
250
|
-
const LIMITS = { free: FREE_DAILY_LIMIT, pro: PRO_DAILY_LIMIT, business: BUSINESS_DAILY_LIMIT, team: TEAM_DAILY_LIMIT };
|
|
251
|
-
const limit = cfg?.licenseKey ? (LIMITS[tier] || PRO_DAILY_LIMIT) : FREE_DAILY_LIMIT;
|
|
252
303
|
const isPro = !!cfg?.licenseKey;
|
|
304
|
+
if (isPro) return { ok: true, used: 0, remaining: Infinity, limit: Infinity, isPro: true };
|
|
253
305
|
|
|
254
|
-
const
|
|
255
|
-
|
|
306
|
+
const limit = FREE_LIFETIME_LIMIT;
|
|
307
|
+
// Lifetime usage stored in cfg.lifetimeUsage (total across all types)
|
|
308
|
+
const used = cfg?.lifetimeUsage?.total ?? 0;
|
|
256
309
|
const remaining = limit - used;
|
|
257
310
|
return { ok: remaining > 0, used, remaining: Math.max(0, remaining), limit, isPro };
|
|
258
311
|
}
|
|
259
312
|
|
|
313
|
+
/** @deprecated Use checkUsageLimit instead */
|
|
314
|
+
export const checkDailyLimit = checkUsageLimit;
|
|
315
|
+
|
|
260
316
|
/**
|
|
261
|
-
* Increment the
|
|
262
|
-
* Returns the new usage count.
|
|
317
|
+
* Increment the lifetime usage counter for a command type after a successful run.
|
|
318
|
+
* Returns the new total usage count.
|
|
263
319
|
*/
|
|
264
|
-
export function
|
|
320
|
+
export function incrementUsage(type = 'analyze') {
|
|
265
321
|
const cfg = loadConfig() || {};
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
cfg.
|
|
322
|
+
if (!cfg.lifetimeUsage) {
|
|
323
|
+
// Migrate from legacy dailyUsage if present
|
|
324
|
+
cfg.lifetimeUsage = { total: 0, analyze: 0, headline: 0 };
|
|
269
325
|
}
|
|
270
|
-
cfg.
|
|
326
|
+
cfg.lifetimeUsage[type] = (cfg.lifetimeUsage[type] ?? 0) + 1;
|
|
327
|
+
cfg.lifetimeUsage.total = (cfg.lifetimeUsage.total ?? 0) + 1;
|
|
271
328
|
saveConfig(cfg);
|
|
272
|
-
return cfg.
|
|
329
|
+
return cfg.lifetimeUsage.total;
|
|
273
330
|
}
|
|
274
331
|
|
|
332
|
+
/** @deprecated Use incrementUsage instead */
|
|
333
|
+
export const incrementDailyUsage = incrementUsage;
|
|
334
|
+
|
|
275
335
|
// ── Internal ──────────────────────────────────────────────────────────────────
|
|
276
336
|
|
|
277
337
|
function _write(event) {
|
|
@@ -285,7 +345,7 @@ function _write(event) {
|
|
|
285
345
|
|
|
286
346
|
async function _sendRemote(event) {
|
|
287
347
|
const controller = new AbortController();
|
|
288
|
-
const timer = setTimeout(() => controller.abort(),
|
|
348
|
+
const timer = setTimeout(() => controller.abort(), 500);
|
|
289
349
|
try {
|
|
290
350
|
await fetch(REMOTE_URL, {
|
|
291
351
|
method: 'POST',
|
|
@@ -31,6 +31,29 @@ function thirtyDaysAgo() {
|
|
|
31
31
|
return d.toISOString().slice(0, 10);
|
|
32
32
|
}
|
|
33
33
|
export function registerAnalyticsRoutes(app) {
|
|
34
|
+
// ── Lightweight ping — fire-and-forget per CLI run ────────────────────────
|
|
35
|
+
// Receives { command, version, timestamp, anonymous_id } from CLI.
|
|
36
|
+
// No PII. Maps anonymous_id → install_id for unique user counts.
|
|
37
|
+
// Always returns 200 — never interrupt a CLI session.
|
|
38
|
+
app.post('/ping', async (req) => {
|
|
39
|
+
try {
|
|
40
|
+
const body = req.body;
|
|
41
|
+
const anonymousId = body?.anonymous_id ? String(body.anonymous_id).slice(0, 64) : null;
|
|
42
|
+
if (!anonymousId)
|
|
43
|
+
return { ok: true };
|
|
44
|
+
const db = getDb();
|
|
45
|
+
const b = body;
|
|
46
|
+
db.prepare(`
|
|
47
|
+
INSERT INTO cli_telemetry
|
|
48
|
+
(install_id, event, command, version, platform, created_at)
|
|
49
|
+
VALUES (?, 'ping', ?, ?, ?, CURRENT_TIMESTAMP)
|
|
50
|
+
`).run(anonymousId, b.command ? String(b.command).slice(0, 64) : null, b.version ? String(b.version).slice(0, 32) : null, b.platform ? String(b.platform).slice(0, 32) : null);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// never fail — telemetry is non-critical
|
|
54
|
+
}
|
|
55
|
+
return { ok: true };
|
|
56
|
+
});
|
|
34
57
|
// ── CLI telemetry receiver ────────────────────────────────────────────────
|
|
35
58
|
// Receives events from CLI users who have opted in to telemetry.
|
|
36
59
|
// Always returns 200 — never interrupt a CLI session for analytics.
|
|
@@ -20,10 +20,10 @@ const TIER_LIMITS = {
|
|
|
20
20
|
team: TEAM_TIER_LIMIT,
|
|
21
21
|
};
|
|
22
22
|
function freeGateMsg(_what) {
|
|
23
|
-
return `Free
|
|
23
|
+
return `Free limit reached (${FREE_TIER_LIMIT} per session). Upgrade to Pro for unlimited: ${UPGRADE_URL}`;
|
|
24
24
|
}
|
|
25
25
|
function paidGateMsg(limit) {
|
|
26
|
-
return `
|
|
26
|
+
return `Rate limit reached (${limit}/day). Upgrade for more at content-grade.github.io/Content-Grade/#pricing. Resets at midnight UTC.`;
|
|
27
27
|
}
|
|
28
28
|
function hashIp(ip) {
|
|
29
29
|
return createHash('sha256').update(ip).digest('hex');
|
package/package.json
CHANGED