cursor-guard 4.5.7 → 4.5.9

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.7`(V4 最终版)
7
- > **文档状态**:`V2` ~ `V4.5.7` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.5.9`(V4 最终版)
7
+ > **文档状态**:`V2` ~ `V4.5.9` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
8
8
 
9
9
  ## 阅读导航
10
10
 
@@ -464,6 +464,7 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
464
464
  | V4.5.4 | **Shadow 硬链接增量优化 + always_watch 强保护模式**:见下方详细说明 | ✅ |
465
465
  | V4.5.6 | **Bug 修复 + 告警 UX + init 优化**:见下方详细说明 | ✅ |
466
466
  | V4.5.7 | **文件详情 Modal 修复 + Dashboard 端口复用**:见下方详细说明 | ✅ |
467
+ | V4.5.8 | **Dashboard 版本更新检测 + 一键重启**:见下方详细说明 | ✅ |
467
468
 
468
469
  #### V4.4.1 详细内容
469
470
 
@@ -671,6 +672,19 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
671
672
  | 透明生效 | registry 是引用传递,已运行的 HTTP handler 闭包自动读取最新 registry,无需重启服务 |
672
673
  | 导出 `getInstance()` | 外部可通过 `getInstance()` 获取当前运行实例的 server/port/registry 信息 |
673
674
 
675
+ #### V4.5.8 详细内容
676
+
677
+ **Dashboard 版本更新检测 + 一键重启**:
678
+
679
+ | 组件 | 说明 |
680
+ |------|------|
681
+ | `/api/version` 端点 | 返回 `serverVersion`(启动时 require cache)和 `installedVersion`(实时从磁盘读取),以及 `updateAvailable` 布尔值 |
682
+ | `/api/restart` 端点 (POST) | 触发服务热重启:关闭旧 HTTP server → `clearGuardCache()` 清除所有 cursor-guard 模块的 require cache → 更新 `SERVER_VERSION` → 在同端口重启新 server |
683
+ | 懒加载 core deps | `handleApi` 中 `getDashboard`/`runDiagnostics`/`listBackups`/`getBackupFiles` 改为通过 `coreDeps()` 懒加载,重启后自动加载新代码 |
684
+ | 升级横幅 | `updateAvailable === true` 时显示黄色横幅。包含"一键重启"按钮(发送 POST → 轮询等待 → 自动 reload)、"手动重启"按钮(展开命令示例)、关闭按钮 |
685
+ | 重启流程 | 前端发 POST `/api/restart` → 显示"正在重启..." → 每 500ms 轮询 `/api/version`(最多 10s)→ 服务就绪后 `location.reload()` 自动刷新页面 |
686
+ | 双语支持 | 新增 `upgrade.*` 共 8 组 i18n key(banner/dismiss/restartNow/restart/hint/restarting/waiting/failed) |
687
+
674
688
  #### V4.5.x 新增配置参考
675
689
 
676
690
  | 字段 | 类型 | 默认值 | 引入版本 | 说明 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.5.7",
3
+ "version": "4.5.9",
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",
@@ -140,6 +140,15 @@ const I18N = {
140
140
  'error.sectionFailed': 'This section failed to load',
141
141
  'empty.noData': 'No data available',
142
142
 
143
+ 'upgrade.banner': 'New version {installed} available (current server: {server}). Please restart the Dashboard service to load the latest features.',
144
+ 'upgrade.dismiss': 'Dismiss',
145
+ 'upgrade.restartNow': 'Restart Now',
146
+ 'upgrade.restart': 'Manual restart',
147
+ 'upgrade.hint': 'Stop the current process (Ctrl+C), then run: cursor-guard-backup --path <dir> --dashboard',
148
+ 'upgrade.restarting': 'Restarting...',
149
+ 'upgrade.waiting': 'Waiting for server...',
150
+ 'upgrade.failed': 'Restart failed, try manually',
151
+
143
152
  'strategy.git': 'Git',
144
153
  'strategy.shadow': 'Shadow',
145
154
  'strategy.both': 'Both',
@@ -355,6 +364,15 @@ const I18N = {
355
364
  'error.sectionFailed': '此区块加载失败',
356
365
  'empty.noData': '暂无数据',
357
366
 
367
+ 'upgrade.banner': '检测到新版本 {installed}(当前服务: {server}),请重启 Dashboard 服务以加载最新功能',
368
+ 'upgrade.dismiss': '关闭',
369
+ 'upgrade.restartNow': '一键重启',
370
+ 'upgrade.restart': '手动重启',
371
+ 'upgrade.hint': '停止当前进程 (Ctrl+C),然后运行: cursor-guard-backup --path <目录> --dashboard',
372
+ 'upgrade.restarting': '正在重启...',
373
+ 'upgrade.waiting': '等待服务就绪...',
374
+ 'upgrade.failed': '重启失败,请手动重启',
375
+
358
376
  'strategy.git': 'Git',
359
377
  'strategy.shadow': '影子',
360
378
  'strategy.both': '双重',
@@ -1580,6 +1598,75 @@ function setupEvents() {
1580
1598
  });
1581
1599
  }
1582
1600
 
1601
+ /* ── Version Check ────────────────────────────────────────── */
1602
+
1603
+ async function checkServerVersion() {
1604
+ try {
1605
+ const data = await fetchJson('/api/version');
1606
+ if (data.updateAvailable) showUpgradeBanner(data);
1607
+ } catch { /* non-critical */ }
1608
+ }
1609
+
1610
+ function showUpgradeBanner(data) {
1611
+ if ($('#upgrade-banner')) return;
1612
+ const banner = document.createElement('div');
1613
+ banner.id = 'upgrade-banner';
1614
+ banner.className = 'upgrade-banner';
1615
+ banner.innerHTML = `
1616
+ <span class="upgrade-banner-text">${t('upgrade.banner', { installed: esc(data.installedVersion), server: esc(data.serverVersion) })}</span>
1617
+ <button class="upgrade-banner-restart-btn">${t('upgrade.restartNow')}</button>
1618
+ <button class="upgrade-banner-hint-btn">${t('upgrade.restart')}</button>
1619
+ <button class="upgrade-banner-close" aria-label="${t('upgrade.dismiss')}">&times;</button>
1620
+ `;
1621
+ const topbar = $('#topbar');
1622
+ topbar.parentNode.insertBefore(banner, topbar.nextSibling);
1623
+ banner.querySelector('.upgrade-banner-close').addEventListener('click', () => banner.remove());
1624
+ banner.querySelector('.upgrade-banner-hint-btn').addEventListener('click', () => {
1625
+ const hint = banner.querySelector('.upgrade-banner-hint');
1626
+ if (hint) { hint.remove(); return; }
1627
+ const el = document.createElement('div');
1628
+ el.className = 'upgrade-banner-hint';
1629
+ el.innerHTML = `<code>${t('upgrade.hint')}</code>`;
1630
+ banner.appendChild(el);
1631
+ });
1632
+ banner.querySelector('.upgrade-banner-restart-btn').addEventListener('click', () => {
1633
+ restartServer(banner);
1634
+ });
1635
+ }
1636
+
1637
+ async function restartServer(banner) {
1638
+ const btn = banner.querySelector('.upgrade-banner-restart-btn');
1639
+ btn.disabled = true;
1640
+ btn.textContent = t('upgrade.restarting');
1641
+ banner.querySelector('.upgrade-banner-hint-btn').style.display = 'none';
1642
+ banner.querySelector('.upgrade-banner-close').style.display = 'none';
1643
+
1644
+ try {
1645
+ const sep = '/api/restart'.includes('?') ? '&' : '?';
1646
+ const tokenParam = window.__GUARD_TOKEN__ ? `${sep}token=${window.__GUARD_TOKEN__}` : '';
1647
+ await fetch('/api/restart' + tokenParam, { method: 'POST' });
1648
+ } catch { /* server may close connection */ }
1649
+
1650
+ btn.textContent = t('upgrade.waiting');
1651
+ let ready = false;
1652
+ for (let i = 0; i < 20; i++) {
1653
+ await new Promise(r => setTimeout(r, 500));
1654
+ try {
1655
+ const r = await fetch('/api/version' + (window.__GUARD_TOKEN__ ? '?token=' + window.__GUARD_TOKEN__ : ''));
1656
+ if (r.ok) { ready = true; break; }
1657
+ } catch { /* still restarting */ }
1658
+ }
1659
+
1660
+ if (ready) {
1661
+ location.reload();
1662
+ } else {
1663
+ btn.textContent = t('upgrade.failed');
1664
+ btn.disabled = false;
1665
+ banner.querySelector('.upgrade-banner-hint-btn').style.display = '';
1666
+ banner.querySelector('.upgrade-banner-close').style.display = '';
1667
+ }
1668
+ }
1669
+
1583
1670
  /* ── Init ─────────────────────────────────────────────────── */
1584
1671
 
1585
1672
  async function init() {
@@ -1595,6 +1682,7 @@ async function init() {
1595
1682
  await loadPageData({ progressive: true });
1596
1683
  renderAll();
1597
1684
  startRefresh();
1685
+ checkServerVersion();
1598
1686
  } catch (e) {
1599
1687
  showGlobalError(e.message);
1600
1688
  }
@@ -118,6 +118,93 @@ body {
118
118
 
119
119
  #last-refresh { font-size: 12px; white-space: nowrap; opacity: .7; }
120
120
 
121
+ /* ── Upgrade Banner ──────────────────────────────────────── */
122
+
123
+ .upgrade-banner {
124
+ position: fixed;
125
+ top: var(--topbar-h);
126
+ left: 0; right: 0;
127
+ background: var(--yellow-bg);
128
+ border-bottom: 1px solid var(--yellow);
129
+ color: var(--yellow);
130
+ padding: 8px 24px;
131
+ font-size: 13px;
132
+ font-weight: 500;
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 12px;
136
+ z-index: 49;
137
+ animation: slideDown 0.3s ease;
138
+ }
139
+ @keyframes slideDown {
140
+ from { transform: translateY(-100%); opacity: 0; }
141
+ to { transform: translateY(0); opacity: 1; }
142
+ }
143
+ .upgrade-banner-text {
144
+ flex: 1;
145
+ }
146
+ .upgrade-banner-restart-btn {
147
+ padding: 4px 14px;
148
+ font-size: 12px;
149
+ font-weight: 600;
150
+ border: 1px solid var(--yellow);
151
+ border-radius: var(--radius-sm);
152
+ background: var(--yellow);
153
+ color: var(--bg);
154
+ cursor: pointer;
155
+ white-space: nowrap;
156
+ transition: all 0.15s;
157
+ }
158
+ .upgrade-banner-restart-btn:hover {
159
+ filter: brightness(1.1);
160
+ }
161
+ .upgrade-banner-restart-btn:disabled {
162
+ opacity: 0.7;
163
+ cursor: wait;
164
+ }
165
+ .upgrade-banner-hint-btn {
166
+ padding: 3px 10px;
167
+ font-size: 11px;
168
+ border: 1px solid var(--yellow);
169
+ border-radius: var(--radius-sm);
170
+ background: transparent;
171
+ color: var(--yellow);
172
+ cursor: pointer;
173
+ white-space: nowrap;
174
+ transition: all 0.15s;
175
+ }
176
+ .upgrade-banner-hint-btn:hover {
177
+ background: var(--yellow);
178
+ color: var(--bg);
179
+ }
180
+ .upgrade-banner-close {
181
+ background: none;
182
+ border: none;
183
+ color: var(--yellow);
184
+ font-size: 18px;
185
+ cursor: pointer;
186
+ padding: 0 4px;
187
+ opacity: 0.7;
188
+ transition: opacity 0.15s;
189
+ }
190
+ .upgrade-banner-close:hover { opacity: 1; }
191
+ .upgrade-banner-hint {
192
+ width: 100%;
193
+ margin-top: 6px;
194
+ padding: 6px 10px;
195
+ background: rgba(0,0,0,0.2);
196
+ border-radius: var(--radius-sm);
197
+ font-size: 12px;
198
+ }
199
+ .upgrade-banner-hint code {
200
+ font-family: var(--font-mono);
201
+ font-size: 11px;
202
+ color: var(--text-primary);
203
+ }
204
+ .upgrade-banner + main {
205
+ margin-top: 38px;
206
+ }
207
+
121
208
  /* ── Buttons ──────────────────────────────────────────────── */
122
209
 
123
210
  .btn {
@@ -6,11 +6,26 @@ const crypto = require('crypto');
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
8
 
9
- const { getDashboard } = require('../lib/core/dashboard');
10
- const { runDiagnostics } = require('../lib/core/doctor');
11
- const { listBackups, getBackupFiles } = require('../lib/core/backups');
9
+ const GUARD_ROOT = path.resolve(__dirname, '..');
10
+
11
+ function coreDeps() {
12
+ return {
13
+ getDashboard: require('../lib/core/dashboard').getDashboard,
14
+ runDiagnostics: require('../lib/core/doctor').runDiagnostics,
15
+ listBackups: require('../lib/core/backups').listBackups,
16
+ getBackupFiles: require('../lib/core/backups').getBackupFiles,
17
+ };
18
+ }
19
+
20
+ function clearGuardCache() {
21
+ Object.keys(require.cache).forEach(key => {
22
+ if (key.startsWith(GUARD_ROOT)) delete require.cache[key];
23
+ });
24
+ }
12
25
 
13
26
  const PUBLIC_DIR = path.join(__dirname, 'public');
27
+ const PKG_PATH = path.resolve(__dirname, '..', '..', 'package.json');
28
+ let SERVER_VERSION = require('../../package.json').version;
14
29
  const DEFAULT_PORT = 3120;
15
30
  const MAX_PORT_RETRIES = 10;
16
31
  const ALLOWED_HOSTS = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
@@ -124,7 +139,31 @@ function serveStatic(reqUrl, res, serverToken) {
124
139
 
125
140
  /* ── API routes ─────────────────────────────────────────────── */
126
141
 
127
- function handleApi(pathname, query, registry, res) {
142
+ function handleApi(pathname, query, registry, res, req) {
143
+ if (pathname === '/api/version') {
144
+ let installedVersion = SERVER_VERSION;
145
+ try {
146
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
147
+ installedVersion = pkg.version;
148
+ } catch { /* fallback to server version */ }
149
+ return json(res, {
150
+ serverVersion: SERVER_VERSION,
151
+ installedVersion,
152
+ updateAvailable: SERVER_VERSION !== installedVersion,
153
+ });
154
+ }
155
+
156
+ if (pathname === '/api/restart') {
157
+ if (req.method !== 'POST') {
158
+ res.writeHead(405);
159
+ return res.end('Method Not Allowed');
160
+ }
161
+ if (!_instance) return json(res, { error: 'No running instance' }, 500);
162
+ json(res, { restarting: true });
163
+ setTimeout(() => restartDashboard(), 300);
164
+ return;
165
+ }
166
+
128
167
  if (pathname === '/api/projects') {
129
168
  const list = [...registry.values()].map(({ id, name, pathLabel }) => ({ id, name, pathLabel }));
130
169
  return json(res, list);
@@ -139,6 +178,8 @@ function handleApi(pathname, query, registry, res) {
139
178
  return json(res, { error: `Project directory not accessible: ${project.pathLabel}` }, 500);
140
179
  }
141
180
 
181
+ const { getDashboard, runDiagnostics, listBackups, getBackupFiles } = coreDeps();
182
+
142
183
  if (pathname === '/api/page-data') {
143
184
  const scope = query.get('scope');
144
185
  const result = { timestamp: new Date().toISOString() };
@@ -243,7 +284,7 @@ function startDashboardServer(paths, opts = {}) {
243
284
  return res.end('Forbidden: invalid host');
244
285
  }
245
286
 
246
- if (req.method !== 'GET') {
287
+ if (req.method !== 'GET' && req.method !== 'POST') {
247
288
  res.writeHead(405);
248
289
  return res.end('Method Not Allowed');
249
290
  }
@@ -257,8 +298,9 @@ function startDashboardServer(paths, opts = {}) {
257
298
  res.writeHead(403);
258
299
  return res.end('Forbidden: invalid token');
259
300
  }
260
- handleApi(parsed.pathname, parsed.searchParams, registry, res);
301
+ handleApi(parsed.pathname, parsed.searchParams, registry, res, req);
261
302
  } else {
303
+ if (req.method !== 'GET') { res.writeHead(405); return res.end('Method Not Allowed'); }
262
304
  serveStatic(req.url, res, token);
263
305
  }
264
306
  });
@@ -294,6 +336,26 @@ function startDashboardServer(paths, opts = {}) {
294
336
  });
295
337
  }
296
338
 
339
+ /* ── Hot Restart ───────────────────────────────────────────── */
340
+
341
+ async function restartDashboard() {
342
+ if (!_instance) return;
343
+ const paths = [..._instance.registry.values()].map(p => p._path);
344
+ const port = _instance.port;
345
+
346
+ _instance.server.close();
347
+ _instance = null;
348
+
349
+ clearGuardCache();
350
+ try {
351
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
352
+ SERVER_VERSION = pkg.version;
353
+ } catch { /* keep old version */ }
354
+
355
+ console.log(` [dashboard] Restarting on port ${port}...`);
356
+ await startDashboardServer(paths, { port, silent: false });
357
+ }
358
+
297
359
  /* ── CLI entry ─────────────────────────────────────────────── */
298
360
 
299
361
  if (require.main === module) {