content-grade 1.0.38 → 1.0.41

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.
@@ -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, '..');
@@ -160,7 +160,7 @@ function showFreeTierCTA(count) {
160
160
  } else {
161
161
  // Runs available — always visible count + clear Pro benefits + Stripe link
162
162
  console.log(` ${WH}${count}/${limit} runs used · ${remaining} remaining${R}`);
163
- console.log(` ${D} Pro: unlimited runs · batch analysis · priority support${R}`);
163
+ console.log(` ${WH} Pro: unlimited runs · batch analysis · priority support${R}`);
164
164
  console.log(` ${MG}→ Upgrade ($9/mo): ${CY}${UPGRADE_LINKS.free}${R}`);
165
165
  }
166
166
  hr();
@@ -276,20 +276,20 @@ function checkFreeTierLimit() {
276
276
 
277
277
  blank();
278
278
  hr();
279
- console.log(` ${WH}${B}You've used all ${limit} free runs — Pro gives you unlimited.${R}`);
279
+ console.log(` ${WH}${B}You've used all ${limit} free analyses.${R}`);
280
280
  blank();
281
- console.log(` ${WH}Pro unlocks:${R}`);
282
- console.log(` ${D} · Unlimited runs — no daily cap, no lifetime cap${R}`);
283
- console.log(` ${D} · Batch analysis — grade entire /posts/ directories at once${R}`);
284
- console.log(` ${D} · Priority support — direct response within 24h${R}`);
285
- console.log(` ${D} · CI integration — exit codes, JSON/HTML output${R}`);
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}`);
286
286
  blank();
287
287
  console.log(` ${MG}${B}→ Get Pro ($9/mo): ${CY}${UPGRADE_LINKS.free}${R}`);
288
288
  blank();
289
- console.log(` ${D}After checkoutrunning again in 2 minutes:${R}`);
289
+ console.log(` ${D}After purchaseactivate in 2 steps:${R}`);
290
290
  console.log(` ${D} 1. Get your key: ${CY}https://content-grade.onrender.com/my-license${R}`);
291
- console.log(` ${D} 2. Activate: ${CY}content-grade activate <your-key>${R}`);
292
- console.log(` ${D} 3. Re-run no cap, no counter.${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}`);
293
293
  hr();
294
294
  blank();
295
295
  return true;
@@ -2163,6 +2163,10 @@ if (_telem.isNew) {
2163
2163
  recordEvent({ event: 'install' });
2164
2164
  }
2165
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
+
2166
2170
  // ── License key startup validation ───────────────────────────────────────────
2167
2171
  // Background check: re-validate stored license key against server every 7 days.
2168
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/api/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,6 +249,44 @@ 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
292
  const FREE_LIFETIME_LIMIT = 15;
@@ -290,7 +345,7 @@ function _write(event) {
290
345
 
291
346
  async function _sendRemote(event) {
292
347
  const controller = new AbortController();
293
- const timer = setTimeout(() => controller.abort(), 3000);
348
+ const timer = setTimeout(() => controller.abort(), 500);
294
349
  try {
295
350
  await fetch(REMOTE_URL, {
296
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('/api/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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "content-grade",
3
- "version": "1.0.38",
3
+ "version": "1.0.41",
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": {