cursor-guard 4.5.6 → 4.5.8

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.6`(V4 最终版)
7
- > **文档状态**:`V2` ~ `V4.5.6` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
6
+ > **当前版本**:`V4.5.8`(V4 最终版)
7
+ > **文档状态**:`V2` ~ `V4.5.8` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
8
8
 
9
9
  ## 阅读导航
10
10
 
@@ -463,6 +463,8 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
463
463
  | V4.5.3 | **告警历史 UX 优化 + 备份结构化文件表格**:见下方详细说明 | ✅ |
464
464
  | V4.5.4 | **Shadow 硬链接增量优化 + always_watch 强保护模式**:见下方详细说明 | ✅ |
465
465
  | V4.5.6 | **Bug 修复 + 告警 UX + init 优化**:见下方详细说明 | ✅ |
466
+ | V4.5.7 | **文件详情 Modal 修复 + Dashboard 端口复用**:见下方详细说明 | ✅ |
467
+ | V4.5.8 | **Dashboard 版本更新检测**:见下方详细说明 | ✅ |
466
468
 
467
469
  #### V4.4.1 详细内容
468
470
 
@@ -652,6 +654,35 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
652
654
  | `cursor-guard-init` 自动创建配置 | init 流程新增 Step 4/5:若 `.cursor-guard.json` 不存在,自动从 `cursor-guard.example.json` 复制为项目根默认配置。升级场景下保留现有配置 |
653
655
  | `backup_interval_seconds` 兼容别名 | `loadConfig` 支持 `backup_interval_seconds` 作为 `auto_backup_interval_seconds` 的别名(带 deprecation 警告) |
654
656
 
657
+ #### V4.5.7 详细内容
658
+
659
+ **Bug 修复**:
660
+
661
+ | 问题 | 根因 | 修复 |
662
+ |------|------|------|
663
+ | 告警"查看文件详情"Modal 点不开 | 事件处理中 `state.pageData?.alerts` 路径错误,告警数据实际存储在 `state.pageData.dashboard.alerts` | 修正为 `state.pageData?.dashboard?.alerts`,同时修正 projectPath 取值路径 |
664
+
665
+ **Dashboard 服务端口复用(单例模式)**:
666
+
667
+ | 改进 | 说明 |
668
+ |------|------|
669
+ | 模块级单例 `_instance` | `startDashboardServer` 首次调用时创建 HTTP 服务并缓存到 `_instance`(含 server/port/registry/token) |
670
+ | 热加载新项目 | 后续调用检测到 `_instance` 已存在时,不创建新服务,而是调用 `_mergeProjects` 将新路径合并到已有 registry 中 |
671
+ | 去重机制 | `_mergeProjects` 按 `_path.toLowerCase()` 去重,相同项目不重复注册 |
672
+ | 透明生效 | registry 是引用传递,已运行的 HTTP handler 闭包自动读取最新 registry,无需重启服务 |
673
+ | 导出 `getInstance()` | 外部可通过 `getInstance()` 获取当前运行实例的 server/port/registry 信息 |
674
+
675
+ #### V4.5.8 详细内容
676
+
677
+ **Dashboard 版本更新检测**:
678
+
679
+ | 组件 | 说明 |
680
+ |------|------|
681
+ | `/api/version` 端点 | 新增无需 project id 的公共 API。返回 `serverVersion`(进程启动时 require cache 锁定)和 `installedVersion`(实时从磁盘读取 `package.json`),以及 `updateAvailable` 布尔值 |
682
+ | 前端版本检查 | `init()` 完成后异步调用 `checkServerVersion()`,非阻塞,失败静默 |
683
+ | 升级横幅 | `updateAvailable === true` 时,在 topbar 下方滑入黄色横幅,显示版本差异和重启提示。带"如何重启"按钮(展开命令示例)和关闭按钮 |
684
+ | 双语支持 | 新增 `upgrade.banner` / `upgrade.dismiss` / `upgrade.restart` / `upgrade.hint` 四组 i18n key |
685
+
655
686
  #### V4.5.x 新增配置参考
656
687
 
657
688
  | 字段 | 类型 | 默认值 | 引入版本 | 说明 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.5.6",
3
+ "version": "4.5.8",
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,11 @@ 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.restart': 'How to restart',
146
+ 'upgrade.hint': 'Stop the current process (Ctrl+C), then run: cursor-guard-backup --path <dir> --dashboard',
147
+
143
148
  'strategy.git': 'Git',
144
149
  'strategy.shadow': 'Shadow',
145
150
  'strategy.both': 'Both',
@@ -355,6 +360,11 @@ const I18N = {
355
360
  'error.sectionFailed': '此区块加载失败',
356
361
  'empty.noData': '暂无数据',
357
362
 
363
+ 'upgrade.banner': '检测到新版本 {installed}(当前服务: {server}),请重启 Dashboard 服务以加载最新功能',
364
+ 'upgrade.dismiss': '关闭',
365
+ 'upgrade.restart': '如何重启',
366
+ 'upgrade.hint': '停止当前进程 (Ctrl+C),然后运行: cursor-guard-backup --path <目录> --dashboard',
367
+
358
368
  'strategy.git': 'Git',
359
369
  'strategy.shadow': '影子',
360
370
  'strategy.both': '双重',
@@ -1540,10 +1550,10 @@ function setupEvents() {
1540
1550
  }
1541
1551
  const modalBtn = e.target.closest('[data-alert-files-modal]');
1542
1552
  if (modalBtn) {
1543
- const alerts = state.pageData?.alerts;
1553
+ const alerts = state.pageData?.dashboard?.alerts;
1544
1554
  const files = alerts?.latest?.files || [];
1545
1555
  if (files.length > 0) {
1546
- const proj = state.pageData?.status?.config?.path || '';
1556
+ const proj = state.pageData?.dashboard?.watcher?.path || '';
1547
1557
  openFileModal(t('modal.alertFiles'), files, proj, '');
1548
1558
  }
1549
1559
  return;
@@ -1580,6 +1590,38 @@ function setupEvents() {
1580
1590
  });
1581
1591
  }
1582
1592
 
1593
+ /* ── Version Check ────────────────────────────────────────── */
1594
+
1595
+ async function checkServerVersion() {
1596
+ try {
1597
+ const data = await fetchJson('/api/version');
1598
+ if (data.updateAvailable) showUpgradeBanner(data);
1599
+ } catch { /* non-critical */ }
1600
+ }
1601
+
1602
+ function showUpgradeBanner(data) {
1603
+ if ($('#upgrade-banner')) return;
1604
+ const banner = document.createElement('div');
1605
+ banner.id = 'upgrade-banner';
1606
+ banner.className = 'upgrade-banner';
1607
+ banner.innerHTML = `
1608
+ <span class="upgrade-banner-text">${t('upgrade.banner', { installed: esc(data.installedVersion), server: esc(data.serverVersion) })}</span>
1609
+ <button class="upgrade-banner-hint-btn" title="${t('upgrade.hint')}">${t('upgrade.restart')}</button>
1610
+ <button class="upgrade-banner-close" aria-label="${t('upgrade.dismiss')}">&times;</button>
1611
+ `;
1612
+ const topbar = $('#topbar');
1613
+ topbar.parentNode.insertBefore(banner, topbar.nextSibling);
1614
+ banner.querySelector('.upgrade-banner-close').addEventListener('click', () => banner.remove());
1615
+ banner.querySelector('.upgrade-banner-hint-btn').addEventListener('click', () => {
1616
+ const hint = banner.querySelector('.upgrade-banner-hint');
1617
+ if (hint) { hint.remove(); return; }
1618
+ const el = document.createElement('div');
1619
+ el.className = 'upgrade-banner-hint';
1620
+ el.innerHTML = `<code>${t('upgrade.hint')}</code>`;
1621
+ banner.appendChild(el);
1622
+ });
1623
+ }
1624
+
1583
1625
  /* ── Init ─────────────────────────────────────────────────── */
1584
1626
 
1585
1627
  async function init() {
@@ -1595,6 +1637,7 @@ async function init() {
1595
1637
  await loadPageData({ progressive: true });
1596
1638
  renderAll();
1597
1639
  startRefresh();
1640
+ checkServerVersion();
1598
1641
  } catch (e) {
1599
1642
  showGlobalError(e.message);
1600
1643
  }
@@ -118,6 +118,74 @@ 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-hint-btn {
147
+ padding: 3px 10px;
148
+ font-size: 11px;
149
+ border: 1px solid var(--yellow);
150
+ border-radius: var(--radius-sm);
151
+ background: transparent;
152
+ color: var(--yellow);
153
+ cursor: pointer;
154
+ white-space: nowrap;
155
+ transition: all 0.15s;
156
+ }
157
+ .upgrade-banner-hint-btn:hover {
158
+ background: var(--yellow);
159
+ color: var(--bg);
160
+ }
161
+ .upgrade-banner-close {
162
+ background: none;
163
+ border: none;
164
+ color: var(--yellow);
165
+ font-size: 18px;
166
+ cursor: pointer;
167
+ padding: 0 4px;
168
+ opacity: 0.7;
169
+ transition: opacity 0.15s;
170
+ }
171
+ .upgrade-banner-close:hover { opacity: 1; }
172
+ .upgrade-banner-hint {
173
+ width: 100%;
174
+ margin-top: 6px;
175
+ padding: 6px 10px;
176
+ background: rgba(0,0,0,0.2);
177
+ border-radius: var(--radius-sm);
178
+ font-size: 12px;
179
+ }
180
+ .upgrade-banner-hint code {
181
+ font-family: var(--font-mono);
182
+ font-size: 11px;
183
+ color: var(--text-primary);
184
+ }
185
+ .upgrade-banner + main {
186
+ margin-top: 38px;
187
+ }
188
+
121
189
  /* ── Buttons ──────────────────────────────────────────────── */
122
190
 
123
191
  .btn {
@@ -11,6 +11,8 @@ const { runDiagnostics } = require('../lib/core/doctor');
11
11
  const { listBackups, getBackupFiles } = require('../lib/core/backups');
12
12
 
13
13
  const PUBLIC_DIR = path.join(__dirname, 'public');
14
+ const PKG_PATH = path.resolve(__dirname, '..', '..', 'package.json');
15
+ const SERVER_VERSION = require('../../package.json').version;
14
16
  const DEFAULT_PORT = 3120;
15
17
  const MAX_PORT_RETRIES = 10;
16
18
  const ALLOWED_HOSTS = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
@@ -125,6 +127,19 @@ function serveStatic(reqUrl, res, serverToken) {
125
127
  /* ── API routes ─────────────────────────────────────────────── */
126
128
 
127
129
  function handleApi(pathname, query, registry, res) {
130
+ if (pathname === '/api/version') {
131
+ let installedVersion = SERVER_VERSION;
132
+ try {
133
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
134
+ installedVersion = pkg.version;
135
+ } catch { /* fallback to server version */ }
136
+ return json(res, {
137
+ serverVersion: SERVER_VERSION,
138
+ installedVersion,
139
+ updateAvailable: SERVER_VERSION !== installedVersion,
140
+ });
141
+ }
142
+
128
143
  if (pathname === '/api/projects') {
129
144
  const list = [...registry.values()].map(({ id, name, pathLabel }) => ({ id, name, pathLabel }));
130
145
  return json(res, list);
@@ -186,19 +201,47 @@ function handleApi(pathname, query, registry, res) {
186
201
  return notFound(res);
187
202
  }
188
203
 
189
- /* ── Server ─────────────────────────────────────────────────── */
204
+ /* ── Server (singleton) ─────────────────────────────────────── */
205
+
206
+ let _instance = null;
207
+
208
+ function _mergeProjects(registry, paths) {
209
+ const seen = new Set([...registry.values()].map(p => p._path.toLowerCase()));
210
+ let maxIdx = registry.size;
211
+ let added = 0;
212
+ for (const raw of paths) {
213
+ const resolved = path.resolve(raw);
214
+ if (seen.has(resolved.toLowerCase())) continue;
215
+ seen.add(resolved.toLowerCase());
216
+ const id = `p${maxIdx++}`;
217
+ const name = path.basename(resolved) || resolved;
218
+ const label = resolved.length > 50 ? '...' + resolved.slice(-47) : resolved;
219
+ registry.set(id, { id, name, pathLabel: label, _path: resolved });
220
+ added++;
221
+ }
222
+ return added;
223
+ }
190
224
 
191
225
  /**
192
- * Start the dashboard HTTP server.
193
- * Can be called standalone (CLI) or embedded (from watcher).
226
+ * Start the dashboard HTTP server, or hot-add projects to an existing instance.
227
+ * Uses a module-level singleton: subsequent calls reuse the same port and server,
228
+ * merging new project paths into the live registry.
194
229
  *
195
230
  * @param {string[]} paths - Project directories to serve
196
231
  * @param {object} [opts]
197
- * @param {number} [opts.port=3120] - Starting port
232
+ * @param {number} [opts.port=3120] - Starting port (ignored if server already running)
198
233
  * @param {boolean} [opts.silent=false] - Suppress banner output
199
234
  * @returns {Promise<{server: http.Server, port: number, registry: Map}>}
200
235
  */
201
236
  function startDashboardServer(paths, opts = {}) {
237
+ if (_instance) {
238
+ const added = _mergeProjects(_instance.registry, paths);
239
+ if (added > 0 && !opts.silent) {
240
+ console.log(` [dashboard] Hot-added ${added} project(s) — total: ${_instance.registry.size} on port ${_instance.port}`);
241
+ }
242
+ return Promise.resolve(_instance);
243
+ }
244
+
202
245
  const port = opts.port || DEFAULT_PORT;
203
246
  const silent = opts.silent || false;
204
247
  const registry = buildRegistry(paths);
@@ -209,7 +252,6 @@ function startDashboardServer(paths, opts = {}) {
209
252
  let retries = 0;
210
253
 
211
254
  const server = http.createServer((req, res) => {
212
- // DNS rebinding protection: reject unexpected Host headers
213
255
  const host = req.headers.host || '';
214
256
  if (!ALLOWED_HOSTS.test(host)) {
215
257
  res.writeHead(403);
@@ -224,7 +266,6 @@ function startDashboardServer(paths, opts = {}) {
224
266
  try { parsed = new URL(req.url, `http://${host}`); }
225
267
  catch { return notFound(res); }
226
268
 
227
- // API endpoints require per-process token
228
269
  if (parsed.pathname.startsWith('/api/')) {
229
270
  const reqToken = parsed.searchParams.get('token');
230
271
  if (reqToken !== token) {
@@ -249,6 +290,7 @@ function startDashboardServer(paths, opts = {}) {
249
290
 
250
291
  server.on('listening', () => {
251
292
  const addr = server.address();
293
+ _instance = { server, port: addr.port, registry, token };
252
294
  if (!silent) {
253
295
  console.log('');
254
296
  console.log(' Cursor Guard Dashboard');
@@ -260,7 +302,7 @@ function startDashboardServer(paths, opts = {}) {
260
302
  }
261
303
  console.log('');
262
304
  }
263
- resolve({ server, port: addr.port, registry });
305
+ resolve(_instance);
264
306
  });
265
307
 
266
308
  server.listen(currentPort, '127.0.0.1');
@@ -277,4 +319,4 @@ if (require.main === module) {
277
319
  });
278
320
  }
279
321
 
280
- module.exports = { startDashboardServer };
322
+ module.exports = { startDashboardServer, getInstance: () => _instance };