cursor-guard 4.5.9 → 4.6.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/ROADMAP.md CHANGED
@@ -3,8 +3,8 @@
3
3
  > 本文档描述 cursor-guard 从 V2 到 V7 的长期演进方向。
4
4
  > 每一代向下兼容,低版本功能永远不废弃。
5
5
  >
6
- > **当前版本**:`V4.5.9`(V4 最终版)
7
- > **文档状态**:`V2` ~ `V4.5.9` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.6.1`
7
+ > **文档状态**:`V2` ~ `V4.6.1` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
8
8
 
9
9
  ## 阅读导航
10
10
 
@@ -465,6 +465,8 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
465
465
  | V4.5.6 | **Bug 修复 + 告警 UX + init 优化**:见下方详细说明 | ✅ |
466
466
  | V4.5.7 | **文件详情 Modal 修复 + Dashboard 端口复用**:见下方详细说明 | ✅ |
467
467
  | V4.5.8 | **Dashboard 版本更新检测 + 一键重启**:见下方详细说明 | ✅ |
468
+ | V4.6.0 | **告警 UX 全面升级**:见下方详细说明 | ✅ |
469
+ | V4.6.1 | **告警历史弹窗化**:见下方详细说明 | ✅ |
468
470
 
469
471
  #### V4.4.1 详细内容
470
472
 
@@ -685,6 +687,29 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
685
687
  | 重启流程 | 前端发 POST `/api/restart` → 显示"正在重启..." → 每 500ms 轮询 `/api/version`(最多 10s)→ 服务就绪后 `location.reload()` 自动刷新页面 |
686
688
  | 双语支持 | 新增 `upgrade.*` 共 8 组 i18n key(banner/dismiss/restartNow/restart/hint/restarting/waiting/failed) |
687
689
 
690
+ #### V4.6.0 详细内容
691
+
692
+ **告警 UX 全面升级**:
693
+
694
+ | 修复项 | 说明 |
695
+ |------|------|
696
+ | 倒计时秒级实时更新 | `state.alertExpiresAt` 在 `renderAlertCard` 时记录过期时间;已有的 1 秒定时器 `updateRefreshDisplay` 中实时更新 `.alert-countdown` 元素,无需额外 `setInterval` |
697
+ | 告警文件详情复制/恢复操作 | 告警详情弹窗现在传入 `commitHash`(从 `alerts.latest.commitHash` 或 `'HEAD'` 回退),使每行文件的"复制恢复命令"按钮正常显示和工作 |
698
+ | 备份过时阈值修正 | 阈值从 `interval * 5 / 60` 分钟改为 `max(interval * 10, 300)` 秒(至少 5 分钟);仅在 watcher 正在运行时才检查过时状态;文案从"stale"改为中性的"Last git backup: X ago" |
699
+ | 告警历史常驻入口 | `buildAlertHistoryHtml()` 提取为独立函数,在活跃告警和无告警两种状态下都渲染"历史 N 条"折叠按钮,告警激活时也能查看历史 |
700
+ | 告警历史 localStorage 持久化 | 新增 `loadAlertHistory()` / `saveAlertHistory()`,使用 `localStorage` key `cursorGuard_alertHistory` 持久化最近 20 条告警历史,刷新页面不丢失 |
701
+
702
+ #### V4.6.1 详细内容
703
+
704
+ **告警历史弹窗化**:
705
+
706
+ | 改动 | 说明 |
707
+ |------|------|
708
+ | 历史按钮改为弹窗触发 | "历史 N 条"按钮点击后打开全屏 Modal(复用 `file-modal`),不再内联折叠展开 |
709
+ | 历史表格化展示 | Modal 内以表格呈现所有历史告警:触发时间、详情、文件类型分布、文件列表按钮 |
710
+ | 嵌套文件详情 | 每条历史告警的"查看文件"按钮可再打开文件详情 Modal,查看具体变更文件列表 |
711
+ | 新增 i18n keys | `alert.col.detail` / `alert.col.breakdown` / `alert.col.files`(中英双语) |
712
+
688
713
  #### V4.5.x 新增配置参考
689
714
 
690
715
  | 字段 | 类型 | 默认值 | 引入版本 | 说明 |
@@ -709,6 +734,15 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
709
734
  }
710
735
  ```
711
736
 
737
+ ### V4.7 规划:IDE 集成(VSCode/Cursor Extension)
738
+
739
+ | 项目 | 说明 |
740
+ |------|------|
741
+ | 定位 | 将 Dashboard 从浏览器迁移到 IDE 内置 WebView,减少上下文切换 |
742
+ | 实现方式 | VSCode Extension + WebView Panel,复用现有 `dashboard/public/` 前端代码 |
743
+ | 核心能力 | IDE 侧边栏入口、状态栏告警指示器、WebView 内嵌 Dashboard、一键快照按钮 |
744
+ | 状态 | 🔮 规划中 |
745
+
712
746
  ### V4 不做的事
713
747
 
714
748
  - 不做自动恢复(恢复永远需要人确认,这是产品底线)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.5.9",
3
+ "version": "4.6.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",
@@ -52,6 +52,9 @@ const I18N = {
52
52
  'alert.col.file': 'File',
53
53
  'alert.col.action': 'Action',
54
54
  'alert.col.changes':'Changes',
55
+ 'alert.col.detail': 'Detail',
56
+ 'alert.col.breakdown': 'Breakdown',
57
+ 'alert.col.files': 'Files',
55
58
  'alert.action.modified': 'Modified',
56
59
  'alert.action.added': 'Added',
57
60
  'alert.action.deleted': 'Deleted',
@@ -276,6 +279,9 @@ const I18N = {
276
279
  'alert.col.file': '文件',
277
280
  'alert.col.action': '操作',
278
281
  'alert.col.changes':'变化量',
282
+ 'alert.col.detail': '详情',
283
+ 'alert.col.breakdown': '文件类型',
284
+ 'alert.col.files': '文件列表',
279
285
  'alert.action.modified': '修改',
280
286
  'alert.action.added': '新增',
281
287
  'alert.action.deleted': '删除',
@@ -470,10 +476,23 @@ const state = {
470
476
  tickTimer: null,
471
477
  lastRefreshAt: null,
472
478
  drawerOpen: null,
473
- alertHistory: [],
479
+ alertHistory: loadAlertHistory(),
480
+ alertExpiresAt: null,
474
481
  };
475
482
 
476
483
  const REFRESH_MS = 15000;
484
+ const ALERT_HISTORY_KEY = 'cursorGuard_alertHistory';
485
+
486
+ function loadAlertHistory() {
487
+ try {
488
+ const raw = localStorage.getItem(ALERT_HISTORY_KEY);
489
+ return raw ? JSON.parse(raw) : [];
490
+ } catch { return []; }
491
+ }
492
+
493
+ function saveAlertHistory() {
494
+ try { localStorage.setItem(ALERT_HISTORY_KEY, JSON.stringify(state.alertHistory)); } catch { /* quota */ }
495
+ }
477
496
 
478
497
  /* ── DOM helpers ──────────────────────────────────────────── */
479
498
 
@@ -737,9 +756,17 @@ async function manualRefresh() {
737
756
 
738
757
  function updateRefreshDisplay() {
739
758
  const el = $('#last-refresh');
740
- if (!el || !state.lastRefreshAt) return;
741
- const sec = Math.floor((Date.now() - state.lastRefreshAt) / 1000);
742
- el.textContent = `${t('topbar.lastRefresh')}: ${sec}s`;
759
+ if (el && state.lastRefreshAt) {
760
+ const sec = Math.floor((Date.now() - state.lastRefreshAt) / 1000);
761
+ el.textContent = `${t('topbar.lastRefresh')}: ${sec}s`;
762
+ }
763
+
764
+ const cdEl = document.querySelector('.alert-countdown');
765
+ if (cdEl && state.alertExpiresAt) {
766
+ const remain = Math.max(0, state.alertExpiresAt - Date.now());
767
+ const s = Math.ceil(remain / 1000);
768
+ cdEl.textContent = s > 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`;
769
+ }
743
770
  }
744
771
 
745
772
  /* ── Rendering: Top bar ───────────────────────────────────── */
@@ -889,33 +916,66 @@ function alertFileBreakdown(files) {
889
916
  return t('alert.breakdown', { added, modified, deleted });
890
917
  }
891
918
 
919
+ function buildAlertHistoryHtml() {
920
+ if (state.alertHistory.length === 0) return '';
921
+ const count = state.alertHistory.length;
922
+ return `
923
+ <div class="alert-history-toggle-wrap">
924
+ <button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-modal>${t('alert.historyCount', { n: count })}</button>
925
+ </div>`;
926
+ }
927
+
928
+ function openAlertHistoryModal() {
929
+ const list = [...state.alertHistory].reverse();
930
+ if (list.length === 0) return;
931
+ $('#file-modal-title').textContent = t('alert.history');
932
+ const body = $('#file-modal-body');
933
+
934
+ const rows = list.map((h, i) => {
935
+ const breakdown = alertFileBreakdown(h.files);
936
+ const fileCount = Array.isArray(h.files) ? h.files.length : 0;
937
+ return `<tr>
938
+ <td class="text-mono">${esc(formatTime(h.timestamp))}</td>
939
+ <td>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</td>
940
+ <td>${breakdown ? esc(breakdown) : '-'}</td>
941
+ <td>${fileCount > 0 ? `<button class="modal-restore-btn" data-history-files="${i}">${t('alert.viewFiles', { n: fileCount })}</button>` : '-'}</td>
942
+ </tr>`;
943
+ }).join('');
944
+
945
+ body.innerHTML = `<table>
946
+ <thead><tr>
947
+ <th>${t('alert.triggered')}</th>
948
+ <th>${t('alert.col.detail')}</th>
949
+ <th>${t('alert.col.breakdown')}</th>
950
+ <th>${t('alert.col.files')}</th>
951
+ </tr></thead>
952
+ <tbody>${rows}</tbody>
953
+ </table>`;
954
+
955
+ body.addEventListener('click', (e) => {
956
+ const btn = e.target.closest('[data-history-files]');
957
+ if (btn) {
958
+ const idx = parseInt(btn.dataset.historyFiles);
959
+ const h = list[idx];
960
+ if (h?.files?.length > 0) {
961
+ const proj = state.pageData?.dashboard?.watcher?.path || '';
962
+ openFileModal(t('modal.alertFiles'), h.files, proj, '');
963
+ }
964
+ }
965
+ });
966
+
967
+ $('#file-modal-overlay').classList.add('active');
968
+ document.body.style.overflow = 'hidden';
969
+ }
970
+
892
971
  function renderAlertCard(alerts) {
893
972
  const el = $('#card-alert');
894
973
  if (!alerts?.active) {
895
- let historyHtml = '';
896
- if (state.alertHistory.length > 0) {
897
- const count = state.alertHistory.length;
898
- const rows = state.alertHistory.slice(-5).reverse().map(h => {
899
- const breakdown = alertFileBreakdown(h.files);
900
- return `<div class="alert-history-row text-sm text-muted">
901
- <span class="alert-history-time">${esc(formatTime(h.timestamp))}</span>
902
- <span>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</span>
903
- ${breakdown ? `<span class="alert-history-breakdown">${esc(breakdown)}</span>` : ''}
904
- <span class="badge badge-expired">${t('alert.expired')}</span>
905
- </div>`;
906
- }).join('');
907
- historyHtml = `
908
- <div class="alert-history-toggle-wrap">
909
- <button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-toggle>${t('alert.historyCount', { n: count })}</button>
910
- </div>
911
- <div class="alert-history alert-history-collapsed">
912
- <div class="alert-history-label text-sm">${t('alert.history')}</div>${rows}
913
- </div>`;
914
- }
974
+ state.alertExpiresAt = null;
915
975
  el.innerHTML = `
916
976
  <div class="card-label">${t('alert.title')}</div>
917
977
  <div class="card-status"><span class="status-dot status-healthy"></span><span>${t('alert.none')}</span></div>
918
- ${historyHtml}
978
+ ${buildAlertHistoryHtml()}
919
979
  `;
920
980
  return;
921
981
  }
@@ -925,10 +985,12 @@ function renderAlertCard(alerts) {
925
985
  if (a.timestamp && !state.alertHistory.some(h => h.timestamp === a.timestamp)) {
926
986
  state.alertHistory.push({ timestamp: a.timestamp, fileCount: a.fileCount, windowSeconds: a.windowSeconds, threshold: a.threshold, expiresAt: a.expiresAt, files: a.files });
927
987
  if (state.alertHistory.length > 20) state.alertHistory = state.alertHistory.slice(-20);
988
+ saveAlertHistory();
928
989
  }
929
990
 
930
991
  const triggeredAt = a.timestamp ? formatTime(a.timestamp) : '-';
931
992
  const expiresAt = a.expiresAt ? new Date(a.expiresAt) : null;
993
+ state.alertExpiresAt = expiresAt ? expiresAt.getTime() : null;
932
994
  const remainMs = expiresAt ? expiresAt.getTime() - Date.now() : 0;
933
995
  const remainSec = Math.max(0, Math.ceil(remainMs / 1000));
934
996
  const remainMin = Math.floor(remainSec / 60);
@@ -956,6 +1018,7 @@ function renderAlertCard(alerts) {
956
1018
  <div class="alert-detail-row alert-suggestion text-sm text-muted">${t('alert.suggestion')}</div>
957
1019
  </div>
958
1020
  ${filesHtml}
1021
+ ${buildAlertHistoryHtml()}
959
1022
  `;
960
1023
  }
961
1024
 
@@ -1547,13 +1610,11 @@ function setupEvents() {
1547
1610
  if (backup) openRestoreDrawer(backup);
1548
1611
  });
1549
1612
 
1550
- // Alert history toggle + file modal (event delegation)
1613
+ // Alert history modal + file modal (event delegation)
1551
1614
  $('#card-alert').addEventListener('click', (e) => {
1552
- const historyToggle = e.target.closest('[data-alert-history-toggle]');
1553
- if (historyToggle) {
1554
- const card = historyToggle.closest('#card-alert');
1555
- const history = card?.querySelector('.alert-history');
1556
- if (history) history.classList.toggle('alert-history-collapsed');
1615
+ const historyBtn = e.target.closest('[data-alert-history-modal]');
1616
+ if (historyBtn) {
1617
+ openAlertHistoryModal();
1557
1618
  return;
1558
1619
  }
1559
1620
  const modalBtn = e.target.closest('[data-alert-files-modal]');
@@ -1562,7 +1623,8 @@ function setupEvents() {
1562
1623
  const files = alerts?.latest?.files || [];
1563
1624
  if (files.length > 0) {
1564
1625
  const proj = state.pageData?.dashboard?.watcher?.path || '';
1565
- openFileModal(t('modal.alertFiles'), files, proj, '');
1626
+ const hash = alerts?.latest?.commitHash || 'HEAD';
1627
+ openFileModal(t('modal.alertFiles'), files, proj, hash);
1566
1628
  }
1567
1629
  return;
1568
1630
  }
@@ -168,12 +168,12 @@ function getDashboard(projectDir) {
168
168
  issues.push(`Disk space low (${status.disk.freeGB} GB free)`);
169
169
  }
170
170
 
171
- if (status.lastBackup.git) {
171
+ if (status.lastBackup.git && status.watcher.running) {
172
172
  const lastTs = new Date(status.lastBackup.git.timestamp).getTime();
173
- const staleMinutes = (Date.now() - lastTs) / 60000;
174
- const staleThreshold = Math.min(cfg.auto_backup_interval_seconds * 5 / 60, 30);
175
- if (staleMinutes > staleThreshold) {
176
- issues.push(`Last git backup is stale (${relativeTime(status.lastBackup.git.timestamp)})`);
173
+ const staleSec = (Date.now() - lastTs) / 1000;
174
+ const staleThreshold = Math.max(cfg.auto_backup_interval_seconds * 10, 300);
175
+ if (staleSec > staleThreshold) {
176
+ issues.push(`Last git backup: ${relativeTime(status.lastBackup.git.timestamp)}`);
177
177
  }
178
178
  }
179
179