seo-intel 1.5.39 → 1.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.40 (2026-05-28)
4
+
5
+ ### Setup wizard — daily notifications now self-install
6
+ The notify loop is now opt-in via a single click. Setup-wizard's Done step gains a small "Daily problem notifications" card that toggles a managed `crontab` line via `lib/cron.js`. No more manual `crontab -e`.
7
+
8
+ - **New CLI:** `seo-intel install-cron [--schedule "0 9 * * *"] [--open] [--remove]` — install / replace / remove a managed cron line. Idempotent. macOS + Linux. Windows returns a clear "use Task Scheduler" message.
9
+ - **New library:** `lib/cron.js` exports `installNotifyCron`, `removeNotifyCron`, `getNotifyCronStatus`. Uses a `# managed-by-seo-intel` marker comment so we only touch our own entries — the user's other cron jobs are never modified.
10
+ - **Setup wizard card** (Step 6, Done): live status pill, one-click Enable/Disable button, default schedule 9am daily, intel-blue accent matching the visual brief tokens. Polls `/api/setup/cron` on step entry; toggle hits `/api/setup/cron POST { action: 'install'|'remove' }`.
11
+ - Cron line writes the absolute `process.execPath` so the scheduled job works regardless of the user's PATH inside cron's minimal environment (a classic foot-gun avoided).
12
+
13
+ Full lifecycle verified: status read → install (`0 9 * * *` line written, crontab confirms) → status reports installed → remove → crontab clean → status reports clean. Smoke 10/10.
14
+
3
15
  ## 1.5.39 (2026-05-27)
4
16
 
5
17
  ### Dashboard — Problems card as the landing surface (Ahrefs-style "what's broken")
package/cli.js CHANGED
@@ -1511,6 +1511,41 @@ program
1511
1511
  if (fired === 0) console.log(chalk.dim(' ✓ No projects need attention.'));
1512
1512
  });
1513
1513
 
1514
+ // ── INSTALL-CRON (one-shot cron entry for daily notify) ────────────────────
1515
+ program
1516
+ .command('install-cron')
1517
+ .description('Install / remove the daily seo-intel notify cron entry (macOS + Linux)')
1518
+ .option('--remove', 'Remove the managed cron entry instead of installing')
1519
+ .option('--schedule <cron>', 'Cron schedule, 5 fields (default: 9am daily)', '0 9 * * *')
1520
+ .option('--open', 'Pass --open to the scheduled notify command (dashboard opens when fired)')
1521
+ .action(async (opts) => {
1522
+ const { installNotifyCron, removeNotifyCron, getNotifyCronStatus, DEFAULT_SCHEDULE } = await import('./lib/cron.js');
1523
+ if (opts.remove) {
1524
+ const r = removeNotifyCron();
1525
+ if (!r.ok) { console.error(chalk.red(`Failed: ${r.error}`)); process.exit(1); }
1526
+ console.log(chalk.green(r.removed ? ' ✓ Removed managed cron entry.' : ' (Nothing to remove.)'));
1527
+ return;
1528
+ }
1529
+ const status = getNotifyCronStatus();
1530
+ if (status.platform === 'win32') {
1531
+ console.log(chalk.yellow(' ⚠ Windows: use Task Scheduler manually.'));
1532
+ console.log(chalk.dim(' Daily task command:'));
1533
+ console.log(chalk.dim(` ${process.execPath} ${join(__dirname, 'cli.js')} notify`));
1534
+ return;
1535
+ }
1536
+ if (status.installed) {
1537
+ console.log(chalk.dim(` Existing entry detected (schedule: ${status.schedule}). Replacing…`));
1538
+ }
1539
+ const result = installNotifyCron({ schedule: opts.schedule, openOnFire: !!opts.open });
1540
+ if (!result.ok) { console.error(chalk.red(`Failed: ${result.error}`)); process.exit(1); }
1541
+ console.log(chalk.green(` ✓ Daily notification scheduled (cron: ${result.schedule})`));
1542
+ console.log(chalk.dim(` Cron line: ${result.line}`));
1543
+ console.log(chalk.dim(` Remove with: seo-intel install-cron --remove`));
1544
+ if (process.platform === 'darwin') {
1545
+ console.log(chalk.dim(` Note: macOS may prompt to authorise cron access on first fire — approve in System Settings if so.`));
1546
+ }
1547
+ });
1548
+
1514
1549
  // ── STATUS ─────────────────────────────────────────────────────────────────
1515
1550
  program
1516
1551
  .command('status')
package/lib/cron.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * lib/cron.js — Install / remove the daily `seo-intel notify` cron entry.
3
+ *
4
+ * The "user forgets to check SEO" defense from v1.5.34's delivery brainstorm.
5
+ * Adds a single managed crontab line tagged with a marker comment so we can
6
+ * find and replace/remove our own entry without touching the user's other
7
+ * cron jobs.
8
+ *
9
+ * macOS + Linux: uses crontab(1). On macOS the first install will prompt the
10
+ * user to approve calendar/automation access via the system permission dialog
11
+ * — that's normal, nothing we can do about it.
12
+ *
13
+ * Windows: returns ok:false with a hint pointing at Task Scheduler. Out of
14
+ * scope for v1.5.40.
15
+ */
16
+
17
+ import { spawnSync } from 'child_process';
18
+ import { fileURLToPath } from 'url';
19
+ import { dirname, join } from 'path';
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url));
22
+ const ROOT = join(__dirname, '..');
23
+ const NODE_BIN = process.execPath;
24
+ const MARKER = '# managed-by-seo-intel';
25
+
26
+ export const DEFAULT_SCHEDULE = '0 9 * * *'; // 9am every day
27
+
28
+ function readCrontab() {
29
+ const r = spawnSync('crontab', ['-l'], { encoding: 'utf8' });
30
+ if (r.status === 0) return r.stdout || '';
31
+ // status !== 0 typically means "no crontab for user yet" — return empty
32
+ return '';
33
+ }
34
+
35
+ function writeCrontab(content) {
36
+ const text = (content || '').replace(/\n*$/, '\n'); // ensure single trailing newline
37
+ const r = spawnSync('crontab', ['-'], { input: text, encoding: 'utf8' });
38
+ if (r.status !== 0) {
39
+ throw new Error(`crontab write failed: ${r.stderr || 'unknown error'}`);
40
+ }
41
+ }
42
+
43
+ function isWindows() { return process.platform === 'win32'; }
44
+
45
+ /**
46
+ * @returns {{ installed: boolean, line: string|null, schedule: string|null, platform: string }}
47
+ */
48
+ export function getNotifyCronStatus() {
49
+ if (isWindows()) return { installed: false, line: null, schedule: null, platform: 'win32' };
50
+ const lines = readCrontab().split('\n').filter(l => l.includes(MARKER));
51
+ if (!lines.length) return { installed: false, line: null, schedule: null, platform: process.platform };
52
+ const line = lines[0];
53
+ // Schedule is the first 5 space-separated fields
54
+ const parts = line.trim().split(/\s+/);
55
+ const schedule = parts.slice(0, 5).join(' ');
56
+ return { installed: true, line, schedule, platform: process.platform };
57
+ }
58
+
59
+ /**
60
+ * Install (or replace) the managed cron line.
61
+ *
62
+ * @param {object} [opts]
63
+ * @param {string} [opts.schedule] Cron schedule, default DEFAULT_SCHEDULE (9am daily)
64
+ * @param {boolean} [opts.openOnFire] Append `--open` flag so the dashboard opens when fired
65
+ * @returns {{ ok: boolean, line?: string, schedule?: string, error?: string, hint?: string }}
66
+ */
67
+ export function installNotifyCron({ schedule = DEFAULT_SCHEDULE, openOnFire = false } = {}) {
68
+ if (isWindows()) {
69
+ return {
70
+ ok: false,
71
+ error: 'Windows not supported — use Task Scheduler manually',
72
+ hint: `Create a daily task running: ${NODE_BIN} "${join(ROOT, 'cli.js')}" notify`,
73
+ };
74
+ }
75
+ // Sanity-check schedule (5 fields, no shell metachars)
76
+ if (!/^[-*\/0-9, ]+$/.test(schedule) || schedule.trim().split(/\s+/).length !== 5) {
77
+ return { ok: false, error: `Invalid cron schedule "${schedule}". Expected 5 fields, e.g. "0 9 * * *".` };
78
+ }
79
+ const cmd = `cd ${ROOT} && ${NODE_BIN} cli.js notify${openOnFire ? ' --open' : ''}`;
80
+ const newLine = `${schedule} ${cmd} ${MARKER}`;
81
+ const current = readCrontab();
82
+ const kept = current.split('\n').filter(l => l && !l.includes(MARKER));
83
+ kept.push(newLine);
84
+ try {
85
+ writeCrontab(kept.join('\n'));
86
+ return { ok: true, line: newLine, schedule };
87
+ } catch (e) {
88
+ return { ok: false, error: e.message };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Remove the managed cron line (if any). Idempotent.
94
+ * @returns {{ ok: boolean, removed: boolean, error?: string }}
95
+ */
96
+ export function removeNotifyCron() {
97
+ if (isWindows()) return { ok: true, removed: false }; // nothing to remove
98
+ const current = readCrontab();
99
+ const before = current.split('\n').filter(Boolean).length;
100
+ const kept = current.split('\n').filter(l => l && !l.includes(MARKER));
101
+ if (kept.length === before) return { ok: true, removed: false };
102
+ try {
103
+ writeCrontab(kept.join('\n'));
104
+ return { ok: true, removed: true };
105
+ } catch (e) {
106
+ return { ok: false, removed: false, error: e.message };
107
+ }
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seo-intel",
3
- "version": "1.5.39",
3
+ "version": "1.5.40",
4
4
  "description": "Local Ahrefs-style SEO competitor intelligence. Crawl → SQLite → cloud analysis.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -128,6 +128,17 @@ export function handleSetupRequest(req, res, url) {
128
128
  return true;
129
129
  }
130
130
 
131
+ // GET /api/setup/cron — read notify-cron install state
132
+ if (path === '/api/setup/cron' && method === 'GET') {
133
+ handleCronStatus(req, res);
134
+ return true;
135
+ }
136
+ // POST /api/setup/cron — install / remove
137
+ if (path === '/api/setup/cron' && method === 'POST') {
138
+ handleCronAction(req, res);
139
+ return true;
140
+ }
141
+
131
142
  // POST /api/setup/save-env — save a key to .env
132
143
  if (path === '/api/setup/save-env' && method === 'POST') {
133
144
  handleSaveEnv(req, res);
@@ -341,6 +352,34 @@ async function handlePingOllama(req, res) {
341
352
  }
342
353
  }
343
354
 
355
+ async function handleCronStatus(req, res) {
356
+ try {
357
+ const { getNotifyCronStatus } = await import('../lib/cron.js');
358
+ jsonResponse(res, getNotifyCronStatus());
359
+ } catch (err) {
360
+ jsonResponse(res, { error: err.message }, 500);
361
+ }
362
+ }
363
+
364
+ async function handleCronAction(req, res) {
365
+ try {
366
+ const body = await readBody(req);
367
+ const { action, schedule, openOnFire } = body || {};
368
+ const { installNotifyCron, removeNotifyCron, getNotifyCronStatus } = await import('../lib/cron.js');
369
+ if (action === 'install') {
370
+ const result = installNotifyCron({ schedule, openOnFire: !!openOnFire });
371
+ jsonResponse(res, { ...result, status: getNotifyCronStatus() });
372
+ } else if (action === 'remove') {
373
+ const result = removeNotifyCron();
374
+ jsonResponse(res, { ...result, status: getNotifyCronStatus() });
375
+ } else {
376
+ jsonResponse(res, { error: 'action must be "install" or "remove"' }, 400);
377
+ }
378
+ } catch (err) {
379
+ jsonResponse(res, { error: err.message }, 500);
380
+ }
381
+ }
382
+
344
383
  async function handleSaveEnv(req, res) {
345
384
  try {
346
385
  const body = await readBody(req);
package/setup/wizard.html CHANGED
@@ -1862,6 +1862,21 @@ input::placeholder {
1862
1862
  <!-- Populated by JS -->
1863
1863
  </table>
1864
1864
 
1865
+ <!-- ── Daily notification scheduler (v1.5.40) ────────────────────────── -->
1866
+ <div id="notifyCronCard" style="margin-top:20px; padding:14px 16px; background:var(--bg-elevated); border:1px solid rgba(59,130,246,0.18); border-radius:var(--radius); display:flex; gap:14px; align-items:center; flex-wrap:wrap;">
1867
+ <i class="fa-solid fa-bell" style="font-size:1rem; color:#3b82f6;"></i>
1868
+ <div style="flex:1; min-width:200px;">
1869
+ <div style="font-size:0.8rem; font-weight:600; color:var(--text-primary); margin-bottom:2px;">Daily problem notifications</div>
1870
+ <div id="notifyCronHint" style="font-size:0.66rem; color:var(--text-muted); line-height:1.4;">
1871
+ Fires a native macOS / Linux notification each morning for projects with pending criticals or warns.
1872
+ </div>
1873
+ </div>
1874
+ <div id="notifyCronStatus" style="font-size:0.66rem; color:var(--text-muted);">…</div>
1875
+ <button id="notifyCronToggleBtn" class="btn btn-sm" onclick="toggleNotifyCron()" style="padding:5px 12px; font-size:0.7rem; min-width:90px;">
1876
+ <i class="fa-solid fa-spinner fa-spin"></i>
1877
+ </button>
1878
+ </div>
1879
+
1865
1880
  <h3 style="font-family:var(--font-display); font-size:0.85rem; color:var(--text-primary); margin: 20px 0 12px; font-weight:600;">
1866
1881
  <i class="fa-solid fa-rocket" style="color:var(--accent-gold); margin-right:6px;"></i> What's Next
1867
1882
  </h3>
@@ -3409,6 +3424,64 @@ input::placeholder {
3409
3424
  }
3410
3425
 
3411
3426
  goToStep(6);
3427
+ loadNotifyCronStatus();
3428
+ };
3429
+
3430
+ // ── Daily notification cron (v1.5.40) ──────────────────────────────────
3431
+ let _cronState = null;
3432
+ async function loadNotifyCronStatus() {
3433
+ const statusEl = document.getElementById('notifyCronStatus');
3434
+ const btn = document.getElementById('notifyCronToggleBtn');
3435
+ const hint = document.getElementById('notifyCronHint');
3436
+ if (!statusEl || !btn) return;
3437
+ try {
3438
+ const s = await API.get('/api/setup/cron');
3439
+ _cronState = s;
3440
+ if (s.platform === 'win32') {
3441
+ statusEl.textContent = 'Windows: use Task Scheduler';
3442
+ btn.disabled = true;
3443
+ btn.innerHTML = 'N/A';
3444
+ hint.textContent = 'Cron auto-install is macOS / Linux only — set up a daily Task Scheduler entry manually.';
3445
+ return;
3446
+ }
3447
+ if (s.installed) {
3448
+ statusEl.innerHTML = `<span style="color:var(--color-success);"><i class="fa-solid fa-check"></i> Active · ${s.schedule}</span>`;
3449
+ btn.innerHTML = '<i class="fa-solid fa-bell-slash"></i> Disable';
3450
+ btn.style.borderColor = 'rgba(244,123,93,0.4)';
3451
+ btn.style.color = '#f47b5d';
3452
+ } else {
3453
+ statusEl.innerHTML = `<span style="color:var(--text-muted);">Not scheduled</span>`;
3454
+ btn.innerHTML = '<i class="fa-solid fa-bell"></i> Enable';
3455
+ btn.style.borderColor = 'rgba(59,130,246,0.4)';
3456
+ btn.style.color = '#3b82f6';
3457
+ }
3458
+ btn.disabled = false;
3459
+ } catch (err) {
3460
+ statusEl.textContent = 'Status check failed';
3461
+ btn.disabled = true;
3462
+ }
3463
+ }
3464
+
3465
+ window.toggleNotifyCron = async function() {
3466
+ const btn = document.getElementById('notifyCronToggleBtn');
3467
+ if (!btn || btn.disabled) return;
3468
+ btn.disabled = true;
3469
+ btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
3470
+ const action = _cronState?.installed ? 'remove' : 'install';
3471
+ try {
3472
+ const r = await API.post('/api/setup/cron', { action, schedule: '0 9 * * *' });
3473
+ if (r.ok === false && r.error) {
3474
+ document.getElementById('notifyCronStatus').textContent = 'Error: ' + r.error;
3475
+ btn.disabled = false;
3476
+ btn.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i> Retry';
3477
+ return;
3478
+ }
3479
+ await loadNotifyCronStatus();
3480
+ } catch (err) {
3481
+ document.getElementById('notifyCronStatus').textContent = 'Error: ' + err.message;
3482
+ btn.disabled = false;
3483
+ btn.innerHTML = '<i class="fa-solid fa-triangle-exclamation"></i> Retry';
3484
+ }
3412
3485
  };
3413
3486
 
3414
3487
  window.copyCli = function() {