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 +12 -0
- package/cli.js +35 -0
- package/lib/cron.js +108 -0
- package/package.json +1 -1
- package/setup/web-routes.js +39 -0
- package/setup/wizard.html +73 -0
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
package/setup/web-routes.js
CHANGED
|
@@ -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() {
|