cursor-guard 4.4.0 → 4.4.1

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 CHANGED
@@ -380,9 +380,11 @@ The skill activates on these signals:
380
380
  ### v4.4.0 — V4 Final
381
381
 
382
382
  - **Fix**: First snapshot now generates "Added N: file1, file2, ..." summary instead of blank — previously the very first backup had no summary because there was no parent tree to diff against
383
+ - **Feature**: `--dashboard` flag for watcher — `npx cursor-guard-backup --path <dir> --dashboard` starts the web dashboard alongside the watcher in a single process. Optional port: `--dashboard 4000`. Auto-increments if port is busy
383
384
  - **Feature**: Doctor check "Git retention" — warns when git backup commits exceed 500 and `git_retention.enabled` is `false`, guiding users to enable auto-pruning before refs grow unbounded
384
385
  - **Feature**: Doctor check "Backup integrity" — verifies that the latest auto-backup commit's tree object is reachable via `git cat-file -t`, catching silent corruption early
385
386
  - **Improve**: `cursor-guard-init` now detects existing `.cursor-guard.json` and displays an upgrade notice instead of silently overwriting
387
+ - **Improve**: Dashboard server refactored to export `startDashboardServer()` for embedding into other processes
386
388
 
387
389
  ### v4.3.5
388
390
 
package/README.zh-CN.md CHANGED
@@ -380,9 +380,11 @@ node references\dashboard\server.js --path "D:\MyProject"
380
380
  ### v4.4.0 — V4 收官版
381
381
 
382
382
  - **修复**:首次快照现在会生成 "Added N: file1, file2, ..." 摘要,而不是空白——之前第一次备份因为没有 parent tree 对比所以 summary 始终为空
383
+ - **功能**:Watcher `--dashboard` 参数——`npx cursor-guard-backup --path <dir> --dashboard` 启动时同时启动 Web 仪表盘,单进程完成监控+查看。可选端口:`--dashboard 4000`,端口被占自动递增
383
384
  - **功能**:Doctor 新增 "Git retention" 检查——当 Git 备份 commit 数超过 500 且 `git_retention.enabled` 为 `false` 时发出 WARN,引导用户开启自动清理防止 ref 无限增长
384
385
  - **功能**:Doctor 新增 "Backup integrity" 检查——通过 `git cat-file -t` 验证最近一次 auto-backup commit 的 tree 对象是否可达,尽早发现静默损坏
385
386
  - **改进**:`cursor-guard-init` 现在检测已有 `.cursor-guard.json`,显示升级提示而非静默覆盖
387
+ - **改进**:Dashboard server 重构,导出 `startDashboardServer()` 供嵌入其他进程使用
386
388
 
387
389
  ### v4.3.5
388
390
 
package/SKILL.md CHANGED
@@ -147,7 +147,7 @@ When the target file of an edit **falls outside the protected scope**, the agent
147
147
 
148
148
  > **MCP shortcut**: if `snapshot_now` tool is available, call it with `{ "path": "<project>", "strategy": "git" }` instead of the shell commands below. The tool handles temp index, secrets exclusion, and ref creation internally, and returns `{ "git": { "status": "created", "commitHash": "...", "shortHash": "..." } }`. Report the `shortHash` to the user and proceed.
149
149
  >
150
- > **Best practice — intent context**: Always provide `intent` to describe *what operation you are about to perform and why*. This creates an audit trail so the user can later understand "what was the AI doing when this backup was made". Also pass `message` for the commit subject. Example:
150
+ > **Best practice — intent context**: Before making high-risk changes, call `snapshot_now` with `intent` to directly record what you are about to do. The intent is stored in the Git commit trailer no intermediary, no file bridge, no concurrency issues. Example:
151
151
  > ```json
152
152
  > {
153
153
  > "path": "/project",
@@ -158,7 +158,9 @@ When the target file of an edit **falls outside the protected scope**, the agent
158
158
  > "session": "6290c87f"
159
159
  > }
160
160
  > ```
161
- > The `intent`, `agent`, and `session` fields are stored as Git commit trailers and displayed in the dashboard restore-point list and detail drawer, forming a complete audit trail per operation.
161
+ > The `intent`, `agent`, and `session` fields are stored as Git commit trailers and displayed in the dashboard restore-point list and detail drawer.
162
+ >
163
+ > **Timeline the user sees**: manual snapshot with intent ("AI准备重构 calculator.js") → auto-backup with file changes ("Modified 2: src/app.js (+15 -3)"). The causal relationship is clear from ordering — the manual snapshot explains WHY, the auto-backup shows WHAT changed.
162
164
 
163
165
  Use a **temporary index and dedicated ref** so the user's staged/unstaged state is never touched:
164
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.4.0",
3
+ "version": "4.4.1",
4
4
  "description": "Protects code from accidental AI overwrite or deletion in Cursor IDE — mandatory pre-write snapshots, review-before-apply, local Git safety net, and deterministic recovery. | 保护代码免受 Cursor AI 代理意外覆写或删除——强制写前快照、预览再执行、本地 Git 安全网、确定性恢复。",
5
5
  "keywords": [
6
6
  "cursor",
@@ -12,6 +12,7 @@ if (args.help || args.h) {
12
12
  Options:
13
13
  --path <dir> Project directory to watch (default: current dir)
14
14
  --interval <sec> Override backup interval in seconds
15
+ --dashboard [port] Start dashboard server alongside watcher (default port: 3120)
15
16
  --help, -h Show this help message
16
17
  --version, -v Show version number`);
17
18
  process.exit(0);
@@ -27,5 +28,12 @@ const targetPath = args.path || '.';
27
28
  const interval = parseInt(args.interval, 10) || 0;
28
29
  const resolved = path.resolve(targetPath);
29
30
 
31
+ const opts = {};
32
+ if (args.dashboard !== undefined) {
33
+ opts.dashboardPort = (typeof args.dashboard === 'string' && /^\d+$/.test(args.dashboard))
34
+ ? parseInt(args.dashboard, 10)
35
+ : 3120;
36
+ }
37
+
30
38
  const { runBackup } = require('../lib/auto-backup');
31
- runBackup(resolved, interval);
39
+ runBackup(resolved, interval, opts);
@@ -565,7 +565,9 @@ function relativeTime(ts) {
565
565
  /* ── Data fetching ────────────────────────────────────────── */
566
566
 
567
567
  async function fetchJson(url) {
568
- const r = await fetch(url);
568
+ const sep = url.includes('?') ? '&' : '?';
569
+ const tokenParam = window.__GUARD_TOKEN__ ? `${sep}token=${window.__GUARD_TOKEN__}` : '';
570
+ const r = await fetch(url + tokenParam);
569
571
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
570
572
  return r.json();
571
573
  }
@@ -577,10 +579,45 @@ async function loadProjects() {
577
579
  }
578
580
  }
579
581
 
580
- async function loadPageData() {
582
+ async function loadPageData(opts = {}) {
581
583
  if (!state.currentProjectId) return;
582
- state.pageData = await fetchJson(`/api/page-data?id=${state.currentProjectId}`);
583
- state.lastRefreshAt = Date.now();
584
+ const id = state.currentProjectId;
585
+
586
+ if (opts.progressive) {
587
+ state.pageData = { dashboard: null, doctor: null, backups: null };
588
+ const dashPromise = fetchJson(`/api/page-data?id=${id}&scope=dashboard`);
589
+ const restPromise = Promise.allSettled([
590
+ fetchJson(`/api/page-data?id=${id}&scope=backups`),
591
+ fetchJson(`/api/page-data?id=${id}&scope=doctor`),
592
+ ]);
593
+
594
+ const dash = await dashPromise;
595
+ state.pageData.dashboard = dash.dashboard;
596
+ state.lastRefreshAt = Date.now();
597
+ showContent();
598
+ if (dash.dashboard && !dash.dashboard.error) {
599
+ renderStrategyBadge(dash.dashboard.strategy);
600
+ renderOverview(dash.dashboard);
601
+ renderProtection(dash.dashboard.protectionScope);
602
+ }
603
+
604
+ const [backupsResult, doctorResult] = await restPromise;
605
+ if (backupsResult.status === 'fulfilled') {
606
+ state.pageData.backups = backupsResult.value.backups;
607
+ if (state.pageData.dashboard) {
608
+ renderBackupsSection(state.pageData.dashboard, Array.isArray(state.pageData.backups) ? state.pageData.backups : []);
609
+ }
610
+ }
611
+ if (doctorResult.status === 'fulfilled') {
612
+ state.pageData.doctor = doctorResult.value.doctor;
613
+ if (state.pageData.doctor && !state.pageData.doctor.error) {
614
+ renderDiagnostics(state.pageData.doctor);
615
+ }
616
+ }
617
+ } else {
618
+ state.pageData = await fetchJson(`/api/page-data?id=${state.currentProjectId}`);
619
+ state.lastRefreshAt = Date.now();
620
+ }
584
621
  }
585
622
 
586
623
  /* ── Refresh ──────────────────────────────────────────────── */
@@ -633,21 +670,18 @@ function renderStrategyBadge(strategy) {
633
670
 
634
671
  /* ── Rendering: Global states ─────────────────────────────── */
635
672
 
636
- function showLoading() {
637
- show($('#loading-state'));
673
+ function showSkeleton() {
638
674
  hide($('#error-state'));
639
- $$('.screen').forEach(s => hide(s));
675
+ $$('.screen').forEach(s => show(s));
640
676
  }
641
677
 
642
678
  function showGlobalError(msg) {
643
- hide($('#loading-state'));
644
679
  show($('#error-state'));
645
680
  $$('.screen').forEach(s => hide(s));
646
681
  $('#error-message').textContent = msg || t('error.fetchFailed');
647
682
  }
648
683
 
649
684
  function showContent() {
650
- hide($('#loading-state'));
651
685
  hide($('#error-state'));
652
686
  $$('.screen').forEach(s => show(s));
653
687
  }
@@ -819,9 +853,10 @@ function translateSummary(raw) {
819
853
  }
820
854
 
821
855
  function formatSummaryCell(b) {
822
- const line1 = [];
823
- if (b.filesChanged != null) line1.push(`<span class="summary-files">${b.filesChanged} ${t('summary.files')}</span>`);
824
- if (b.trigger) line1.push(`<span class="badge badge-trigger">${t('trigger.' + b.trigger)}</span>`);
856
+ let line1 = '';
857
+ if (b.filesChanged != null) {
858
+ line1 = `<div class="summary-meta"><span class="summary-files">${b.filesChanged} ${t('summary.files')}</span></div>`;
859
+ }
825
860
 
826
861
  let line2 = '';
827
862
  if (b.intent) {
@@ -834,13 +869,12 @@ function formatSummaryCell(b) {
834
869
 
835
870
  let line3 = '';
836
871
  if (b.summary) {
837
- const translated = translateSummary(b.summary);
838
- const short = translated.length > 90 ? translated.substring(0, 87) + '...' : translated;
839
- line3 = `<div class="summary-detail">${esc(short)}</div>`;
872
+ const categories = b.summary.split('; ').map(s => translateSummary(s));
873
+ line3 = categories.map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
840
874
  }
841
875
 
842
- if (!line1.length && !line2 && !line3) return '<span class="text-muted text-sm">-</span>';
843
- return `<div class="summary-stack">${line1.length ? '<div class="summary-meta">' + line1.join(' ') + '</div>' : ''}${line2}${line3}</div>`;
876
+ if (!line1 && !line2 && !line3) return '<span class="text-muted text-sm">-</span>';
877
+ return `<div class="summary-stack">${line1}${line2}${line3}</div>`;
844
878
  }
845
879
 
846
880
  function renderBackupTable(backups) {
@@ -971,7 +1005,10 @@ function openRestoreDrawer(backup) {
971
1005
  if (backup.agent) fields.push({ key: 'drawer.field.agent', val: backup.agent });
972
1006
  if (backup.session) fields.push({ key: 'drawer.field.session', val: backup.session });
973
1007
  if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
974
- if (backup.summary) fields.push({ key: 'drawer.field.summary', val: translateSummary(backup.summary) });
1008
+ if (backup.summary) {
1009
+ const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
1010
+ fields.push({ key: 'drawer.field.summary', val: translated, pre: true });
1011
+ }
975
1012
 
976
1013
  const refText = backup.ref || backup.shortHash || backup.timestamp || '';
977
1014
  const jsonText = JSON.stringify(backup, null, 2);
@@ -980,7 +1017,10 @@ function openRestoreDrawer(backup) {
980
1017
  ${fields.map(f => `
981
1018
  <div class="restore-field">
982
1019
  <div class="restore-field-label">${t(f.key)}</div>
983
- <div class="restore-field-value text-mono">${esc(f.val)}</div>
1020
+ ${f.pre
1021
+ ? `<pre class="restore-field-value text-mono summary-pre">${esc(f.val)}</pre>`
1022
+ : `<div class="restore-field-value text-mono">${esc(f.val)}</div>`
1023
+ }
984
1024
  </div>
985
1025
  `).join('')}
986
1026
  <div class="restore-actions">
@@ -1123,12 +1163,12 @@ async function init() {
1123
1163
  document.documentElement.lang = state.locale === 'zh-CN' ? 'zh-CN' : 'en';
1124
1164
  document.title = t('app.title');
1125
1165
  updateStaticI18n();
1126
- showLoading();
1166
+ showSkeleton();
1127
1167
 
1128
1168
  try {
1129
1169
  await loadProjects();
1130
1170
  renderProjectSelect();
1131
- await loadPageData();
1171
+ await loadPageData({ progressive: true });
1132
1172
  renderAll();
1133
1173
  startRefresh();
1134
1174
  } catch (e) {
@@ -33,12 +33,6 @@
33
33
 
34
34
  <main id="content">
35
35
 
36
- <!-- Loading -->
37
- <div id="loading-state" class="state-panel">
38
- <div class="spinner"></div>
39
- <p data-i18n="state.loading">Loading…</p>
40
- </div>
41
-
42
36
  <!-- Global Error -->
43
37
  <div id="error-state" class="state-panel hidden">
44
38
  <div class="error-icon">⚠</div>
@@ -47,35 +41,35 @@
47
41
  </div>
48
42
 
49
43
  <!-- Screen 1: Overview ───────────────────────────────── -->
50
- <section id="screen-overview" class="screen hidden">
44
+ <section id="screen-overview" class="screen">
51
45
  <h2 class="section-title" data-i18n="overview.title">Overview</h2>
52
46
  <div id="overview-grid" class="card-grid">
53
- <div id="card-health" class="card card-health"></div>
54
- <div id="card-git-backup" class="card"></div>
55
- <div id="card-shadow-backup" class="card"></div>
56
- <div id="card-watcher" class="card"></div>
57
- <div id="card-alert" class="card"></div>
47
+ <div id="card-health" class="card card-health"><div class="skeleton-block"></div></div>
48
+ <div id="card-git-backup" class="card"><div class="skeleton-block"></div></div>
49
+ <div id="card-shadow-backup" class="card"><div class="skeleton-block"></div></div>
50
+ <div id="card-watcher" class="card"><div class="skeleton-block"></div></div>
51
+ <div id="card-alert" class="card"><div class="skeleton-block"></div></div>
58
52
  </div>
59
53
  </section>
60
54
 
61
55
  <!-- Screen 2: Backups & Recovery ─────────────────────── -->
62
- <section id="screen-backups" class="screen hidden">
56
+ <section id="screen-backups" class="screen">
63
57
  <h2 class="section-title" data-i18n="backups.title">Backups &amp; Recovery</h2>
64
- <div id="backup-stats" class="stats-row"></div>
58
+ <div id="backup-stats" class="stats-row"><div class="skeleton-row"></div></div>
65
59
  <div id="backup-filters" class="filter-bar"></div>
66
- <div id="backup-table-wrap" class="table-wrap"></div>
60
+ <div id="backup-table-wrap" class="table-wrap"><div class="skeleton-table"></div></div>
67
61
  </section>
68
62
 
69
63
  <!-- Screen 3: Protection Scope ───────────────────────── -->
70
- <section id="screen-protection" class="screen hidden">
64
+ <section id="screen-protection" class="screen">
71
65
  <h2 class="section-title" data-i18n="protection.title">Protection Scope</h2>
72
- <div id="protection-content"></div>
66
+ <div id="protection-content"><div class="skeleton-block"></div></div>
73
67
  </section>
74
68
 
75
69
  <!-- Screen 4: Diagnostics ────────────────────────────── -->
76
- <section id="screen-diagnostics" class="screen hidden">
70
+ <section id="screen-diagnostics" class="screen">
77
71
  <h2 class="section-title" data-i18n="diagnostics.title">Diagnostics</h2>
78
- <div id="diagnostics-summary"></div>
72
+ <div id="diagnostics-summary"><div class="skeleton-block"></div></div>
79
73
  </section>
80
74
 
81
75
  </main>
@@ -326,38 +326,64 @@ main {
326
326
  .badge-trigger { background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.7rem; border-color: var(--border-subtle); }
327
327
  .badge-intent { background: var(--blue-bg); color: var(--blue); font-size: 0.7rem; border-color: rgba(59,130,246,.18); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: middle; }
328
328
 
329
- .backup-summary-cell { max-width: 400px; }
329
+ .backup-summary-cell { max-width: 420px; min-width: 180px; }
330
330
 
331
- .summary-stack { display: flex; flex-direction: column; gap: 4px; }
332
- .summary-meta { display: flex; align-items: center; gap: 6px; }
333
- .summary-files { font-size: 13px; font-weight: 600; color: var(--text-heading); }
331
+ .summary-stack { display: flex; flex-direction: column; gap: 6px; padding: 4px 0; }
332
+ .summary-meta { display: flex; align-items: center; gap: 8px; }
333
+ .summary-files {
334
+ font-size: 13px;
335
+ font-weight: 700;
336
+ color: var(--text-heading);
337
+ font-variant-numeric: tabular-nums;
338
+ }
334
339
  .summary-intent {
335
340
  font-size: 12px;
336
341
  color: var(--blue);
337
342
  background: var(--blue-bg);
338
- padding: 2px 8px;
339
- border-radius: 3px;
340
- border-left: 2px solid var(--blue);
343
+ padding: 4px 10px;
344
+ border-radius: 4px;
345
+ border-left: 3px solid var(--blue);
341
346
  overflow: hidden;
342
347
  text-overflow: ellipsis;
343
348
  white-space: nowrap;
344
- max-width: 380px;
349
+ max-width: 400px;
350
+ line-height: 1.4;
345
351
  }
346
352
  .summary-message {
347
353
  font-size: 12px;
348
- color: var(--text-secondary);
354
+ color: var(--text-primary);
355
+ background: var(--bg-tertiary);
356
+ padding: 3px 8px;
357
+ border-radius: 3px;
358
+ border-left: 3px solid var(--text-tertiary);
349
359
  overflow: hidden;
350
360
  text-overflow: ellipsis;
351
361
  white-space: nowrap;
352
- max-width: 380px;
362
+ max-width: 400px;
363
+ line-height: 1.4;
353
364
  }
354
- .summary-detail {
355
- font-size: 11px;
356
- color: var(--text-tertiary);
365
+ .summary-detail-line {
366
+ font-size: 12px;
367
+ color: var(--text-secondary);
368
+ font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
369
+ line-height: 1.5;
370
+ padding: 1px 0;
357
371
  overflow: hidden;
358
372
  text-overflow: ellipsis;
359
373
  white-space: nowrap;
360
- max-width: 380px;
374
+ max-width: 400px;
375
+ }
376
+ .summary-detail-line:first-child { padding-top: 2px; }
377
+
378
+ .summary-pre {
379
+ font-size: 12px;
380
+ line-height: 1.6;
381
+ white-space: pre-wrap;
382
+ margin: 0;
383
+ padding: 8px 10px;
384
+ background: var(--bg-tertiary);
385
+ border-radius: 4px;
386
+ border-left: 3px solid var(--blue);
361
387
  }
362
388
 
363
389
  /* ── Stats Row ────────────────────────────────────────────── */
@@ -776,6 +802,54 @@ main {
776
802
 
777
803
  .icon-spin-active { animation: spin .6s linear infinite; }
778
804
 
805
+ /* ── Skeleton loading ────────────────────────────────────── */
806
+
807
+ @keyframes shimmer {
808
+ 0% { background-position: -200% 0; }
809
+ 100% { background-position: 200% 0; }
810
+ }
811
+
812
+ .skeleton-block {
813
+ height: 48px;
814
+ border-radius: var(--radius-sm);
815
+ background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
816
+ background-size: 200% 100%;
817
+ animation: shimmer 1.5s ease-in-out infinite;
818
+ }
819
+
820
+ .skeleton-row {
821
+ display: flex;
822
+ gap: 12px;
823
+ }
824
+ .skeleton-row::before,
825
+ .skeleton-row::after {
826
+ content: '';
827
+ flex: 1;
828
+ height: 64px;
829
+ border-radius: var(--radius-sm);
830
+ background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
831
+ background-size: 200% 100%;
832
+ animation: shimmer 1.5s ease-in-out infinite;
833
+ }
834
+
835
+ .skeleton-table {
836
+ display: flex;
837
+ flex-direction: column;
838
+ gap: 8px;
839
+ padding: 12px 0;
840
+ }
841
+ .skeleton-table::before,
842
+ .skeleton-table::after {
843
+ content: '';
844
+ display: block;
845
+ height: 36px;
846
+ border-radius: var(--radius-sm);
847
+ background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
848
+ background-size: 200% 100%;
849
+ animation: shimmer 1.5s ease-in-out infinite;
850
+ }
851
+ .skeleton-table::after { width: 75%; opacity: .6; }
852
+
779
853
  /* ── Utility ──────────────────────────────────────────────── */
780
854
 
781
855
  .hidden { display: none !important; }
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const http = require('http');
5
+ const crypto = require('crypto');
5
6
  const fs = require('fs');
6
7
  const path = require('path');
7
8
 
@@ -12,6 +13,7 @@ const { listBackups } = require('../lib/core/backups');
12
13
  const PUBLIC_DIR = path.join(__dirname, 'public');
13
14
  const DEFAULT_PORT = 3120;
14
15
  const MAX_PORT_RETRIES = 10;
16
+ const ALLOWED_HOSTS = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
15
17
 
16
18
  const MIME = {
17
19
  '.html': 'text/html; charset=utf-8',
@@ -78,7 +80,7 @@ function forbidden(res) { res.writeHead(403); res.end('Forbidden'); }
78
80
 
79
81
  /* ── Static file server (strict) ────────────────────────────── */
80
82
 
81
- function serveStatic(reqUrl, res) {
83
+ function serveStatic(reqUrl, res, serverToken) {
82
84
  let pathname;
83
85
  try { pathname = decodeURIComponent(new URL(reqUrl, 'http://x').pathname); }
84
86
  catch { return notFound(res); }
@@ -94,6 +96,23 @@ function serveStatic(reqUrl, res) {
94
96
  fs.readFile(resolved, (err, data) => {
95
97
  if (err) return notFound(res);
96
98
  const ext = path.extname(resolved).toLowerCase();
99
+
100
+ // Inject per-process token into index.html so the frontend can authenticate API calls
101
+ if (pathname === '/index.html' && serverToken) {
102
+ const html = data.toString('utf-8').replace(
103
+ '</head>',
104
+ `<script>window.__GUARD_TOKEN__="${serverToken}";</script></head>`
105
+ );
106
+ const buf = Buffer.from(html, 'utf-8');
107
+ res.writeHead(200, {
108
+ 'Content-Type': MIME[ext] || 'text/html; charset=utf-8',
109
+ 'Content-Length': buf.length,
110
+ 'X-Content-Type-Options': 'nosniff',
111
+ 'Cache-Control': 'no-store',
112
+ });
113
+ return res.end(buf);
114
+ }
115
+
97
116
  res.writeHead(200, {
98
117
  'Content-Type': MIME[ext] || 'application/octet-stream',
99
118
  'Content-Length': data.length,
@@ -121,13 +140,21 @@ function handleApi(pathname, query, registry, res) {
121
140
  }
122
141
 
123
142
  if (pathname === '/api/page-data') {
143
+ const scope = query.get('scope');
124
144
  const result = { timestamp: new Date().toISOString() };
125
- try { result.dashboard = getDashboard(pp); }
126
- catch (e) { result.dashboard = { error: e.message }; }
127
- try { result.doctor = runDiagnostics(pp); }
128
- catch (e) { result.doctor = { error: e.message }; }
129
- try { result.backups = listBackups(pp, { limit: 50 }).sources || []; }
130
- catch (e) { result.backups = { error: e.message }; }
145
+
146
+ if (!scope || scope === 'dashboard') {
147
+ try { result.dashboard = getDashboard(pp); }
148
+ catch (e) { result.dashboard = { error: e.message }; }
149
+ }
150
+ if (!scope || scope === 'doctor') {
151
+ try { result.doctor = runDiagnostics(pp); }
152
+ catch (e) { result.doctor = { error: e.message }; }
153
+ }
154
+ if (!scope || scope === 'backups') {
155
+ try { result.backups = listBackups(pp, { limit: 50 }).sources || []; }
156
+ catch (e) { result.backups = { error: e.message }; }
157
+ }
131
158
  return json(res, result);
132
159
  }
133
160
 
@@ -154,53 +181,93 @@ function handleApi(pathname, query, registry, res) {
154
181
 
155
182
  /* ── Server ─────────────────────────────────────────────────── */
156
183
 
157
- function main() {
158
- const args = parseCliArgs();
159
- const registry = buildRegistry(args.paths);
160
- let port = args.port;
161
- let retries = 0;
162
-
163
- const server = http.createServer((req, res) => {
164
- if (req.method !== 'GET') {
165
- res.writeHead(405);
166
- return res.end('Method Not Allowed');
167
- }
168
- let parsed;
169
- try { parsed = new URL(req.url, `http://${req.headers.host || 'localhost'}`); }
170
- catch { return notFound(res); }
171
-
172
- if (parsed.pathname.startsWith('/api/')) {
173
- handleApi(parsed.pathname, parsed.searchParams, registry, res);
174
- } else {
175
- serveStatic(req.url, res);
176
- }
177
- });
184
+ /**
185
+ * Start the dashboard HTTP server.
186
+ * Can be called standalone (CLI) or embedded (from watcher).
187
+ *
188
+ * @param {string[]} paths - Project directories to serve
189
+ * @param {object} [opts]
190
+ * @param {number} [opts.port=3120] - Starting port
191
+ * @param {boolean} [opts.silent=false] - Suppress banner output
192
+ * @returns {Promise<{server: http.Server, port: number, registry: Map}>}
193
+ */
194
+ function startDashboardServer(paths, opts = {}) {
195
+ const port = opts.port || DEFAULT_PORT;
196
+ const silent = opts.silent || false;
197
+ const registry = buildRegistry(paths);
198
+ const token = crypto.randomBytes(16).toString('hex');
199
+
200
+ return new Promise((resolve, reject) => {
201
+ let currentPort = port;
202
+ let retries = 0;
203
+
204
+ const server = http.createServer((req, res) => {
205
+ // DNS rebinding protection: reject unexpected Host headers
206
+ const host = req.headers.host || '';
207
+ if (!ALLOWED_HOSTS.test(host)) {
208
+ res.writeHead(403);
209
+ return res.end('Forbidden: invalid host');
210
+ }
211
+
212
+ if (req.method !== 'GET') {
213
+ res.writeHead(405);
214
+ return res.end('Method Not Allowed');
215
+ }
216
+ let parsed;
217
+ try { parsed = new URL(req.url, `http://${host}`); }
218
+ catch { return notFound(res); }
219
+
220
+ // API endpoints require per-process token
221
+ if (parsed.pathname.startsWith('/api/')) {
222
+ const reqToken = parsed.searchParams.get('token');
223
+ if (reqToken !== token) {
224
+ res.writeHead(403);
225
+ return res.end('Forbidden: invalid token');
226
+ }
227
+ handleApi(parsed.pathname, parsed.searchParams, registry, res);
228
+ } else {
229
+ serveStatic(req.url, res, token);
230
+ }
231
+ });
178
232
 
179
- server.on('error', (err) => {
180
- if (err.code === 'EADDRINUSE' && retries < MAX_PORT_RETRIES) {
181
- retries++;
182
- port++;
183
- server.listen(port, '127.0.0.1');
184
- } else {
185
- console.error('Failed to start:', err.message);
186
- process.exit(1);
187
- }
188
- });
233
+ server.on('error', (err) => {
234
+ if (err.code === 'EADDRINUSE' && retries < MAX_PORT_RETRIES) {
235
+ retries++;
236
+ currentPort++;
237
+ server.listen(currentPort, '127.0.0.1');
238
+ } else {
239
+ reject(err);
240
+ }
241
+ });
189
242
 
190
- server.on('listening', () => {
191
- const addr = server.address();
192
- console.log('');
193
- console.log(' Cursor Guard Dashboard');
194
- console.log(' ─────────────────────────');
195
- console.log(` URL: http://127.0.0.1:${addr.port}`);
196
- console.log(` Projects: ${registry.size}`);
197
- for (const p of registry.values()) {
198
- console.log(` [${p.id}] ${p.name} ${p._path}`);
199
- }
200
- console.log('');
243
+ server.on('listening', () => {
244
+ const addr = server.address();
245
+ if (!silent) {
246
+ console.log('');
247
+ console.log(' Cursor Guard Dashboard');
248
+ console.log(' ─────────────────────────');
249
+ console.log(` URL: http://127.0.0.1:${addr.port}`);
250
+ console.log(` Projects: ${registry.size}`);
251
+ for (const p of registry.values()) {
252
+ console.log(` [${p.id}] ${p.name} → ${p._path}`);
253
+ }
254
+ console.log('');
255
+ }
256
+ resolve({ server, port: addr.port, registry });
257
+ });
258
+
259
+ server.listen(currentPort, '127.0.0.1');
201
260
  });
261
+ }
262
+
263
+ /* ── CLI entry ─────────────────────────────────────────────── */
202
264
 
203
- server.listen(port, '127.0.0.1');
265
+ if (require.main === module) {
266
+ const args = parseCliArgs();
267
+ startDashboardServer(args.paths, { port: args.port }).catch(err => {
268
+ console.error('Failed to start:', err.message);
269
+ process.exit(1);
270
+ });
204
271
  }
205
272
 
206
- main();
273
+ module.exports = { startDashboardServer };
@@ -25,7 +25,7 @@ function isProcessAlive(pid) {
25
25
 
26
26
  // ── Main ────────────────────────────────────────────────────────
27
27
 
28
- async function runBackup(projectDir, intervalOverride) {
28
+ async function runBackup(projectDir, intervalOverride, opts = {}) {
29
29
  const hasGit = gitAvailable();
30
30
  const repo = hasGit && isGitRepo(projectDir);
31
31
  const gDir = repo ? getGitDir(projectDir) : null;
@@ -177,6 +177,18 @@ async function runBackup(projectDir, intervalOverride) {
177
177
  console.log(color.cyan(`[guard] Watching '${projectDir}' every ${interval}s (Ctrl+C to stop)`));
178
178
  console.log(color.cyan(`[guard] Strategy: ${cfg.backup_strategy} | Ref: ${branchRef} | Retention: ${cfg.retention.mode}`));
179
179
  console.log(color.cyan(`[guard] Log: ${logFilePath}`));
180
+
181
+ // Optional embedded dashboard
182
+ if (opts.dashboardPort) {
183
+ try {
184
+ const { startDashboardServer } = require('../dashboard/server');
185
+ const { port } = await startDashboardServer([projectDir], { port: opts.dashboardPort, silent: true });
186
+ console.log(color.cyan(`[guard] Dashboard: http://127.0.0.1:${port}`));
187
+ } catch (e) {
188
+ console.log(color.yellow(`[guard] Dashboard failed to start: ${e.message}`));
189
+ }
190
+ }
191
+
180
192
  console.log('');
181
193
 
182
194
  // Main loop
@@ -313,25 +313,25 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
313
313
  return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'retention disabled' };
314
314
  }
315
315
 
316
- const out = git(['log', branchRef, '--format=%H %aI %cI %s'], { cwd, allowFail: true });
316
+ const RS = '\x1e', US = '\x1f';
317
+ const out = git(['log', branchRef, `--format=%H${US}%aI${US}%cI${US}%s${US}%B${RS}`], { cwd, allowFail: true });
317
318
  if (!out) {
318
319
  return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'no commits on ref' };
319
320
  }
320
321
 
321
- const lines = out.split('\n').filter(Boolean);
322
+ const records = out.split(RS).filter(r => r.trim());
322
323
  const guardCommits = [];
323
- for (const line of lines) {
324
- const firstSpace = line.indexOf(' ');
325
- const secondSpace = line.indexOf(' ', firstSpace + 1);
326
- const thirdSpace = line.indexOf(' ', secondSpace + 1);
327
- const hash = line.substring(0, firstSpace);
328
- const authorDate = line.substring(firstSpace + 1, secondSpace);
329
- const committerDate = line.substring(secondSpace + 1, thirdSpace);
330
- const subject = line.substring(thirdSpace + 1);
324
+ for (const record of records) {
325
+ const fields = record.split(US);
326
+ if (fields.length < 5) continue;
327
+ const hash = fields[0].trim();
328
+ const authorDate = fields[1].trim();
329
+ const committerDate = fields[2].trim();
330
+ const subject = fields[3].trim();
331
+ const fullBody = fields[4].trim();
331
332
  if (subject.startsWith('guard: auto-backup') || subject.startsWith('guard: snapshot')) {
332
- guardCommits.push({ hash, authorDate, committerDate, subject });
333
+ guardCommits.push({ hash, authorDate, committerDate, subject, fullBody });
333
334
  }
334
- // Non-guard commits are silently skipped; continue scanning older history
335
335
  }
336
336
 
337
337
  const total = guardCommits.length;
@@ -373,7 +373,8 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
373
373
  if (!rootTree) {
374
374
  return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'could not resolve root tree' };
375
375
  }
376
- let prevHash = commitTreeWithDate(['commit-tree', rootTree, '-m', toKeep[0].subject], toKeep[0]);
376
+ const msgOf = (c) => c.fullBody || c.subject;
377
+ let prevHash = commitTreeWithDate(['commit-tree', rootTree, '-m', msgOf(toKeep[0])], toKeep[0]);
377
378
  if (!prevHash) {
378
379
  return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'commit-tree failed for root' };
379
380
  }
@@ -383,7 +384,7 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
383
384
  if (!tree) {
384
385
  return { kept: total, pruned: 0, mode, rebuilt: false, reason: `could not resolve tree for commit ${i}` };
385
386
  }
386
- prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', toKeep[i].subject], toKeep[i]);
387
+ prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', msgOf(toKeep[i])], toKeep[i]);
387
388
  if (!prevHash) {
388
389
  return { kept: total, pruned: 0, mode, rebuilt: false, reason: `commit-tree failed at index ${i}` };
389
390
  }
@@ -92,6 +92,11 @@ function runFixes(projectDir, opts = {}) {
92
92
  if (!existingIgnore.includes('.cursor-guard-backup')) {
93
93
  missingPatterns.push('# cursor-guard shadow copies', '.cursor-guard-backup/', '');
94
94
  }
95
+ const nmEntries = ['node_modules/', '.cursor/skills/**/node_modules/'];
96
+ const missingNm = nmEntries.filter(e => !existingIgnore.includes(e));
97
+ if (missingNm.length > 0) {
98
+ missingPatterns.push('# Dependencies', ...missingNm, '');
99
+ }
95
100
  const missingSecrets = initCfg.secrets_patterns.filter(p => !existingIgnore.includes(p));
96
101
  if (missingSecrets.length > 0) {
97
102
  missingPatterns.push('# Secrets (cursor-guard defaults)', ...missingSecrets, '');
@@ -11,10 +11,16 @@ const { createGitSnapshot, formatTimestamp, removeSecretsFromIndex } = require('
11
11
  // ── Path safety ─────────────────────────────────────────────────
12
12
 
13
13
  function validateRelativePath(file) {
14
+ if (!file || typeof file !== 'string') {
15
+ return { valid: false, error: 'file path is required' };
16
+ }
14
17
  const normalized = path.normalize(file).replace(/\\/g, '/');
15
18
  if (path.isAbsolute(normalized) || normalized.startsWith('..')) {
16
19
  return { valid: false, error: 'file path must be relative and within project directory' };
17
20
  }
21
+ if (normalized === '.' || normalized === '') {
22
+ return { valid: false, error: 'file path must target a specific file, not the project root' };
23
+ }
18
24
  return { valid: true, normalized };
19
25
  }
20
26
 
@@ -66,6 +72,10 @@ function restoreFile(projectDir, file, source, opts = {}) {
66
72
  return { status: 'error', restoredFrom: source, error: pathCheck.error };
67
73
  }
68
74
 
75
+ if (isToolPath(pathCheck.normalized)) {
76
+ return { status: 'error', restoredFrom: source, error: `refusing to restore protected path '${pathCheck.normalized}' — use restore_project instead` };
77
+ }
78
+
69
79
  const preserveCurrent = resolvePreserve(projectDir, opts);
70
80
  const repo = isGitRepo(projectDir);
71
81
  const result = { restoredFrom: source };
@@ -299,15 +309,29 @@ function executeProjectRestore(projectDir, source, opts = {}) {
299
309
  cwd: projectDir, stdio: 'pipe',
300
310
  });
301
311
 
302
- // Restore protected paths from HEAD to prevent tool/skill/config downgrade
312
+ // Restore protected paths: keep HEAD state, don't let old snapshots resurrect deleted files
303
313
  const head = git(['rev-parse', 'HEAD'], { cwd: projectDir, allowFail: true });
304
314
  if (head) {
305
- for (const p of ['.cursor/', ...GUARD_CONFIGS]) {
306
- try {
307
- execFileSync('git', ['restore', `--source=HEAD`, '--', p], {
308
- cwd: projectDir, stdio: 'pipe',
309
- });
310
- } catch { /* may not exist in HEAD, that's fine */ }
315
+ const protectedPatterns = ['.cursor/', ...GUARD_CONFIGS];
316
+ for (const p of protectedPatterns) {
317
+ const existsInHead = git(['ls-tree', '--name-only', head, '--', p], { cwd: projectDir, allowFail: true });
318
+ if (existsInHead) {
319
+ try {
320
+ execFileSync('git', ['restore', `--source=HEAD`, '--', p], {
321
+ cwd: projectDir, stdio: 'pipe',
322
+ });
323
+ } catch { /* restore failed, keep whatever is there */ }
324
+ } else {
325
+ // HEAD intentionally doesn't have this path — remove if old snapshot resurrected it
326
+ const fullPath = path.join(projectDir, p);
327
+ try {
328
+ if (p.endsWith('/')) {
329
+ fs.rmSync(fullPath, { recursive: true, force: true });
330
+ } else {
331
+ fs.unlinkSync(fullPath);
332
+ }
333
+ } catch { /* already gone */ }
334
+ }
311
335
  }
312
336
  }
313
337
 
@@ -159,11 +159,29 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
159
159
  const fileName = filePart.split('\t').pop();
160
160
  groups[key].push(fileName);
161
161
  }
162
+
163
+ const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', parentTree, newTree], { cwd, allowFail: true });
164
+ const stats = {};
165
+ if (numstatOut) {
166
+ for (const line of numstatOut.split('\n').filter(Boolean)) {
167
+ const [add, del, ...nameParts] = line.split('\t');
168
+ const fname = nameParts.join('\t');
169
+ if (add !== '-') stats[fname] = `+${add} -${del}`;
170
+ }
171
+ }
172
+
173
+ function fmtFiles(arr) {
174
+ return arr.slice(0, 5).map(f => {
175
+ const s = stats[f];
176
+ return s ? `${f} (${s})` : f;
177
+ }).join(', ');
178
+ }
179
+
162
180
  const parts = [];
163
- if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${groups.M.slice(0, 5).join(', ')}`);
164
- if (groups.A.length) parts.push(`Added ${groups.A.length}: ${groups.A.slice(0, 5).join(', ')}`);
165
- if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${groups.D.slice(0, 5).join(', ')}`);
166
- if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${groups.R.slice(0, 5).join(', ')}`);
181
+ if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${fmtFiles(groups.M)}${groups.M.length > 5 ? ', ...' : ''}`);
182
+ if (groups.A.length) parts.push(`Added ${groups.A.length}: ${fmtFiles(groups.A)}${groups.A.length > 5 ? ', ...' : ''}`);
183
+ if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${fmtFiles(groups.D)}${groups.D.length > 5 ? ', ...' : ''}`);
184
+ if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${fmtFiles(groups.R)}${groups.R.length > 5 ? ', ...' : ''}`);
167
185
  if (parts.length) incrementalSummary = parts.join('; ');
168
186
  }
169
187
  } else {