cursor-guard 4.5.1 → 4.5.4
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 +140 -9
- package/package.json +1 -1
- package/references/dashboard/public/app.js +188 -18
- package/references/dashboard/public/style.css +170 -1
- package/references/dashboard/server.js +8 -1
- package/references/lib/auto-backup.js +5 -2
- package/references/lib/core/anomaly.js +22 -0
- package/references/lib/core/backups.js +62 -1
- package/references/lib/core/snapshot.js +54 -4
- package/references/lib/utils.js +6 -0
- package/references/mcp/server.js +35 -2
package/ROADMAP.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
> 本文档描述 cursor-guard 从 V2 到 V7 的长期演进方向。
|
|
4
4
|
> 每一代向下兼容,低版本功能永远不废弃。
|
|
5
5
|
>
|
|
6
|
-
> **当前版本**:`V4.5.
|
|
7
|
-
> **文档状态**:`V2` ~ `V4.5.
|
|
6
|
+
> **当前版本**:`V4.5.4`(V4 最终版)
|
|
7
|
+
> **文档状态**:`V2` ~ `V4.5.4` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
|
|
8
8
|
|
|
9
9
|
## 阅读导航
|
|
10
10
|
|
|
@@ -459,6 +459,9 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
|
|
|
459
459
|
| V4.4.0 | **V4 收官版**:首次快照 summary(无 parent 时生成 Added N: ...);doctor 新增 Git retention 警告(>500 commits + disabled)和 Backup integrity 校验(`cat-file -t` tree 可达性);`cursor-guard-init` 升级检测(已有配置提示) | ✅ |
|
|
460
460
|
| V4.4.1 | **安全硬化版(5 项审计修复 + UX 优化)**:见下方详细说明 | ✅ |
|
|
461
461
|
| V4.5.0 | **V4 最终版(异常检测修复 + Dashboard 全面升级)**:见下方详细说明 | ✅ 收官 |
|
|
462
|
+
| V4.5.2 | **告警结构化文件列表**:见下方详细说明 | ✅ |
|
|
463
|
+
| V4.5.3 | **告警历史 UX 优化 + 备份结构化文件表格**:见下方详细说明 | ✅ |
|
|
464
|
+
| V4.5.4 | **Shadow 硬链接增量优化 + always_watch 强保护模式**:见下方详细说明 | ✅ |
|
|
462
465
|
|
|
463
466
|
#### V4.4.1 详细内容
|
|
464
467
|
|
|
@@ -512,6 +515,134 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
|
|
|
512
515
|
|
|
513
516
|
> **注**:V4.2 的 Web 仪表盘最初在 V4.0 规划中标记为"不做",但用户需求明确后实施。事实证明只读仪表盘投入产出比合理,且不违反安全原则。
|
|
514
517
|
|
|
518
|
+
#### V4.5.2 详细内容
|
|
519
|
+
|
|
520
|
+
**告警结构化文件列表**:
|
|
521
|
+
|
|
522
|
+
| 层 | 改动 | 说明 |
|
|
523
|
+
|----|------|------|
|
|
524
|
+
| Core | `snapshot.js` 返回 `changedFiles` 数组 | 每项包含 `{ path, action, added, deleted }`,数据来源 `diff-tree --numstat`,按变化量降序排列 |
|
|
525
|
+
| Core | `auto-backup.js` 透传 `changedFiles` | `createGitSnapshot` → `recordChange(tracker, count, changedFiles)` |
|
|
526
|
+
| Core | `anomaly.js` alert 携带 `files` 字段 | 窗口内多次事件的文件按路径去重合并,最多保留 50 条,`saveAlert` 持久化到磁盘 |
|
|
527
|
+
| Dashboard | 告警卡片可展开文件详情表格 | 点击"展开文件详情"显示排序表格(文件路径 / 操作类型 / 变化量),操作类型用彩色 badge 区分(修改=蓝 / 新增=绿 / 删除=红 / 重命名=紫) |
|
|
528
|
+
| Dashboard | i18n 补全 | 新增 8 个双语 key(showFiles / hideFiles / col.file / col.action / col.changes / action.*) |
|
|
529
|
+
|
|
530
|
+
> 22 个文件被删除和 22 个文件被新增的风险完全不同——结构化文件列表让用户一眼判断严重程度。
|
|
531
|
+
|
|
532
|
+
#### V4.5.3 详细内容
|
|
533
|
+
|
|
534
|
+
**告警历史 UX 修复**:
|
|
535
|
+
|
|
536
|
+
| 问题 | 修复 | 实现细节 |
|
|
537
|
+
|------|------|----------|
|
|
538
|
+
| 告警过期后,"无活跃告警"绿色状态下方直接展示历史记录,上面说没事、下面列了两条记录,信息矛盾 | 历史默认完全隐藏,只显示灰色"历史(N 条)"可点击文字,展开后才显示历史列表 | `renderAlertCard` 无活跃告警分支:新增 `alert-history-toggle-btn`(灰色 11px 可点击按钮)+ `.alert-history-collapsed`(CSS `display:none`)。事件委托 `[data-alert-history-toggle]` 绑定在 `#card-alert` 上,toggle `alert-history-collapsed` class |
|
|
539
|
+
|
|
540
|
+
告警卡片状态设计:
|
|
541
|
+
|
|
542
|
+
```
|
|
543
|
+
无告警时:
|
|
544
|
+
✅ 无活跃告警
|
|
545
|
+
历史(2 条) ← 灰色小字,点击展开
|
|
546
|
+
|
|
547
|
+
有告警时:
|
|
548
|
+
⚠ 活跃告警
|
|
549
|
+
13:03:54 触发 · 剩余 1m 53s
|
|
550
|
+
22 个文件在 10 秒内变更(阈值:20)
|
|
551
|
+
[展开文件详情]
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
**备份结构化文件表格**:
|
|
555
|
+
|
|
556
|
+
| 层 | 改动 | 说明 |
|
|
557
|
+
|----|------|------|
|
|
558
|
+
| Core | `backups.js` 新增 `getBackupFiles(projectDir, commitHash)` | 对指定 commit 运行 `diff-tree --numstat + --name-status`,返回 `[{path, action, added, deleted}]`,按变化量降序。支持 rename 解析(`R` 前缀→取 tab 分割的最后一段)。无 parent 时退化为 `ls-tree` 列出全部文件 |
|
|
559
|
+
| Server | `GET /api/backup-files?id=<project>&hash=<commit>` | 懒加载端点,不在 `list_backups` 中批量计算(50 条备份×`diff-tree` 会很慢)。400 校验 `hash` 必填 |
|
|
560
|
+
| Dashboard | `parseSummaryToFiles(summary)` | 解析 summary 文本格式 `"Modified 3: a.js (+2 -1), b.js (+0 -5), ...; Added 2: c.js (+10 -0)"` → `[{path, action, added, deleted}]`。正则匹配 `(Modified|Added|Deleted|Renamed) N:` 段头,逐文件解析 `filename (+N -M)`,自动跳过 `...` 截断标记 |
|
|
561
|
+
| Dashboard | `fetchBackupFiles(hash)` | 调用 `/api/backup-files` 端点,返回完整文件数组。网络失败静默降级(返回空数组) |
|
|
562
|
+
| Dashboard | 备份表 `formatSummaryCell` | 行内 mini 文件表格:`parseSummaryToFiles` 取前 3 个文件,每文件显示路径(mono 字体 11px,`max-width:220px` 省略号截断)+ 操作 badge(彩色)+ `+N -M`。超出显示"等 N 个文件…"(斜体灰色),`N` 取 `filesChanged` 字段(当 summary 有 `...` 截断时)或实际剩余数 |
|
|
563
|
+
| Dashboard | 抽屉 `openRestoreDrawer` | summary 字段不再用 `<pre>` 文本,改为懒加载可排序文件表格。打开抽屉 → `fetchBackupFiles(hash)` → `renderDrawerFilesTable(files, sortKey)`。三列表头均可点击排序(path=字典序 / action=字母序 / changes=变化量降序),当前排序列高亮。API 失败时降级为 `parseSummaryToFiles` 本地解析 |
|
|
564
|
+
| Dashboard | `renderDrawerFilesTable(files, sortKey)` | 可排序表格渲染函数:sticky 表头、340px 最大高度滚动区域、表头带 ↕ 排序指示器、行内复用 `formatFileActionBadge` 统一操作 badge |
|
|
565
|
+
| Dashboard | i18n 补全 | 新增 `summary.andMore`("and {n} more…" / "等 {n} 个文件…")、`alert.historyCount`("History ({n})" / "历史({n} 条)") |
|
|
566
|
+
|
|
567
|
+
> 同一个 `getBackupFiles` 数据结构,告警详情和备份详情两个地方同时受益。备份表行内看 3 个关键文件,抽屉里看完整列表——信息层级清晰,不再一行文本挤 22 个文件名。
|
|
568
|
+
|
|
569
|
+
#### V4.5.4 详细内容
|
|
570
|
+
|
|
571
|
+
**Shadow 硬链接增量优化**:
|
|
572
|
+
|
|
573
|
+
| 层 | 改动 | 说明 |
|
|
574
|
+
|----|------|------|
|
|
575
|
+
| Core | `snapshot.js` `findPreviousSnapshot(backupDir)` | 新增辅助函数。扫描 backupDir 下所有 `YYYYMMDD_HHMMSS` 格式目录,按时间戳降序排列,返回最新的 snapshot 目录路径(`isDirectory` 校验) |
|
|
576
|
+
| Core | `snapshot.js` `createShadowCopy` 硬链接逻辑 | 备份循环中,对每个文件执行增量判断:① 读取源文件 `stat`(size + mtimeMs)② 读取上一个 snapshot 同路径文件 `stat` ③ 若 size 完全一致且 mtimeMs 差值 < 1ms → `fs.linkSync(prevFile, dest)` 硬链接 ④ 否则 → `fs.copyFileSync(src, dest)` + `fs.utimesSync(dest, atime, mtime)` 保留源文件时间戳 |
|
|
577
|
+
| Core | mtime 保留策略 | 每次 `copyFileSync` 后立即 `utimesSync` 将源文件的 mtime 同步到目标。这确保下一次 shadow 备份时,"上一个 snapshot 的文件 mtime" = "当时源文件的 mtime",硬链接比较才有基准。`utimesSync` 失败不阻塞备份(`try-catch` 静默) |
|
|
578
|
+
| Core | 跨卷容错 | `fs.linkSync` 在跨文件系统(如 backupDir 在不同磁盘卷)或 FAT32 分区上会抛 `EXDEV` 错误。外层 `try-catch` 捕获后自动 fall back 到 `copyFileSync`,不影响备份正确性 |
|
|
579
|
+
| Watcher | `auto-backup.js` 日志 | Shadow 日志增加硬链接统计:`Shadow copy 20260322_130000 (150 files [142 hard-linked])`。linkedCount = 0 时不显示 |
|
|
580
|
+
| 返回值 | `createShadowCopy` 新增 `linkedCount` 字段 | 表示本次备份中通过硬链接节省 I/O 的文件数。用于日志、Dashboard 未来可展示 |
|
|
581
|
+
|
|
582
|
+
性能对比:
|
|
583
|
+
|
|
584
|
+
| 场景 | 文件总数 | 变更数 | 传统全量 copy | 硬链接增量 | 磁盘节省 |
|
|
585
|
+
|------|---------|--------|--------------|-----------|---------|
|
|
586
|
+
| 常规开发 | 150 | 8 | 150 次 copy | 8 copy + 142 link | ~95% I/O |
|
|
587
|
+
| 大规模重构 | 150 | 50 | 150 次 copy | 50 copy + 100 link | ~67% I/O |
|
|
588
|
+
| 首次备份 | 150 | 150 | 150 次 copy | 150 次 copy(无上一个 snapshot) | 0%(正常) |
|
|
589
|
+
|
|
590
|
+
> 硬链接在 NTFS(Windows)和 ext4/APFS(Linux/macOS)上均支持。同一 inode 共享磁盘块,150 个文件只改 8 个时磁盘写入降 95%。
|
|
591
|
+
|
|
592
|
+
**always_watch 强保护模式**:
|
|
593
|
+
|
|
594
|
+
| 层 | 改动 | 说明 |
|
|
595
|
+
|----|------|------|
|
|
596
|
+
| Config | `utils.js` DEFAULT_CONFIG | 新增 `always_watch: false` 默认配置项 |
|
|
597
|
+
| Config | `utils.js` loadConfig | 解析 `.cursor-guard.json` 中的 `always_watch` 布尔值。类型校验:非 `true`/`false` 值 → 警告 + 使用默认值 `false` |
|
|
598
|
+
| MCP | `mcp/server.js` `watchedProjects` Map | 进程级 Map,`key = projectPath`,`value = { pid, external }`。追踪已启动/检测到的 watcher,防止重复 spawn |
|
|
599
|
+
| MCP | `mcp/server.js` `ensureWatcher(projectPath)` | 自动 watcher 管理器,执行流程:① `watchedProjects.has(path)` → 已处理过则跳过 ② `loadConfig(path)` → 检查 `always_watch` 是否为 `true` ③ `isWatcherRunning(path)` → 已有外部 watcher 则标记 `external: true` 跳过 ④ `spawn(process.execPath, [cursor-guard-backup, --path, path])` → 创建 detached 子进程(`detached: true, stdio: 'ignore', windowsHide: true`) ⑤ `child.unref()` → 父进程退出不影响 watcher |
|
|
600
|
+
| MCP | 所有 9 个 tool handler | 入口处统一调用 `ensureWatcher(resolved)`。仅首次调用触发实际逻辑,后续调用通过 Map 缓存直接跳过(O(1)) |
|
|
601
|
+
| 安全 | 与现有锁文件机制兼容 | `ensureWatcher` 先检查 `isWatcherRunning`(读取 lock file + `process.kill(pid, 0)` PID 存活检测),不会在已有 watcher 时重复启动。手动启动的 watcher 和 auto-spawn 的 watcher 使用同一个 lock file,互斥保护 |
|
|
602
|
+
|
|
603
|
+
用户配置方式:
|
|
604
|
+
```json
|
|
605
|
+
{
|
|
606
|
+
"always_watch": true
|
|
607
|
+
}
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
两种保护模式对比:
|
|
611
|
+
|
|
612
|
+
| | 轻量模式(默认) | 强保护模式 |
|
|
613
|
+
|---|---|---|
|
|
614
|
+
| 配置 | `always_watch: false`(或不设) | `always_watch: true` |
|
|
615
|
+
| Watcher 启动 | 手动 `cursor-guard-backup` | MCP server 首次 tool 调用自动 spawn |
|
|
616
|
+
| 保护覆盖 | AI 需手动 `snapshot_now` | 全程自动备份,零保护缺口 |
|
|
617
|
+
| 适用场景 | 小项目、低频编辑 | 重要项目、高频 AI 协作 |
|
|
618
|
+
| 资源开销 | 无后台进程 | 后台 watcher 进程(低 CPU,周期性扫描) |
|
|
619
|
+
|
|
620
|
+
> 这个特性直接填补了 V4 最大的架构缺口——"Watcher 停止 = 裸奔"。详见下方"V4 遗留的架构缺口"中该条目已标记为 **已解决**。
|
|
621
|
+
|
|
622
|
+
#### V4.5.x 新增配置参考
|
|
623
|
+
|
|
624
|
+
| 字段 | 类型 | 默认值 | 引入版本 | 说明 |
|
|
625
|
+
|------|------|--------|---------|------|
|
|
626
|
+
| `always_watch` | `boolean` | `false` | V4.5.4 | 强保护模式。设为 `true` 后,MCP server 首次 tool 调用自动启动 watcher 进程 |
|
|
627
|
+
|
|
628
|
+
完整 `.cursor-guard.json` 配置示例(含 V4.5.4 新增项):
|
|
629
|
+
|
|
630
|
+
```json
|
|
631
|
+
{
|
|
632
|
+
"protect": ["src/**", "*.config.js"],
|
|
633
|
+
"ignore": ["node_modules/**", "dist/**"],
|
|
634
|
+
"backup_strategy": "git",
|
|
635
|
+
"auto_backup_interval_seconds": 60,
|
|
636
|
+
"always_watch": true,
|
|
637
|
+
"proactive_alert": true,
|
|
638
|
+
"alert_thresholds": {
|
|
639
|
+
"files_per_window": 20,
|
|
640
|
+
"window_seconds": 10,
|
|
641
|
+
"cooldown_seconds": 60
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
515
646
|
### V4 不做的事
|
|
516
647
|
|
|
517
648
|
- 不做自动恢复(恢复永远需要人确认,这是产品底线)
|
|
@@ -522,13 +653,13 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
|
|
|
522
653
|
|
|
523
654
|
通过 V4.4.1 的安全审计和真实场景测试,发现以下架构层面的保护缺口。这些不是代码 bug,而是设计边界:
|
|
524
655
|
|
|
525
|
-
| 缺口 | 现状 | 影响 | V5 改进方向 |
|
|
526
|
-
|
|
527
|
-
|
|
|
528
|
-
| **保护依赖 AI 自觉** | SKILL.md 要求 AI 在写入前 snapshot,但没有强制机制 | AI 不遵守协议就直接写,保护形同虚设 | **embedded watcher + `begin_edit` 意图绑定**:MCP server 内嵌 watcher 循环 + 按文件路径匹配 intent,消除进程边界和并发竞争(详见 V5 设计) |
|
|
529
|
-
| **自动备份无意图上下文** | auto-backup 只有 `trigger: auto`,不知道是谁改的、为什么改 | 事后回溯只能看到时间点快照,不知道操作意图 | **`begin_edit` → 文件路径绑定**:AI 编辑前声明意图和目标文件,embedded watcher 检测变更时按路径匹配 intent,自动备份也能带上下文 |
|
|
530
|
-
| **无跨进程写拦截** | 当前 MCP 架构下无法拦截 Cursor 编辑器的文件写入 | 只能在写后检测,不能写前阻止 | 等待 MCP 协议支持 `notification` / `resource subscription`,或探索 fs watch + pre-commit 组合 |
|
|
531
|
-
| **意图队列并发问题** | 曾考虑"意图队列"(AI 写文件 → watcher 读文件关联 intent),但存在 4 类并发竞态:多 Agent 竞争、意图-变更错位、意图堆积、空意图残留 | 文件 I/O 跨进程 + 时间顺序绑定 = 不可靠 | **同进程内存 Map + 文件路径绑定**:消灭进程边界后无 IPC,按路径而非时间匹配消除歧义(详见 V5 设计) |
|
|
656
|
+
| 缺口 | 现状 | 影响 | V5 改进方向 | 状态 |
|
|
657
|
+
|------|------|------|------------|------|
|
|
658
|
+
| ~~**Watcher 停止 = 裸奔**~~ | ~~Watcher 不运行期间无自动备份~~ | ~~变更永久丢失~~ | ~~`always_watch` 配置项~~ | ✅ **V4.5.4 已解决**:`always_watch: true` 时 MCP server 首次 tool 调用自动 spawn watcher 进程,与现有锁文件互斥机制兼容 |
|
|
659
|
+
| **保护依赖 AI 自觉** | SKILL.md 要求 AI 在写入前 snapshot,但没有强制机制 | AI 不遵守协议就直接写,保护形同虚设 | **embedded watcher + `begin_edit` 意图绑定**:MCP server 内嵌 watcher 循环 + 按文件路径匹配 intent,消除进程边界和并发竞争(详见 V5 设计) | 🔮 V5 |
|
|
660
|
+
| **自动备份无意图上下文** | auto-backup 只有 `trigger: auto`,不知道是谁改的、为什么改 | 事后回溯只能看到时间点快照,不知道操作意图 | **`begin_edit` → 文件路径绑定**:AI 编辑前声明意图和目标文件,embedded watcher 检测变更时按路径匹配 intent,自动备份也能带上下文 | 🔮 V5 |
|
|
661
|
+
| **无跨进程写拦截** | 当前 MCP 架构下无法拦截 Cursor 编辑器的文件写入 | 只能在写后检测,不能写前阻止 | 等待 MCP 协议支持 `notification` / `resource subscription`,或探索 fs watch + pre-commit 组合 | 🔮 V5+ |
|
|
662
|
+
| **意图队列并发问题** | 曾考虑"意图队列"(AI 写文件 → watcher 读文件关联 intent),但存在 4 类并发竞态:多 Agent 竞争、意图-变更错位、意图堆积、空意图残留 | 文件 I/O 跨进程 + 时间顺序绑定 = 不可靠 | **同进程内存 Map + 文件路径绑定**:消灭进程边界后无 IPC,按路径而非时间匹配消除歧义(详见 V5 设计) | 🔮 V5 |
|
|
532
663
|
|
|
533
664
|
### 进入 V5 的衡量标准
|
|
534
665
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-guard",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.4",
|
|
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",
|
|
@@ -46,6 +46,16 @@ const I18N = {
|
|
|
46
46
|
'alert.expired': 'Expired',
|
|
47
47
|
'alert.history': 'Recent Alert History',
|
|
48
48
|
'alert.noHistory': 'No alert history',
|
|
49
|
+
'alert.historyCount':'History ({n})',
|
|
50
|
+
'alert.showFiles': 'Show file details',
|
|
51
|
+
'alert.hideFiles': 'Hide file details',
|
|
52
|
+
'alert.col.file': 'File',
|
|
53
|
+
'alert.col.action': 'Action',
|
|
54
|
+
'alert.col.changes':'Changes',
|
|
55
|
+
'alert.action.modified': 'Modified',
|
|
56
|
+
'alert.action.added': 'Added',
|
|
57
|
+
'alert.action.deleted': 'Deleted',
|
|
58
|
+
'alert.action.renamed': 'Renamed',
|
|
49
59
|
|
|
50
60
|
'backups.gitCommits': 'Git Commits',
|
|
51
61
|
'backups.shadowSnapshots': 'Shadow Snapshots',
|
|
@@ -107,6 +117,7 @@ const I18N = {
|
|
|
107
117
|
'summary.deleted': 'Deleted',
|
|
108
118
|
'summary.renamed': 'Renamed',
|
|
109
119
|
'summary.files': 'files',
|
|
120
|
+
'summary.andMore': 'and {n} more…',
|
|
110
121
|
'drawer.field.intent': 'Intent',
|
|
111
122
|
'drawer.field.agent': 'Agent',
|
|
112
123
|
'drawer.field.session': 'Session',
|
|
@@ -243,6 +254,16 @@ const I18N = {
|
|
|
243
254
|
'alert.expired': '已过期',
|
|
244
255
|
'alert.history': '近期告警历史',
|
|
245
256
|
'alert.noHistory': '暂无告警记录',
|
|
257
|
+
'alert.historyCount':'历史({n} 条)',
|
|
258
|
+
'alert.showFiles': '展开文件详情',
|
|
259
|
+
'alert.hideFiles': '收起文件详情',
|
|
260
|
+
'alert.col.file': '文件',
|
|
261
|
+
'alert.col.action': '操作',
|
|
262
|
+
'alert.col.changes':'变化量',
|
|
263
|
+
'alert.action.modified': '修改',
|
|
264
|
+
'alert.action.added': '新增',
|
|
265
|
+
'alert.action.deleted': '删除',
|
|
266
|
+
'alert.action.renamed': '重命名',
|
|
246
267
|
|
|
247
268
|
'backups.gitCommits': 'Git 提交数',
|
|
248
269
|
'backups.shadowSnapshots': '影子快照',
|
|
@@ -304,6 +325,7 @@ const I18N = {
|
|
|
304
325
|
'summary.deleted': '删除',
|
|
305
326
|
'summary.renamed': '重命名',
|
|
306
327
|
'summary.files': '个文件',
|
|
328
|
+
'summary.andMore': '等 {n} 个文件…',
|
|
307
329
|
'drawer.field.intent': '操作意图',
|
|
308
330
|
'drawer.field.agent': 'AI 模型',
|
|
309
331
|
'drawer.field.session': '会话 ID',
|
|
@@ -829,6 +851,7 @@ function renderAlertCard(alerts) {
|
|
|
829
851
|
if (!alerts?.active) {
|
|
830
852
|
let historyHtml = '';
|
|
831
853
|
if (state.alertHistory.length > 0) {
|
|
854
|
+
const count = state.alertHistory.length;
|
|
832
855
|
const rows = state.alertHistory.slice(-5).reverse().map(h =>
|
|
833
856
|
`<div class="alert-history-row text-sm text-muted">
|
|
834
857
|
<span>${esc(formatTime(h.timestamp))}</span>
|
|
@@ -836,7 +859,13 @@ function renderAlertCard(alerts) {
|
|
|
836
859
|
<span class="badge badge-expired">${t('alert.expired')}</span>
|
|
837
860
|
</div>`
|
|
838
861
|
).join('');
|
|
839
|
-
historyHtml =
|
|
862
|
+
historyHtml = `
|
|
863
|
+
<div class="alert-history-toggle-wrap">
|
|
864
|
+
<button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-toggle>${t('alert.historyCount', { n: count })}</button>
|
|
865
|
+
</div>
|
|
866
|
+
<div class="alert-history alert-history-collapsed">
|
|
867
|
+
<div class="alert-history-label text-sm">${t('alert.history')}</div>${rows}
|
|
868
|
+
</div>`;
|
|
840
869
|
}
|
|
841
870
|
el.innerHTML = `
|
|
842
871
|
<div class="card-label">${t('alert.title')}</div>
|
|
@@ -849,7 +878,7 @@ function renderAlertCard(alerts) {
|
|
|
849
878
|
|
|
850
879
|
// Track in history
|
|
851
880
|
if (a.timestamp && !state.alertHistory.some(h => h.timestamp === a.timestamp)) {
|
|
852
|
-
state.alertHistory.push({ timestamp: a.timestamp, fileCount: a.fileCount, windowSeconds: a.windowSeconds, threshold: a.threshold, expiresAt: a.expiresAt });
|
|
881
|
+
state.alertHistory.push({ timestamp: a.timestamp, fileCount: a.fileCount, windowSeconds: a.windowSeconds, threshold: a.threshold, expiresAt: a.expiresAt, files: a.files });
|
|
853
882
|
if (state.alertHistory.length > 20) state.alertHistory = state.alertHistory.slice(-20);
|
|
854
883
|
}
|
|
855
884
|
|
|
@@ -861,6 +890,32 @@ function renderAlertCard(alerts) {
|
|
|
861
890
|
const remainDisplay = remainMin > 0 ? `${remainMin}m ${remainSec % 60}s` : `${remainSec}s`;
|
|
862
891
|
const detailText = t('alert.detail', { count: a.fileCount || '?', window: a.windowSeconds || '?', threshold: a.threshold || '?' });
|
|
863
892
|
|
|
893
|
+
const files = Array.isArray(a.files) ? a.files : [];
|
|
894
|
+
let filesHtml = '';
|
|
895
|
+
if (files.length > 0) {
|
|
896
|
+
const actionBadge = (action) => {
|
|
897
|
+
const cls = action === 'deleted' ? 'alert-action-deleted'
|
|
898
|
+
: action === 'added' ? 'alert-action-added'
|
|
899
|
+
: action === 'renamed' ? 'alert-action-renamed'
|
|
900
|
+
: 'alert-action-modified';
|
|
901
|
+
return `<span class="alert-action-badge ${cls}">${t('alert.action.' + action)}</span>`;
|
|
902
|
+
};
|
|
903
|
+
const rows = files.map(f =>
|
|
904
|
+
`<tr><td class="text-mono alert-file-path">${esc(f.path)}</td><td>${actionBadge(f.action)}</td><td class="text-mono alert-file-changes">+${f.added || 0} -${f.deleted || 0}</td></tr>`
|
|
905
|
+
).join('');
|
|
906
|
+
filesHtml = `
|
|
907
|
+
<div class="alert-files-section">
|
|
908
|
+
<button class="alert-files-toggle" data-alert-files-toggle>${t('alert.showFiles')}</button>
|
|
909
|
+
<div class="alert-files-table-wrap alert-files-hidden">
|
|
910
|
+
<table class="alert-files-table">
|
|
911
|
+
<thead><tr><th>${t('alert.col.file')}</th><th>${t('alert.col.action')}</th><th>${t('alert.col.changes')}</th></tr></thead>
|
|
912
|
+
<tbody>${rows}</tbody>
|
|
913
|
+
</table>
|
|
914
|
+
</div>
|
|
915
|
+
</div>
|
|
916
|
+
`;
|
|
917
|
+
}
|
|
918
|
+
|
|
864
919
|
el.innerHTML = `
|
|
865
920
|
<div class="card-label">${t('alert.title')}</div>
|
|
866
921
|
<div class="card-status"><span class="status-dot status-warning"></span><span class="status-text status-warning">${t('alert.active')}</span></div>
|
|
@@ -869,6 +924,7 @@ function renderAlertCard(alerts) {
|
|
|
869
924
|
<div class="alert-detail-row"><span class="alert-detail-label">${t('alert.expires')}</span><span class="alert-countdown">${esc(remainDisplay)}</span></div>
|
|
870
925
|
<div class="alert-detail-row alert-numbers">${esc(detailText)}</div>
|
|
871
926
|
</div>
|
|
927
|
+
${filesHtml}
|
|
872
928
|
`;
|
|
873
929
|
}
|
|
874
930
|
|
|
@@ -935,6 +991,49 @@ function translateSummary(raw) {
|
|
|
935
991
|
.replace(/\bRenamed (\d+)/g, (_, n) => `${t('summary.renamed')} ${n}`);
|
|
936
992
|
}
|
|
937
993
|
|
|
994
|
+
/**
|
|
995
|
+
* Parse summary text into structured file array for inline preview.
|
|
996
|
+
* Format: "Modified 3: a.js (+2 -1), b.js (+0 -5), ...; Added 2: c.js (+10 -0), d.js (+3 -0)"
|
|
997
|
+
*/
|
|
998
|
+
function parseSummaryToFiles(summary) {
|
|
999
|
+
if (!summary) return [];
|
|
1000
|
+
const ACTION_MAP = { Modified: 'modified', Added: 'added', Deleted: 'deleted', Renamed: 'renamed' };
|
|
1001
|
+
const files = [];
|
|
1002
|
+
for (const segment of summary.split('; ')) {
|
|
1003
|
+
const headerMatch = segment.match(/^(Modified|Added|Deleted|Renamed)\s+\d+:\s*/);
|
|
1004
|
+
if (!headerMatch) continue;
|
|
1005
|
+
const action = ACTION_MAP[headerMatch[1]] || 'modified';
|
|
1006
|
+
const rest = segment.slice(headerMatch[0].length);
|
|
1007
|
+
for (const part of rest.split(/,\s*/)) {
|
|
1008
|
+
if (part === '...') continue;
|
|
1009
|
+
const fileMatch = part.match(/^(.+?)\s*\(\+(\d+)\s+-(\d+)\)$/);
|
|
1010
|
+
if (fileMatch) {
|
|
1011
|
+
files.push({ path: fileMatch[1], action, added: parseInt(fileMatch[2], 10), deleted: parseInt(fileMatch[3], 10) });
|
|
1012
|
+
} else if (part.trim()) {
|
|
1013
|
+
files.push({ path: part.trim(), action, added: 0, deleted: 0 });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
files.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
|
|
1018
|
+
return files;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function fetchBackupFiles(commitHash) {
|
|
1022
|
+
if (!state.currentProjectId || !commitHash) return [];
|
|
1023
|
+
try {
|
|
1024
|
+
const data = await fetchJson(`/api/backup-files?id=${state.currentProjectId}&hash=${commitHash}`);
|
|
1025
|
+
return Array.isArray(data.files) ? data.files : [];
|
|
1026
|
+
} catch { return []; }
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function formatFileActionBadge(action) {
|
|
1030
|
+
const cls = action === 'deleted' ? 'alert-action-deleted'
|
|
1031
|
+
: action === 'added' ? 'alert-action-added'
|
|
1032
|
+
: action === 'renamed' ? 'alert-action-renamed'
|
|
1033
|
+
: 'alert-action-modified';
|
|
1034
|
+
return `<span class="alert-action-badge ${cls}">${t('alert.action.' + action)}</span>`;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
938
1037
|
function formatSummaryCell(b) {
|
|
939
1038
|
let line1 = '';
|
|
940
1039
|
if (b.filesChanged != null) {
|
|
@@ -955,15 +1054,20 @@ function formatSummaryCell(b) {
|
|
|
955
1054
|
|
|
956
1055
|
let line3 = '';
|
|
957
1056
|
if (b.summary) {
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1057
|
+
const parsed = parseSummaryToFiles(b.summary);
|
|
1058
|
+
if (parsed.length > 0) {
|
|
1059
|
+
const MAX_INLINE = 3;
|
|
1060
|
+
const visible = parsed.slice(0, MAX_INLINE).map(f =>
|
|
1061
|
+
`<div class="summary-file-row"><span class="text-mono summary-file-path">${esc(f.path)}</span>${formatFileActionBadge(f.action)}<span class="text-mono text-muted">+${f.added} -${f.deleted}</span></div>`
|
|
1062
|
+
).join('');
|
|
1063
|
+
const remaining = parsed.length > MAX_INLINE ? parsed.length - MAX_INLINE : 0;
|
|
1064
|
+
const truncated = b.summary.includes('...');
|
|
1065
|
+
const moreCount = truncated ? (b.filesChanged || '?') - MAX_INLINE : remaining;
|
|
1066
|
+
const moreHtml = (remaining > 0 || truncated) ? `<div class="summary-file-more text-sm text-muted">${t('summary.andMore', { n: moreCount > 0 ? moreCount : '…' })}</div>` : '';
|
|
1067
|
+
line3 = visible + moreHtml;
|
|
962
1068
|
} else {
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
const more = categories.length - MAX_VISIBLE;
|
|
966
|
-
line3 = `${visible}<div class="summary-collapsed" data-summary-toggle>${hidden}</div><button class="summary-toggle-btn" data-summary-toggle>+${more} more</button>`;
|
|
1069
|
+
const categories = b.summary.split('; ').map(s => translateSummary(s));
|
|
1070
|
+
line3 = categories.slice(0, 2).map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
|
|
967
1071
|
}
|
|
968
1072
|
}
|
|
969
1073
|
|
|
@@ -1095,6 +1199,24 @@ function closeDrawer() {
|
|
|
1095
1199
|
state.drawerOpen = null;
|
|
1096
1200
|
}
|
|
1097
1201
|
|
|
1202
|
+
function renderDrawerFilesTable(files, sortKey) {
|
|
1203
|
+
const sorted = [...files];
|
|
1204
|
+
if (sortKey === 'path') sorted.sort((a, b) => a.path.localeCompare(b.path));
|
|
1205
|
+
else if (sortKey === 'action') sorted.sort((a, b) => a.action.localeCompare(b.action));
|
|
1206
|
+
else sorted.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
|
|
1207
|
+
const rows = sorted.map(f =>
|
|
1208
|
+
`<tr><td class="text-mono drawer-file-path">${esc(f.path)}</td><td>${formatFileActionBadge(f.action)}</td><td class="text-mono drawer-file-changes">+${f.added} -${f.deleted}</td></tr>`
|
|
1209
|
+
).join('');
|
|
1210
|
+
return `<table class="drawer-files-table">
|
|
1211
|
+
<thead><tr>
|
|
1212
|
+
<th data-sort="path" class="drawer-sort-header">${t('alert.col.file')} ↕</th>
|
|
1213
|
+
<th data-sort="action" class="drawer-sort-header">${t('alert.col.action')} ↕</th>
|
|
1214
|
+
<th data-sort="changes" class="drawer-sort-header">${t('alert.col.changes')} ↕</th>
|
|
1215
|
+
</tr></thead>
|
|
1216
|
+
<tbody>${rows}</tbody>
|
|
1217
|
+
</table>`;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1098
1220
|
function openRestoreDrawer(backup) {
|
|
1099
1221
|
const body = $('#restore-drawer-body');
|
|
1100
1222
|
const fields = [
|
|
@@ -1113,10 +1235,6 @@ function openRestoreDrawer(backup) {
|
|
|
1113
1235
|
if (backup.restoreTo) fields.push({ key: 'drawer.field.restoreTo', val: backup.restoreTo });
|
|
1114
1236
|
if (backup.restoreFile) fields.push({ key: 'drawer.field.restoreFile', val: backup.restoreFile });
|
|
1115
1237
|
if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
|
|
1116
|
-
if (backup.summary) {
|
|
1117
|
-
const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
|
|
1118
|
-
fields.push({ key: 'drawer.field.summary', val: translated, pre: true });
|
|
1119
|
-
}
|
|
1120
1238
|
|
|
1121
1239
|
const refText = backup.ref || backup.shortHash || backup.timestamp || '';
|
|
1122
1240
|
const jsonText = JSON.stringify(backup, null, 2);
|
|
@@ -1130,12 +1248,17 @@ function openRestoreDrawer(backup) {
|
|
|
1130
1248
|
${fields.map(f => `
|
|
1131
1249
|
<div class="restore-field">
|
|
1132
1250
|
<div class="restore-field-label">${t(f.key)}</div>
|
|
1133
|
-
|
|
1134
|
-
? `<pre class="restore-field-value text-mono summary-pre">${esc(f.val)}</pre>`
|
|
1135
|
-
: `<div class="restore-field-value text-mono">${esc(f.val)}</div>`
|
|
1136
|
-
}
|
|
1251
|
+
<div class="restore-field-value text-mono">${esc(f.val)}</div>
|
|
1137
1252
|
</div>
|
|
1138
1253
|
`).join('')}
|
|
1254
|
+
${backup.summary ? `
|
|
1255
|
+
<div class="restore-field">
|
|
1256
|
+
<div class="restore-field-label">${t('drawer.field.summary')}</div>
|
|
1257
|
+
<div id="drawer-files-container" class="drawer-files-container">
|
|
1258
|
+
<div class="drawer-files-loading text-muted text-sm">${t('state.loading')}</div>
|
|
1259
|
+
</div>
|
|
1260
|
+
</div>
|
|
1261
|
+
` : ''}
|
|
1139
1262
|
<div class="restore-actions">
|
|
1140
1263
|
<button class="btn btn-sm" data-copy="${esc(refText)}">${t('drawer.copyRef')}</button>
|
|
1141
1264
|
<button class="btn btn-sm" data-copy-json>${t('drawer.copyJson')}</button>
|
|
@@ -1169,6 +1292,34 @@ function openRestoreDrawer(backup) {
|
|
|
1169
1292
|
wrap.classList.toggle('hidden');
|
|
1170
1293
|
});
|
|
1171
1294
|
|
|
1295
|
+
// Lazy-load full file list for summary section
|
|
1296
|
+
if (backup.summary && isGit && hash) {
|
|
1297
|
+
let currentFiles = [];
|
|
1298
|
+
let currentSort = 'changes';
|
|
1299
|
+
fetchBackupFiles(hash).then(files => {
|
|
1300
|
+
currentFiles = files.length > 0 ? files : parseSummaryToFiles(backup.summary);
|
|
1301
|
+
const container = body.querySelector('#drawer-files-container');
|
|
1302
|
+
if (container) {
|
|
1303
|
+
container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort);
|
|
1304
|
+
container.addEventListener('click', (e) => {
|
|
1305
|
+
const th = e.target.closest('[data-sort]');
|
|
1306
|
+
if (!th) return;
|
|
1307
|
+
currentSort = th.dataset.sort;
|
|
1308
|
+
container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort);
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
} else if (backup.summary) {
|
|
1313
|
+
const fallback = parseSummaryToFiles(backup.summary);
|
|
1314
|
+
const container = body.querySelector('#drawer-files-container');
|
|
1315
|
+
if (container && fallback.length > 0) {
|
|
1316
|
+
container.innerHTML = renderDrawerFilesTable(fallback, 'changes');
|
|
1317
|
+
} else if (container) {
|
|
1318
|
+
const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
|
|
1319
|
+
container.innerHTML = `<pre class="restore-field-value text-mono summary-pre">${esc(translated)}</pre>`;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1172
1323
|
openDrawer('restore');
|
|
1173
1324
|
}
|
|
1174
1325
|
|
|
@@ -1281,6 +1432,25 @@ function setupEvents() {
|
|
|
1281
1432
|
if (backup) openRestoreDrawer(backup);
|
|
1282
1433
|
});
|
|
1283
1434
|
|
|
1435
|
+
// Alert history + files toggle (event delegation)
|
|
1436
|
+
$('#card-alert').addEventListener('click', (e) => {
|
|
1437
|
+
const historyToggle = e.target.closest('[data-alert-history-toggle]');
|
|
1438
|
+
if (historyToggle) {
|
|
1439
|
+
const card = historyToggle.closest('#card-alert');
|
|
1440
|
+
const history = card?.querySelector('.alert-history');
|
|
1441
|
+
if (history) history.classList.toggle('alert-history-collapsed');
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
const toggleBtn = e.target.closest('[data-alert-files-toggle]');
|
|
1445
|
+
if (!toggleBtn) return;
|
|
1446
|
+
const section = toggleBtn.closest('.alert-files-section');
|
|
1447
|
+
if (!section) return;
|
|
1448
|
+
const wrap = section.querySelector('.alert-files-table-wrap');
|
|
1449
|
+
if (!wrap) return;
|
|
1450
|
+
const hidden = wrap.classList.toggle('alert-files-hidden');
|
|
1451
|
+
toggleBtn.textContent = hidden ? t('alert.showFiles') : t('alert.hideFiles');
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1284
1454
|
// Diagnostics summary click
|
|
1285
1455
|
$('#diagnostics-summary').addEventListener('click', (e) => {
|
|
1286
1456
|
if (e.target.closest('#diag-summary-click')) openDoctorDrawer();
|
|
@@ -418,6 +418,30 @@ main {
|
|
|
418
418
|
border-left: 3px solid var(--blue);
|
|
419
419
|
}
|
|
420
420
|
|
|
421
|
+
/* ── Summary inline file rows ────────────────────────────── */
|
|
422
|
+
|
|
423
|
+
.summary-file-row {
|
|
424
|
+
display: flex;
|
|
425
|
+
align-items: center;
|
|
426
|
+
gap: 6px;
|
|
427
|
+
padding: 1px 0;
|
|
428
|
+
font-size: 11px;
|
|
429
|
+
line-height: 1.4;
|
|
430
|
+
}
|
|
431
|
+
.summary-file-path {
|
|
432
|
+
flex: 1;
|
|
433
|
+
overflow: hidden;
|
|
434
|
+
text-overflow: ellipsis;
|
|
435
|
+
white-space: nowrap;
|
|
436
|
+
max-width: 220px;
|
|
437
|
+
font-size: 11px;
|
|
438
|
+
color: var(--text-primary);
|
|
439
|
+
}
|
|
440
|
+
.summary-file-more {
|
|
441
|
+
padding: 2px 0;
|
|
442
|
+
font-style: italic;
|
|
443
|
+
}
|
|
444
|
+
|
|
421
445
|
/* ── Stats Row ────────────────────────────────────────────── */
|
|
422
446
|
|
|
423
447
|
.stats-row {
|
|
@@ -749,6 +773,58 @@ main {
|
|
|
749
773
|
line-height: 1.6;
|
|
750
774
|
}
|
|
751
775
|
|
|
776
|
+
/* ── Drawer: Files Table ──────────────────────────────────── */
|
|
777
|
+
|
|
778
|
+
.drawer-files-container {
|
|
779
|
+
margin-top: 6px;
|
|
780
|
+
max-height: 340px;
|
|
781
|
+
overflow: auto;
|
|
782
|
+
border: 1px solid var(--border-subtle);
|
|
783
|
+
border-radius: var(--radius-sm);
|
|
784
|
+
}
|
|
785
|
+
.drawer-files-loading {
|
|
786
|
+
padding: 16px;
|
|
787
|
+
text-align: center;
|
|
788
|
+
}
|
|
789
|
+
.drawer-files-table {
|
|
790
|
+
width: 100%;
|
|
791
|
+
border-collapse: collapse;
|
|
792
|
+
font-size: 12px;
|
|
793
|
+
}
|
|
794
|
+
.drawer-files-table th {
|
|
795
|
+
text-align: left;
|
|
796
|
+
padding: 8px 12px;
|
|
797
|
+
font-size: 10px;
|
|
798
|
+
font-weight: 700;
|
|
799
|
+
text-transform: uppercase;
|
|
800
|
+
letter-spacing: .06em;
|
|
801
|
+
color: var(--text-tertiary);
|
|
802
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
803
|
+
background: var(--bg-elevated);
|
|
804
|
+
position: sticky;
|
|
805
|
+
top: 0;
|
|
806
|
+
z-index: 1;
|
|
807
|
+
}
|
|
808
|
+
.drawer-sort-header { cursor: pointer; transition: color var(--transition); }
|
|
809
|
+
.drawer-sort-header:hover { color: var(--blue); }
|
|
810
|
+
.drawer-files-table td {
|
|
811
|
+
padding: 5px 12px;
|
|
812
|
+
border-bottom: 1px solid rgba(71,85,105,.15);
|
|
813
|
+
vertical-align: middle;
|
|
814
|
+
}
|
|
815
|
+
.drawer-file-path {
|
|
816
|
+
max-width: 280px;
|
|
817
|
+
overflow: hidden;
|
|
818
|
+
text-overflow: ellipsis;
|
|
819
|
+
white-space: nowrap;
|
|
820
|
+
color: var(--text-primary);
|
|
821
|
+
}
|
|
822
|
+
.drawer-file-changes {
|
|
823
|
+
white-space: nowrap;
|
|
824
|
+
font-variant-numeric: tabular-nums;
|
|
825
|
+
color: var(--text-secondary);
|
|
826
|
+
}
|
|
827
|
+
|
|
752
828
|
/* ── Drawer: Doctor Checks ────────────────────────────────── */
|
|
753
829
|
|
|
754
830
|
.check-item {
|
|
@@ -921,11 +997,26 @@ main {
|
|
|
921
997
|
border-left: 3px solid var(--yellow);
|
|
922
998
|
}
|
|
923
999
|
|
|
1000
|
+
.alert-history-toggle-wrap {
|
|
1001
|
+
margin-top: 8px;
|
|
1002
|
+
}
|
|
1003
|
+
.alert-history-toggle-btn {
|
|
1004
|
+
background: none;
|
|
1005
|
+
border: none;
|
|
1006
|
+
color: var(--text-tertiary);
|
|
1007
|
+
font-size: 11px;
|
|
1008
|
+
cursor: pointer;
|
|
1009
|
+
padding: 2px 0;
|
|
1010
|
+
transition: color var(--transition);
|
|
1011
|
+
}
|
|
1012
|
+
.alert-history-toggle-btn:hover { color: var(--blue); }
|
|
1013
|
+
|
|
924
1014
|
.alert-history {
|
|
925
|
-
margin-top:
|
|
1015
|
+
margin-top: 8px;
|
|
926
1016
|
border-top: 1px solid var(--border-subtle);
|
|
927
1017
|
padding-top: 8px;
|
|
928
1018
|
}
|
|
1019
|
+
.alert-history.alert-history-collapsed { display: none; }
|
|
929
1020
|
.alert-history-label {
|
|
930
1021
|
font-size: 10px;
|
|
931
1022
|
font-weight: 700;
|
|
@@ -949,6 +1040,84 @@ main {
|
|
|
949
1040
|
padding: 1px 6px;
|
|
950
1041
|
}
|
|
951
1042
|
|
|
1043
|
+
/* ── Alert Files Table ───────────────────────────────────── */
|
|
1044
|
+
|
|
1045
|
+
.alert-files-section {
|
|
1046
|
+
margin-top: 10px;
|
|
1047
|
+
border-top: 1px solid var(--border-subtle);
|
|
1048
|
+
padding-top: 8px;
|
|
1049
|
+
}
|
|
1050
|
+
.alert-files-toggle {
|
|
1051
|
+
background: none;
|
|
1052
|
+
border: none;
|
|
1053
|
+
color: var(--blue);
|
|
1054
|
+
font-size: 11px;
|
|
1055
|
+
font-weight: 600;
|
|
1056
|
+
cursor: pointer;
|
|
1057
|
+
padding: 2px 0;
|
|
1058
|
+
transition: color var(--transition);
|
|
1059
|
+
}
|
|
1060
|
+
.alert-files-toggle:hover { color: var(--text-heading); }
|
|
1061
|
+
|
|
1062
|
+
.alert-files-table-wrap {
|
|
1063
|
+
margin-top: 8px;
|
|
1064
|
+
max-height: 220px;
|
|
1065
|
+
overflow: auto;
|
|
1066
|
+
border: 1px solid var(--border-subtle);
|
|
1067
|
+
border-radius: var(--radius-sm);
|
|
1068
|
+
}
|
|
1069
|
+
.alert-files-hidden { display: none; }
|
|
1070
|
+
|
|
1071
|
+
.alert-files-table {
|
|
1072
|
+
width: 100%;
|
|
1073
|
+
border-collapse: collapse;
|
|
1074
|
+
font-size: 11px;
|
|
1075
|
+
}
|
|
1076
|
+
.alert-files-table th {
|
|
1077
|
+
text-align: left;
|
|
1078
|
+
padding: 6px 10px;
|
|
1079
|
+
font-size: 9px;
|
|
1080
|
+
font-weight: 700;
|
|
1081
|
+
text-transform: uppercase;
|
|
1082
|
+
letter-spacing: .06em;
|
|
1083
|
+
color: var(--text-tertiary);
|
|
1084
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
1085
|
+
background: var(--bg-elevated);
|
|
1086
|
+
position: sticky;
|
|
1087
|
+
top: 0;
|
|
1088
|
+
z-index: 1;
|
|
1089
|
+
}
|
|
1090
|
+
.alert-files-table td {
|
|
1091
|
+
padding: 4px 10px;
|
|
1092
|
+
border-bottom: 1px solid rgba(71,85,105,.15);
|
|
1093
|
+
vertical-align: middle;
|
|
1094
|
+
}
|
|
1095
|
+
.alert-file-path {
|
|
1096
|
+
max-width: 240px;
|
|
1097
|
+
overflow: hidden;
|
|
1098
|
+
text-overflow: ellipsis;
|
|
1099
|
+
white-space: nowrap;
|
|
1100
|
+
color: var(--text-primary);
|
|
1101
|
+
}
|
|
1102
|
+
.alert-file-changes {
|
|
1103
|
+
white-space: nowrap;
|
|
1104
|
+
font-variant-numeric: tabular-nums;
|
|
1105
|
+
color: var(--text-secondary);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
.alert-action-badge {
|
|
1109
|
+
display: inline-block;
|
|
1110
|
+
padding: 1px 6px;
|
|
1111
|
+
border-radius: 8px;
|
|
1112
|
+
font-size: 9px;
|
|
1113
|
+
font-weight: 600;
|
|
1114
|
+
letter-spacing: .02em;
|
|
1115
|
+
}
|
|
1116
|
+
.alert-action-modified { background: var(--blue-bg); color: var(--blue); }
|
|
1117
|
+
.alert-action-added { background: var(--green-bg); color: var(--green); }
|
|
1118
|
+
.alert-action-deleted { background: var(--red-bg); color: var(--red); }
|
|
1119
|
+
.alert-action-renamed { background: var(--purple-bg); color: var(--purple); }
|
|
1120
|
+
|
|
952
1121
|
/* ── File Search ─────────────────────────────────────────── */
|
|
953
1122
|
|
|
954
1123
|
.file-search-wrap {
|
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
|
|
9
9
|
const { getDashboard } = require('../lib/core/dashboard');
|
|
10
10
|
const { runDiagnostics } = require('../lib/core/doctor');
|
|
11
|
-
const { listBackups } = require('../lib/core/backups');
|
|
11
|
+
const { listBackups, getBackupFiles } = require('../lib/core/backups');
|
|
12
12
|
|
|
13
13
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
14
14
|
const DEFAULT_PORT = 3120;
|
|
@@ -171,6 +171,13 @@ function handleApi(pathname, query, registry, res) {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
if (pathname === '/api/backup-files') {
|
|
175
|
+
const hash = query.get('hash');
|
|
176
|
+
if (!hash) return json(res, { error: 'Missing hash parameter' }, 400);
|
|
177
|
+
try { return json(res, getBackupFiles(pp, hash)); }
|
|
178
|
+
catch (e) { return json(res, { error: e.message }, 500); }
|
|
179
|
+
}
|
|
180
|
+
|
|
174
181
|
if (pathname === '/api/doctor') {
|
|
175
182
|
try { return json(res, runDiagnostics(pp)); }
|
|
176
183
|
catch (e) { return json(res, { error: e.message }, 500); }
|
|
@@ -254,11 +254,13 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
// Git snapshot via Core — changedFileCount comes from diff-tree (accurate incremental)
|
|
257
|
+
let changedFiles;
|
|
257
258
|
if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
|
|
258
259
|
const context = { trigger: 'auto' };
|
|
259
260
|
const snapResult = createGitSnapshot(projectDir, cfg, { branchRef, context });
|
|
260
261
|
if (snapResult.status === 'created') {
|
|
261
262
|
changedFileCount = snapResult.changedCount != null ? snapResult.changedCount : 0;
|
|
263
|
+
changedFiles = snapResult.changedFiles;
|
|
262
264
|
let msg = `Git snapshot ${snapResult.shortHash} (${snapResult.fileCount} files)`;
|
|
263
265
|
if (snapResult.secretsExcluded) {
|
|
264
266
|
msg += ` [secrets excluded: ${snapResult.secretsExcluded.join(', ')}]`;
|
|
@@ -272,7 +274,7 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
|
272
274
|
}
|
|
273
275
|
|
|
274
276
|
// V4: Record change event and check for anomalies (after snapshot, using accurate count)
|
|
275
|
-
recordChange(tracker, changedFileCount);
|
|
277
|
+
recordChange(tracker, changedFileCount, changedFiles);
|
|
276
278
|
const anomalyResult = checkAnomaly(tracker);
|
|
277
279
|
if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
|
|
278
280
|
saveAlert(projectDir, anomalyResult.alert);
|
|
@@ -283,7 +285,8 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
|
283
285
|
if (cfg.backup_strategy === 'shadow' || cfg.backup_strategy === 'both') {
|
|
284
286
|
const shadowResult = createShadowCopy(projectDir, cfg, { backupDir });
|
|
285
287
|
if (shadowResult.status === 'created') {
|
|
286
|
-
|
|
288
|
+
const linkInfo = shadowResult.linkedCount ? ` [${shadowResult.linkedCount} hard-linked]` : '';
|
|
289
|
+
logger.log(`Shadow copy ${shadowResult.timestamp} (${shadowResult.fileCount} files${linkInfo})`);
|
|
287
290
|
if (pendingManifest) {
|
|
288
291
|
saveManifest(backupDir, pendingManifest);
|
|
289
292
|
pendingManifest = null;
|
|
@@ -84,6 +84,27 @@ function checkAnomaly(tracker) {
|
|
|
84
84
|
return { anomaly: true, alert: lastAlert, suppressed: true };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Aggregate per-file details from recent events, deduplicated by path (latest wins)
|
|
88
|
+
const fileMap = new Map();
|
|
89
|
+
for (const e of recentEvents) {
|
|
90
|
+
if (Array.isArray(e.files)) {
|
|
91
|
+
for (const f of e.files) {
|
|
92
|
+
if (f && f.path) {
|
|
93
|
+
const existing = fileMap.get(f.path);
|
|
94
|
+
if (existing) {
|
|
95
|
+
existing.added = (existing.added || 0) + (f.added || 0);
|
|
96
|
+
existing.deleted = (existing.deleted || 0) + (f.deleted || 0);
|
|
97
|
+
} else {
|
|
98
|
+
fileMap.set(f.path, { path: f.path, action: f.action || 'modified', added: f.added || 0, deleted: f.deleted || 0 });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const alertFiles = [...fileMap.values()]
|
|
105
|
+
.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted))
|
|
106
|
+
.slice(0, 50);
|
|
107
|
+
|
|
87
108
|
const alert = {
|
|
88
109
|
type: 'high_change_velocity',
|
|
89
110
|
detectedAt: now,
|
|
@@ -93,6 +114,7 @@ function checkAnomaly(tracker) {
|
|
|
93
114
|
threshold: tracker.config.filesPerWindow,
|
|
94
115
|
expiresAt: new Date(now + 5 * 60 * 1000).toISOString(),
|
|
95
116
|
recommendation: 'High volume of file changes detected. Consider reviewing recent modifications and creating a manual snapshot.',
|
|
117
|
+
files: alertFiles.length > 0 ? alertFiles : undefined,
|
|
96
118
|
};
|
|
97
119
|
|
|
98
120
|
tracker.alerts.push(alert);
|
|
@@ -398,4 +398,65 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
|
|
|
398
398
|
return { kept: keepCount, pruned: total - keepCount, mode, rebuilt: true };
|
|
399
399
|
}
|
|
400
400
|
|
|
401
|
-
|
|
401
|
+
// ── Get backup file details ─────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Get structured file-level changes for a specific git backup commit.
|
|
405
|
+
* Runs diff-tree --numstat + --name-status against parent (or ls-tree for root).
|
|
406
|
+
*
|
|
407
|
+
* @param {string} projectDir
|
|
408
|
+
* @param {string} commitHash - Full or short commit hash
|
|
409
|
+
* @returns {{ files: Array<{path: string, action: string, added: number, deleted: number}>, error?: string }}
|
|
410
|
+
*/
|
|
411
|
+
function getBackupFiles(projectDir, commitHash) {
|
|
412
|
+
if (!isGitRepo(projectDir)) {
|
|
413
|
+
return { files: [], error: 'not a git repository' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const resolved = git(['rev-parse', '--verify', commitHash], { cwd: projectDir, allowFail: true });
|
|
417
|
+
if (!resolved) {
|
|
418
|
+
return { files: [], error: `cannot resolve commit: ${commitHash}` };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const parentCheck = git(['rev-parse', '--verify', `${resolved}^`], { cwd: projectDir, allowFail: true });
|
|
422
|
+
|
|
423
|
+
if (!parentCheck) {
|
|
424
|
+
const lsOut = git(['ls-tree', '--name-only', '-r', resolved], { cwd: projectDir, allowFail: true });
|
|
425
|
+
if (!lsOut) return { files: [] };
|
|
426
|
+
return {
|
|
427
|
+
files: lsOut.split('\n').filter(Boolean).map(f => ({ path: f, action: 'added', added: 0, deleted: 0 })),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const nameStatusOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', `${resolved}^`, resolved], { cwd: projectDir, allowFail: true });
|
|
432
|
+
const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', `${resolved}^`, resolved], { cwd: projectDir, allowFail: true });
|
|
433
|
+
|
|
434
|
+
const stats = {};
|
|
435
|
+
if (numstatOut) {
|
|
436
|
+
for (const line of numstatOut.split('\n').filter(Boolean)) {
|
|
437
|
+
const [add, del, ...nameParts] = line.split('\t');
|
|
438
|
+
const fname = nameParts.join('\t');
|
|
439
|
+
stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const ACTION_MAP = { M: 'modified', A: 'added', D: 'deleted' };
|
|
444
|
+
const files = [];
|
|
445
|
+
if (nameStatusOut) {
|
|
446
|
+
for (const line of nameStatusOut.split('\n').filter(Boolean)) {
|
|
447
|
+
const tab = line.indexOf('\t');
|
|
448
|
+
if (tab < 0) continue;
|
|
449
|
+
const code = line.substring(0, tab).trim();
|
|
450
|
+
const filePart = line.substring(tab + 1);
|
|
451
|
+
const action = code.startsWith('R') ? 'renamed' : (ACTION_MAP[code] || 'modified');
|
|
452
|
+
const fileName = filePart.split('\t').pop();
|
|
453
|
+
const s = stats[fileName] || { added: 0, deleted: 0 };
|
|
454
|
+
files.push({ path: fileName, action, added: s.added, deleted: s.deleted });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
files.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
|
|
459
|
+
return { files };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
module.exports = { listBackups, getBackupFiles, cleanShadowRetention, cleanGitRetention, parseShadowTimestamp };
|
|
@@ -141,6 +141,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
141
141
|
// Build incremental summary from actual tree diff (not working-dir status)
|
|
142
142
|
let changedCount;
|
|
143
143
|
let incrementalSummary;
|
|
144
|
+
let changedFiles;
|
|
144
145
|
if (parentTree) {
|
|
145
146
|
const diffOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', parentTree, newTree], { cwd, allowFail: true });
|
|
146
147
|
if (diffOut) {
|
|
@@ -166,14 +167,25 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
166
167
|
for (const line of numstatOut.split('\n').filter(Boolean)) {
|
|
167
168
|
const [add, del, ...nameParts] = line.split('\t');
|
|
168
169
|
const fname = nameParts.join('\t');
|
|
169
|
-
|
|
170
|
+
stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
// Build structured changedFiles array
|
|
175
|
+
changedFiles = [];
|
|
176
|
+
const ACTION_MAP = { M: 'modified', A: 'added', D: 'deleted', R: 'renamed' };
|
|
177
|
+
for (const [key, arr] of Object.entries(groups)) {
|
|
178
|
+
for (const f of arr) {
|
|
179
|
+
const s = stats[f] || { added: 0, deleted: 0 };
|
|
180
|
+
changedFiles.push({ path: f, action: ACTION_MAP[key], added: s.added, deleted: s.deleted });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
changedFiles.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
|
|
184
|
+
|
|
173
185
|
function fmtFiles(arr) {
|
|
174
186
|
return arr.slice(0, 5).map(f => {
|
|
175
187
|
const s = stats[f];
|
|
176
|
-
return s ? `${f} (
|
|
188
|
+
return s ? `${f} (+${s.added} -${s.deleted})` : f;
|
|
177
189
|
}).join(', ');
|
|
178
190
|
}
|
|
179
191
|
|
|
@@ -191,6 +203,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
191
203
|
changedCount = files.length;
|
|
192
204
|
const sample = files.slice(0, 5).join(', ');
|
|
193
205
|
incrementalSummary = `Added ${files.length}: ${sample}${files.length > 5 ? ', ...' : ''}`;
|
|
206
|
+
changedFiles = files.map(f => ({ path: f, action: 'added', added: 0, deleted: 0 }));
|
|
194
207
|
}
|
|
195
208
|
}
|
|
196
209
|
|
|
@@ -226,6 +239,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
226
239
|
shortHash: commitHash.substring(0, 7),
|
|
227
240
|
fileCount,
|
|
228
241
|
changedCount,
|
|
242
|
+
changedFiles,
|
|
229
243
|
incrementalSummary,
|
|
230
244
|
secretsExcluded: secretsExcluded.length > 0 ? secretsExcluded : undefined,
|
|
231
245
|
};
|
|
@@ -248,6 +262,20 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
248
262
|
* @param {string} [opts.backupDir] - Override backup directory (default: projectDir/.cursor-guard-backup)
|
|
249
263
|
* @returns {{ status: 'created'|'empty'|'error', timestamp?: string, fileCount?: number, snapshotDir?: string, error?: string }}
|
|
250
264
|
*/
|
|
265
|
+
function findPreviousSnapshot(backupDir) {
|
|
266
|
+
try {
|
|
267
|
+
const entries = fs.readdirSync(backupDir)
|
|
268
|
+
.filter(e => /^\d{8}_\d{6}/.test(e))
|
|
269
|
+
.sort()
|
|
270
|
+
.reverse();
|
|
271
|
+
for (const e of entries) {
|
|
272
|
+
const full = path.join(backupDir, e);
|
|
273
|
+
if (fs.statSync(full).isDirectory()) return full;
|
|
274
|
+
}
|
|
275
|
+
} catch { /* no previous snapshots */ }
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
251
279
|
function createShadowCopy(projectDir, cfg, opts = {}) {
|
|
252
280
|
const backupDir = opts.backupDir || path.join(projectDir, '.cursor-guard-backup');
|
|
253
281
|
let ts = formatTimestamp(new Date());
|
|
@@ -264,15 +292,37 @@ function createShadowCopy(projectDir, cfg, opts = {}) {
|
|
|
264
292
|
}
|
|
265
293
|
fs.mkdirSync(snapDir, { recursive: true });
|
|
266
294
|
|
|
295
|
+
const prevSnapDir = findPreviousSnapshot(backupDir);
|
|
296
|
+
|
|
267
297
|
const allFiles = walkDir(projectDir, projectDir);
|
|
268
298
|
const files = filterFiles(allFiles, cfg);
|
|
269
299
|
|
|
270
300
|
let copied = 0;
|
|
301
|
+
let linked = 0;
|
|
271
302
|
for (const f of files) {
|
|
272
303
|
const dest = path.join(snapDir, f.rel);
|
|
273
304
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
274
305
|
try {
|
|
275
|
-
|
|
306
|
+
let didLink = false;
|
|
307
|
+
if (prevSnapDir) {
|
|
308
|
+
const prevFile = path.join(prevSnapDir, f.rel);
|
|
309
|
+
try {
|
|
310
|
+
const srcStat = fs.statSync(f.full);
|
|
311
|
+
const prevStat = fs.statSync(prevFile);
|
|
312
|
+
if (srcStat.size === prevStat.size && Math.abs(srcStat.mtimeMs - prevStat.mtimeMs) < 1) {
|
|
313
|
+
fs.linkSync(prevFile, dest);
|
|
314
|
+
didLink = true;
|
|
315
|
+
linked++;
|
|
316
|
+
}
|
|
317
|
+
} catch { /* prev file missing or stat error — fall through to copy */ }
|
|
318
|
+
}
|
|
319
|
+
if (!didLink) {
|
|
320
|
+
fs.copyFileSync(f.full, dest);
|
|
321
|
+
try {
|
|
322
|
+
const srcStat = fs.statSync(f.full);
|
|
323
|
+
fs.utimesSync(dest, srcStat.atime, srcStat.mtime);
|
|
324
|
+
} catch { /* non-critical: mtime preservation failed */ }
|
|
325
|
+
}
|
|
276
326
|
copied++;
|
|
277
327
|
} catch { /* skip unreadable */ }
|
|
278
328
|
}
|
|
@@ -282,7 +332,7 @@ function createShadowCopy(projectDir, cfg, opts = {}) {
|
|
|
282
332
|
return { status: 'empty', timestamp: ts };
|
|
283
333
|
}
|
|
284
334
|
|
|
285
|
-
return { status: 'created', timestamp: ts, fileCount: copied, snapshotDir: snapDir };
|
|
335
|
+
return { status: 'created', timestamp: ts, fileCount: copied, linkedCount: linked, snapshotDir: snapDir };
|
|
286
336
|
} catch (e) {
|
|
287
337
|
return { status: 'error', error: e.message };
|
|
288
338
|
}
|
package/references/lib/utils.js
CHANGED
|
@@ -137,6 +137,7 @@ const DEFAULT_CONFIG = {
|
|
|
137
137
|
git_retention: { enabled: false, mode: 'count', days: 30, max_count: 200 },
|
|
138
138
|
proactive_alert: true,
|
|
139
139
|
alert_thresholds: { files_per_window: 20, window_seconds: 10, cooldown_seconds: 60 },
|
|
140
|
+
always_watch: false,
|
|
140
141
|
};
|
|
141
142
|
|
|
142
143
|
function loadConfig(projectDir) {
|
|
@@ -221,6 +222,11 @@ function loadConfig(projectDir) {
|
|
|
221
222
|
if (typeof raw.alert_thresholds.cooldown_seconds === 'number' && raw.alert_thresholds.cooldown_seconds > 0)
|
|
222
223
|
cfg.alert_thresholds.cooldown_seconds = raw.alert_thresholds.cooldown_seconds;
|
|
223
224
|
}
|
|
225
|
+
if (raw.always_watch === true) {
|
|
226
|
+
cfg.always_watch = true;
|
|
227
|
+
} else if (raw.always_watch !== undefined && raw.always_watch !== false) {
|
|
228
|
+
warnings.push(`always_watch should be a boolean, got ${JSON.stringify(raw.always_watch)} — using default (false)`);
|
|
229
|
+
}
|
|
224
230
|
return { cfg, loaded: true, error: null, warnings };
|
|
225
231
|
} catch (e) {
|
|
226
232
|
return { cfg, loaded: false, error: e.message };
|
package/references/mcp/server.js
CHANGED
|
@@ -15,10 +15,35 @@ const { getBackupStatus } = require('../lib/core/status');
|
|
|
15
15
|
const { getDashboard } = require('../lib/core/dashboard');
|
|
16
16
|
const { loadActiveAlert } = require('../lib/core/anomaly');
|
|
17
17
|
|
|
18
|
-
const { gitDir: getGitDir } = require('../lib/utils');
|
|
18
|
+
const { loadConfig, gitDir: getGitDir } = require('../lib/utils');
|
|
19
19
|
|
|
20
20
|
const pkg = require('../../package.json');
|
|
21
21
|
|
|
22
|
+
// ── Auto-watch manager for always_watch mode ─────────────────
|
|
23
|
+
|
|
24
|
+
const watchedProjects = new Map();
|
|
25
|
+
|
|
26
|
+
function ensureWatcher(projectPath) {
|
|
27
|
+
if (watchedProjects.has(projectPath)) return;
|
|
28
|
+
const { cfg, loaded } = loadConfig(projectPath);
|
|
29
|
+
if (!loaded || !cfg.always_watch) return;
|
|
30
|
+
if (isWatcherRunning(projectPath)) {
|
|
31
|
+
watchedProjects.set(projectPath, { pid: null, external: true });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const { spawn } = require('child_process');
|
|
36
|
+
const watcherScript = path.join(__dirname, '..', 'bin', 'cursor-guard-backup.js');
|
|
37
|
+
const child = spawn(process.execPath, [watcherScript, '--path', projectPath], {
|
|
38
|
+
detached: true,
|
|
39
|
+
stdio: 'ignore',
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
});
|
|
42
|
+
child.unref();
|
|
43
|
+
watchedProjects.set(projectPath, { pid: child.pid, external: false });
|
|
44
|
+
} catch { /* spawn failed — non-fatal */ }
|
|
45
|
+
}
|
|
46
|
+
|
|
22
47
|
// ── Alert injection helper ──────────────────────────────────────
|
|
23
48
|
|
|
24
49
|
function injectAlert(projectPath, result) {
|
|
@@ -78,6 +103,7 @@ server.tool(
|
|
|
78
103
|
},
|
|
79
104
|
async ({ path: projectPath }) => {
|
|
80
105
|
const resolved = path.resolve(projectPath);
|
|
106
|
+
ensureWatcher(resolved);
|
|
81
107
|
const result = injectAlert(resolved, runDiagnostics(resolved));
|
|
82
108
|
injectWatcherWarning(resolved, result);
|
|
83
109
|
return {
|
|
@@ -102,6 +128,7 @@ server.tool(
|
|
|
102
128
|
},
|
|
103
129
|
async ({ path: projectPath, file, before, limit }) => {
|
|
104
130
|
const resolved = path.resolve(projectPath);
|
|
131
|
+
ensureWatcher(resolved);
|
|
105
132
|
const result = injectAlert(resolved, listBackups(resolved, { file, before, limit }));
|
|
106
133
|
return {
|
|
107
134
|
content: [{
|
|
@@ -128,7 +155,7 @@ server.tool(
|
|
|
128
155
|
},
|
|
129
156
|
async ({ path: projectPath, strategy, message, scope, intent, agent, session }) => {
|
|
130
157
|
const resolved = path.resolve(projectPath);
|
|
131
|
-
|
|
158
|
+
ensureWatcher(resolved);
|
|
132
159
|
const { cfg } = loadConfig(resolved);
|
|
133
160
|
|
|
134
161
|
if (scope === 'all') {
|
|
@@ -182,6 +209,7 @@ server.tool(
|
|
|
182
209
|
},
|
|
183
210
|
async ({ path: projectPath, file, source, preserve_current }) => {
|
|
184
211
|
const resolved = path.resolve(projectPath);
|
|
212
|
+
ensureWatcher(resolved);
|
|
185
213
|
const result = injectAlert(resolved, restoreFile(resolved, file, source, {
|
|
186
214
|
preserveCurrent: preserve_current,
|
|
187
215
|
}));
|
|
@@ -209,6 +237,7 @@ server.tool(
|
|
|
209
237
|
},
|
|
210
238
|
async ({ path: projectPath, source, preview, preserve_current, clean_untracked }) => {
|
|
211
239
|
const resolved = path.resolve(projectPath);
|
|
240
|
+
ensureWatcher(resolved);
|
|
212
241
|
|
|
213
242
|
if (preview !== false) {
|
|
214
243
|
const result = injectAlert(resolved, previewProjectRestore(resolved, source));
|
|
@@ -235,6 +264,7 @@ server.tool(
|
|
|
235
264
|
},
|
|
236
265
|
async ({ path: projectPath, dry_run }) => {
|
|
237
266
|
const resolved = path.resolve(projectPath);
|
|
267
|
+
ensureWatcher(resolved);
|
|
238
268
|
const result = injectAlert(resolved, runFixes(resolved, { dryRun: !!dry_run }));
|
|
239
269
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
240
270
|
}
|
|
@@ -250,6 +280,7 @@ server.tool(
|
|
|
250
280
|
},
|
|
251
281
|
async ({ path: projectPath }) => {
|
|
252
282
|
const resolved = path.resolve(projectPath);
|
|
283
|
+
ensureWatcher(resolved);
|
|
253
284
|
const result = injectAlert(resolved, getBackupStatus(resolved));
|
|
254
285
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
255
286
|
}
|
|
@@ -265,6 +296,7 @@ server.tool(
|
|
|
265
296
|
},
|
|
266
297
|
async ({ path: projectPath }) => {
|
|
267
298
|
const resolved = path.resolve(projectPath);
|
|
299
|
+
ensureWatcher(resolved);
|
|
268
300
|
const result = injectAlert(resolved, getDashboard(resolved));
|
|
269
301
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
270
302
|
}
|
|
@@ -280,6 +312,7 @@ server.tool(
|
|
|
280
312
|
},
|
|
281
313
|
async ({ path: projectPath }) => {
|
|
282
314
|
const resolved = path.resolve(projectPath);
|
|
315
|
+
ensureWatcher(resolved);
|
|
283
316
|
const alert = loadActiveAlert(resolved);
|
|
284
317
|
const result = alert
|
|
285
318
|
? { active: true, alert }
|