cursor-guard 4.4.1 → 4.5.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 +164 -3
- package/SKILL.md +2 -1
- package/package.json +1 -1
- package/references/dashboard/public/app.js +158 -12
- package/references/dashboard/public/index.html +1 -0
- package/references/dashboard/public/style.css +169 -0
- package/references/lib/auto-backup.js +14 -37
- package/references/lib/core/backups.js +3 -0
- package/references/lib/core/core.test.js +40 -0
- package/references/lib/core/doctor.js +16 -2
- package/references/lib/core/restore.js +25 -15
- package/references/mcp/server.js +33 -0
package/ROADMAP.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
> 本文档描述 cursor-guard 从 V2 到 V7 的长期演进方向。
|
|
4
4
|
> 每一代向下兼容,低版本功能永远不废弃。
|
|
5
5
|
>
|
|
6
|
-
> **当前版本**:`V4.
|
|
7
|
-
> **文档状态**:`V2` ~ `V4.
|
|
6
|
+
> **当前版本**:`V4.5.1`(V4 最终版)
|
|
7
|
+
> **文档状态**:`V2` ~ `V4.5.1` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
|
|
8
8
|
|
|
9
9
|
## 阅读导航
|
|
10
10
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|---|---|---|---|
|
|
21
21
|
| `V2` | 能用 | Skill + Script | "AI 弄丢代码能恢复" |
|
|
22
22
|
| `V3` | 更稳 | + Core 抽取 + 可选 MCP | "恢复操作更标准、更省 token" |
|
|
23
|
-
| `V4` | 更聪明 | + 主动检测 + 可观测 + Web 仪表盘 + Intent + 增量摘要 | "cursor-guard 会主动提醒你,能看到为什么备份、改了什么" ✅ |
|
|
23
|
+
| `V4` | 更聪明 | + 主动检测 + 可观测 + Web 仪表盘 + Intent + 增量摘要 + 安全硬化 | "cursor-guard 会主动提醒你,能看到为什么备份、改了什么" ✅ |
|
|
24
24
|
| `V5` | 成闭环 | + 变更控制层 | "AI 代码变更可预防、可追溯、可按事件恢复"(intent 基础已在 V4.3 落地) |
|
|
25
25
|
| `V6` | 成标准 | + 开放协议 + 团队工作流 | "把 AI 代码变更安全做成跨工具标准" |
|
|
26
26
|
| `V7` | 可证明 | + 可验证信任 + 治理层 | "能证明安全流程被执行了" |
|
|
@@ -457,6 +457,58 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
|
|
|
457
457
|
| V4.3.4 | **运维加固**:`backup.log` 日志轮转(1MB / 3 文件);watcher 单实例保护加固(锁文件时间戳 + 24h 超时);`previewProjectRestore` 保护路径分组摘要(降低 token 消耗);SKILL.md 硬规则 #15(升级后提交 skill 文件) | ✅ |
|
|
458
458
|
| V4.3.5 | **Summary 准确性修复 + UI 优化**:备份摘要改用 `diff-tree` 增量对比(修复 porcelain 假摘要 bug);仪表盘变更列三行堆叠布局;配色全面优化(背景层级 / 状态色 / 文字层级) | ✅ |
|
|
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
|
+
| V4.4.1 | **安全硬化版(5 项审计修复 + UX 优化)**:见下方详细说明 | ✅ |
|
|
461
|
+
| V4.5.0 | **V4 最终版(异常检测修复 + Dashboard 全面升级)**:见下方详细说明 | ✅ 收官 |
|
|
462
|
+
|
|
463
|
+
#### V4.4.1 详细内容
|
|
464
|
+
|
|
465
|
+
**安全修复(2×P1 + 3×P2)**:
|
|
466
|
+
|
|
467
|
+
| 级别 | 问题 | 修复 |
|
|
468
|
+
|------|------|------|
|
|
469
|
+
| P1 | `restoreFile` 接受目录 pathspec(`src`、`.cursor`)和项目根(`.`),单文件 API 能恢复整个目录或回滚受保护资产 | `validateRelativePath` 拦截 `.` 和空路径;`isToolPath` 匹配裸 `.cursor`;git 路径用 `cat-file -t` 验证必须是 blob(文件),tree(目录)直接拒绝;shadow 路径用 `statSync` 拦截目录 |
|
|
470
|
+
| P1 | `restore_project` 对 HEAD 已删除的受保护文件不处理,旧快照会把它们复活 | 恢复前用 `ls-tree HEAD -- <path>` 检查存在性,HEAD 中不存在的路径用 `rmSync/unlinkSync` 清除 |
|
|
471
|
+
| P2 | Git retention 重建链只保留 subject(`%s`),丢失 V4.3 审计 trailers | 改用 `%B`(完整 body + trailers),重建用 `fullBody` 传入 `commit-tree -m` |
|
|
472
|
+
| P2 | Dashboard 仅绑定 127.0.0.1 但无 Host/token 防护,可被 DNS rebinding 读取 | 加 Host header 校验(`127.0.0.1`/`localhost`)+ per-process 随机 token 注入 index.html,API 请求必须携带 |
|
|
473
|
+
| P2 | `doctor_fix` 初始化 Git 时 `git add -A` 会提交 `node_modules/` | 在 `git add -A` 前写入 `node_modules/` 和 `.cursor/skills/**/node_modules/` 到 `.gitignore` |
|
|
474
|
+
|
|
475
|
+
**回归测试**:新增 3 条负例锁定 restore 防线(目录 pathspec / 受保护 .cursor / 项目根 `.`),总测试 143/143 全绿。
|
|
476
|
+
|
|
477
|
+
**Dashboard UX**:
|
|
478
|
+
- 骨架屏加载(shimmer 占位,消除白屏→弹出的突兀感)
|
|
479
|
+
- 渐进渲染(`page-data?scope=` 按需返回,overview 先渲染,backups+doctor 并行加载)
|
|
480
|
+
- 备份 summary 行级统计(`git diff-tree --numstat`,每文件 `(+N -M)`),分行显示
|
|
481
|
+
- Summary 可见性提升(12px + secondary 颜色 + monospace 字体)
|
|
482
|
+
- 去除变更列冗余 trigger badge(类型列已有)
|
|
483
|
+
- Pre-restore 快照记录恢复方向(`From: <HEAD短hash>`、`Restore-To: <目标短hash>`、`File: <文件路径>`),表格琥珀色显示 `ab1b45d → f4029e9`
|
|
484
|
+
|
|
485
|
+
**架构级防护缺口修复**:
|
|
486
|
+
- MCP 工具注入 watcher 未运行警告(`_warning` 字段),AI 第一次调任何工具就能看到保护缺口
|
|
487
|
+
- SKILL.md Hard Rule #1 升级:"任何文件写入/删除前必须 snapshot"(之前仅要求"高风险操作前")
|
|
488
|
+
- SKILL.md 新增 Hard Rule #3a:"必须检查 watcher 状态"——看到 `_warning` 必须告知用户
|
|
489
|
+
|
|
490
|
+
#### V4.5.0 详细内容
|
|
491
|
+
|
|
492
|
+
**Bug 修复**:
|
|
493
|
+
|
|
494
|
+
| 问题 | 根因 | 修复 |
|
|
495
|
+
|------|------|------|
|
|
496
|
+
| 异常检测 `changedFileCount` 虚高 | `auto-backup.js` 用 `git status --porcelain`(对比 HEAD)计数,而非对比上一次备份的增量 | 改用 `createGitSnapshot` 返回的 `changedCount`(来自 `diff-tree`),异常检测和 Summary 共享同一数据源。移除了未使用的 `execFileSync` 和 `unquoteGitPath` 导入 |
|
|
497
|
+
| 诊断锁文件状态判断不够智能 | `doctor.js` Lock file 检测只要存在就报 WARN,不区分 watcher 是否在运行 | 加入 PID 存活判断(`process.kill(pid, 0)`):PID 在线 → PASS(`watcher running`);PID 已死 → WARN(`stale lock file`);无 PID → WARN(兜底)。前端 i18n 同步补全 `detail.lock_running` / `detail.lock_stale` / `detail.lock_exists` |
|
|
498
|
+
|
|
499
|
+
**Dashboard 升级(10 项改进)**:
|
|
500
|
+
|
|
501
|
+
| 优先级 | 改进 | 说明 |
|
|
502
|
+
|--------|------|------|
|
|
503
|
+
| 高 | 告警卡片补全 | 显示触发时间、过期倒计时(实时递减)、具体数字(N 文件 / N 秒 / 阈值 N) |
|
|
504
|
+
| 高 | 告警历史 | 保留最近 20 条告警记录,即使过期也显示在卡片下方(最近 5 条) |
|
|
505
|
+
| 中 | 文件搜索框 | 备份表格上方输入框,按文件名/意图/摘要实时过滤相关备份 |
|
|
506
|
+
| 中 | 恢复命令复制 | 抽屉底部显示 `restore_project` 和 `restore_file` MCP 命令,一键复制 |
|
|
507
|
+
| 低 | 筛选按钮计数 | "Git 自动备份 (12)" 而非仅 "Git 自动备份";无数据的类型自动隐藏 |
|
|
508
|
+
| 低 | Watcher 最后扫描 | 卡片增加 "最后扫描: 3s 前",确认 watcher 实际在工作 |
|
|
509
|
+
| 中 | 摘要展开/收起 | 变更摘要超过 2 行时自动折叠,显示 `+N more` 按钮点击展开;避免行过长截断,无需进抽屉即可看全 |
|
|
510
|
+
| 修复 | `showLoading` 引用 | 项目切换时调用了不存在的 `showLoading()`,改为 `showSkeleton()` |
|
|
511
|
+
| 优化 | i18n 补全 | 新增 14 个双语 key(告警详情、告警历史、文件搜索、恢复命令、扫描时间) |
|
|
460
512
|
|
|
461
513
|
> **注**:V4.2 的 Web 仪表盘最初在 V4.0 规划中标记为"不做",但用户需求明确后实施。事实证明只读仪表盘投入产出比合理,且不违反安全原则。
|
|
462
514
|
|
|
@@ -466,6 +518,18 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
|
|
|
466
518
|
- ~~不做 Web 仪表盘~~ → V4.2 已实施(只读、本地、零依赖)
|
|
467
519
|
- 不做云端同步
|
|
468
520
|
|
|
521
|
+
### V4 遗留的架构缺口(V5 接手)
|
|
522
|
+
|
|
523
|
+
通过 V4.4.1 的安全审计和真实场景测试,发现以下架构层面的保护缺口。这些不是代码 bug,而是设计边界:
|
|
524
|
+
|
|
525
|
+
| 缺口 | 现状 | 影响 | V5 改进方向 |
|
|
526
|
+
|------|------|------|------------|
|
|
527
|
+
| **Watcher 停止 = 裸奔** | Watcher 不运行期间的文件变更无任何自动备份 | 如果 AI agent 也没手动 snapshot,变更永久丢失 | **`always_watch` 配置项**:在 `.cursor-guard.json` 中设置 `"always_watch": true`,MCP server 启动时自动 fork watcher 进程。用户可选两种模式:轻量模式(默认,AI 手动 snapshot)和强保护模式(watcher 始终在后台) |
|
|
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 设计) |
|
|
532
|
+
|
|
469
533
|
### 进入 V5 的衡量标准
|
|
470
534
|
|
|
471
535
|
- V4 的主动提醒功能误报率 < 10%
|
|
@@ -522,8 +586,101 @@ V5 不是"三个方向选一个",而是把下面这条链路做完整:
|
|
|
522
586
|
| `impact set` | 为高风险编辑记录受影响文件 / 符号 / 测试集合 | `impact_set` 字段 | 查询事件时能看到"这次改动可能波及哪里" |
|
|
523
587
|
| `MCP / CLI surface` | 暴露最小可用接口给 Agent 和终端 | `register_intent` / `list_active_intents` / `audit_query` / `get_event` / `restore_from_event` | AI 不需要拼复杂 shell,就能完成查询与恢复 |
|
|
524
588
|
| `dashboard / doctor` | 把活跃会话、冲突告警、最近 AI 事件纳入诊断和看板 | `dashboard` / `doctor` 扩展字段 | 用户能看见"现在谁在改、最近改了什么、哪里有冲突" |
|
|
589
|
+
| `always_watch` | 配置项 `"always_watch": true`,MCP server 启动时自动内嵌 watcher 循环;用户可选两种保护模式:**轻量模式**(默认,AI 手动 `snapshot_now`)vs **强保护模式**(watcher 始终在后台,所有变更自动备份) | `.cursor-guard.json` 配置 + MCP server 启动逻辑 | MCP server 启动后,`always_watch: true` 的项目自动有 watcher 保护,无需额外命令;选择权在用户 |
|
|
590
|
+
| `embedded watcher` | **消灭进程边界**:不再是独立后台进程,而是 MCP server 同进程内的 watcher 循环。同进程 = 无 IPC、无文件桥接、无并发竞争。检测到文件变更时自动创建 snapshot,不依赖 AI 手动调用 | MCP server 内部模块 | watcher 停止 = 裸奔的保护缺口彻底消除;AI 忘记 snapshot 也有兜底 |
|
|
591
|
+
| `begin_edit` / `end_edit` | **意图-变更原子绑定**:AI 编辑前调 `begin_edit({ intent, files[], agent, session })`,在内存 `Map<session, EditScope>` 注册编辑意图和目标文件。embedded watcher 检测到变更时按**文件路径**匹配 intent,自动备份带完整上下文。`end_edit(session)` 或 TTL(默认 5 分钟)自动清除 | `begin_edit` / `end_edit` MCP 工具 + 内存 `activeEdits` Map | auto-backup 也能带 intent/agent/session;并发多 Agent 按文件路径消歧,不按时间顺序 |
|
|
525
592
|
| `tests / docs` | 为事件链路补齐单测、集成测试和文档 | tests + schema docs | V5.0 的所有核心事件和恢复路径都有测试覆盖 |
|
|
526
593
|
|
|
594
|
+
### V5 核心设计:Embedded Watcher + 文件路径意图绑定
|
|
595
|
+
|
|
596
|
+
#### 问题根因
|
|
597
|
+
|
|
598
|
+
V4 的自动备份(auto-backup)和意图上下文(intent)分属两个独立进程:
|
|
599
|
+
|
|
600
|
+
```
|
|
601
|
+
MCP Server 进程(知道 intent) ←——×——→ Watcher 进程(知道文件变了)
|
|
602
|
+
↑ ↑
|
|
603
|
+
AI agent 调用 fs 检测循环
|
|
604
|
+
有上下文 无上下文
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
- `auto-backup.js` 调用 snapshot 时只传 `{ trigger: 'auto', changedFileCount }`,没有 intent/agent/session
|
|
608
|
+
- 只有 `snapshot_now` MCP 工具支持 intent 参数
|
|
609
|
+
- 曾考虑"意图队列"(AI 写文件 → watcher 读文件关联),但存在 4 类并发竞态:
|
|
610
|
+
|
|
611
|
+
| 竞态场景 | 描述 |
|
|
612
|
+
|---------|------|
|
|
613
|
+
| 多 Agent 竞争 | A 提交意图 → B 提交意图 → watcher 触发 → 绑谁的? |
|
|
614
|
+
| 意图-变更错位 | A 提交意图但未改文件 → B 改了另一个文件 → A 的意图被错绑到 B 的变更 |
|
|
615
|
+
| 意图堆积 | 一个 cycle 内多个 agent 提交意图 → watcher 只产生一次 commit → 意图丢失或错配 |
|
|
616
|
+
| 空意图残留 | Agent 提交意图后放弃 → 意图留在队列 → 被绑到下一次无关变更 |
|
|
617
|
+
|
|
618
|
+
根本原因:**意图提交和文件变更是两个独立事件,跨进程 + 按时间绑定 = 无法原子化**。
|
|
619
|
+
|
|
620
|
+
#### 方案:同进程 + 按文件路径绑定
|
|
621
|
+
|
|
622
|
+
```
|
|
623
|
+
┌─────────────── MCP Server 进程(V5) ──────────────────┐
|
|
624
|
+
│ │
|
|
625
|
+
│ AI agent 调用 内存 activeEdits │
|
|
626
|
+
│ begin_edit({ Map<session, { │
|
|
627
|
+
│ intent, → intent, files[], │
|
|
628
|
+
│ files[], agent, timestamp, │
|
|
629
|
+
│ agent, ttl │
|
|
630
|
+
│ session }> │
|
|
631
|
+
│ }) ↓ 直接读取(同进程) │
|
|
632
|
+
│ │
|
|
633
|
+
│ Embedded Watcher 循环 ←─── 检测到 src/auth.ts 变更 │
|
|
634
|
+
│ 查 activeEdits: │
|
|
635
|
+
│ s1 声明了 [src/auth.ts] → 匹配 ✅ │
|
|
636
|
+
│ s2 声明了 [src/style.css] → 不匹配 ❌ │
|
|
637
|
+
│ → 创建 auto-backup commit 带 s1 的 intent │
|
|
638
|
+
│ │
|
|
639
|
+
└─────────────────────────────────────────────────────────┘
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
**核心优势**:
|
|
643
|
+
- **同进程**:无 IPC、无文件 I/O、无并发竞争(Node.js 单线程 event loop)
|
|
644
|
+
- **按文件路径绑定**:不同 Agent 改不同文件时各自匹配,无歧义
|
|
645
|
+
- **TTL 自动过期**:空意图 5 分钟后自动清除,不会残留
|
|
646
|
+
- **多 Agent 同文件**:标记为 `multi-agent overlap`,附上所有匹配意图(同时作为冲突检测信号)
|
|
647
|
+
|
|
648
|
+
#### 并发场景处理
|
|
649
|
+
|
|
650
|
+
| 场景 | 处理方式 |
|
|
651
|
+
|------|---------|
|
|
652
|
+
| A 改 `auth.ts`,B 改 `style.css` | 各自匹配各自的 `begin_edit` scope,零歧义 |
|
|
653
|
+
| A、B 都声明要改 `auth.ts` | `begin_edit` 时即检测到重叠,返回 advisory warning;auto-backup 附上两个 intent |
|
|
654
|
+
| A 声明了意图但没改文件 | TTL 到期自动清除;`end_edit` 可提前关闭 |
|
|
655
|
+
| 文件变更但没有任何 `begin_edit` | 降级为普通 auto-backup(和 V4 行为一致,无退化) |
|
|
656
|
+
| AI 直接调 `snapshot_now(intent=...)` | 和现在一模一样,完全兼容 |
|
|
657
|
+
|
|
658
|
+
#### 实现分期
|
|
659
|
+
|
|
660
|
+
```
|
|
661
|
+
Phase 1 (V5.0):
|
|
662
|
+
├── always_watch: true → MCP server 内嵌 watcher 循环
|
|
663
|
+
├── 新增 begin_edit / end_edit MCP 工具
|
|
664
|
+
├── 内存 Map<session, EditScope> 管理活跃编辑意图
|
|
665
|
+
└── watcher 变更检测时查 Map,按文件路径匹配 intent → 写入 commit trailer
|
|
666
|
+
|
|
667
|
+
Phase 2 (V5.x):
|
|
668
|
+
├── begin_edit → 产生 intent_registered 事件(写入审计存储)
|
|
669
|
+
├── 文件变更 → 产生 edit_applied 事件(关联 intent + before_ref)
|
|
670
|
+
├── end_edit → 产生 intent_released 事件
|
|
671
|
+
└── 完整审计链闭环,支持 restore_from_event
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
#### 与"意图队列"方案的本质区别
|
|
675
|
+
|
|
676
|
+
| | 意图队列(已否决) | embedded watcher + begin_edit |
|
|
677
|
+
|---|---|---|
|
|
678
|
+
| 通信方式 | 文件 I/O(跨进程) | 内存 Map(同进程) |
|
|
679
|
+
| 绑定维度 | 时间顺序(先进先出) | 文件路径(精确匹配) |
|
|
680
|
+
| 并发安全 | 4 类竞态条件 | 无竞态(单进程 event loop) |
|
|
681
|
+
| 空意图处理 | 残留在文件中 | TTL 自动过期 + `end_edit` 显式关闭 |
|
|
682
|
+
| 多 Agent 消歧 | 无法消歧 | 文件路径级消歧 + 冲突检测信号 |
|
|
683
|
+
|
|
527
684
|
### V5 主线 A:并发编辑安全(预防层)
|
|
528
685
|
|
|
529
686
|
```
|
|
@@ -650,6 +807,10 @@ V5 的关键不是"多打一行日志",而是建立完整证据链:
|
|
|
650
807
|
|
|
651
808
|
### V5 完成标志(Definition of Done)
|
|
652
809
|
|
|
810
|
+
- `always_watch: true` 配置生效后,MCP server 启动自动内嵌 watcher 循环,用户无需额外命令
|
|
811
|
+
- `begin_edit` / `end_edit` MCP 工具可用,AI 能声明编辑意图和目标文件
|
|
812
|
+
- embedded watcher 的自动备份能通过文件路径匹配关联 `begin_edit` 中的 intent/agent/session
|
|
813
|
+
- 无 `begin_edit` 时自动备份行为与 V4 一致(无退化)
|
|
653
814
|
- AI 能在高风险写入前注册意图并创建 `pre-edit` 快照
|
|
654
815
|
- 用户能查询最近一次 AI 编辑的完整上下文
|
|
655
816
|
- 给定 `event_id` 能找到对应快照并执行恢复
|
package/SKILL.md
CHANGED
|
@@ -571,9 +571,10 @@ Skip the block for unrelated turns.
|
|
|
571
571
|
|
|
572
572
|
## Hard Rules (Non-Negotiable)
|
|
573
573
|
|
|
574
|
-
1. **MUST snapshot before
|
|
574
|
+
1. **MUST snapshot before ANY file write or delete** — call `snapshot_now` (with `intent`) before creating, editing, or deleting files. This is NOT optional and NOT just for "high-risk" operations. Every write is potentially destructive. No exceptions unless user explicitly says "不用备份" / "skip backup". If the MCP response includes `_warning` about the watcher being stopped, tell the user immediately.
|
|
575
575
|
2. **MUST Read before Write** — never overwrite a file the agent hasn't read in the current turn.
|
|
576
576
|
3. **MUST preserve current version before restore** — every restore operation must first snapshot the current state (§5a Step 4). Skip ONLY when: (a) user explicitly opts out, (b) current state is identical to target, or (c) no changes exist. If preservation fails, abort restore by default.
|
|
577
|
+
3a. **MUST check watcher status** — if any MCP tool returns `_warning` about the watcher not running, immediately warn the user: "自动备份守护进程未运行,当前处于无保护状态。建议先启动 watcher 或在每次修改前手动调用 snapshot_now。" Do NOT silently continue editing files without protection.
|
|
577
578
|
4. **Do not** treat Timeline/Checkpoints as the only or primary recovery path.
|
|
578
579
|
5. **Do not** recommend Checkpoints as long-term or sole backup.
|
|
579
580
|
6. **No automatic push** to remotes; local commits only unless user requests push.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-guard",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.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",
|
|
@@ -40,6 +40,12 @@ const I18N = {
|
|
|
40
40
|
'alert.title': 'Alerts',
|
|
41
41
|
'alert.none': 'No active alerts',
|
|
42
42
|
'alert.active': 'Active Alert',
|
|
43
|
+
'alert.triggered': 'Triggered',
|
|
44
|
+
'alert.expires': 'Expires in',
|
|
45
|
+
'alert.detail': '{count} files in {window}s (threshold: {threshold})',
|
|
46
|
+
'alert.expired': 'Expired',
|
|
47
|
+
'alert.history': 'Recent Alert History',
|
|
48
|
+
'alert.noHistory': 'No alert history',
|
|
43
49
|
|
|
44
50
|
'backups.gitCommits': 'Git Commits',
|
|
45
51
|
'backups.shadowSnapshots': 'Shadow Snapshots',
|
|
@@ -95,6 +101,7 @@ const I18N = {
|
|
|
95
101
|
'trigger.manual': 'Manual (agent)',
|
|
96
102
|
'trigger.pre-restore': 'Pre-Restore',
|
|
97
103
|
'backups.col.summary': 'Changes',
|
|
104
|
+
'backups.search': 'Search files…',
|
|
98
105
|
'summary.modified': 'Modified',
|
|
99
106
|
'summary.added': 'Added',
|
|
100
107
|
'summary.deleted': 'Deleted',
|
|
@@ -103,6 +110,13 @@ const I18N = {
|
|
|
103
110
|
'drawer.field.intent': 'Intent',
|
|
104
111
|
'drawer.field.agent': 'Agent',
|
|
105
112
|
'drawer.field.session': 'Session',
|
|
113
|
+
'drawer.field.from': 'From (current)',
|
|
114
|
+
'drawer.field.restoreTo':'Restore to',
|
|
115
|
+
'drawer.field.restoreFile':'Restored file',
|
|
116
|
+
'drawer.restoreCmd': 'Copy Restore Command',
|
|
117
|
+
'drawer.restoreCmdFile':'Copy File Restore Command',
|
|
118
|
+
|
|
119
|
+
'watcher.lastScan': 'Last scan',
|
|
106
120
|
|
|
107
121
|
'error.fetchFailed': 'Failed to fetch data',
|
|
108
122
|
'error.sectionFailed': 'This section failed to load',
|
|
@@ -176,6 +190,8 @@ const I18N = {
|
|
|
176
190
|
'detail.disk_critical': '{gb} GB free — critically low',
|
|
177
191
|
'detail.disk_free': '{gb} GB free',
|
|
178
192
|
'detail.disk_unknown': 'could not determine free space',
|
|
193
|
+
'detail.lock_running': 'watcher running (pid={pid}, since {since})',
|
|
194
|
+
'detail.lock_stale': 'stale lock file (pid={pid} is dead) — safe to delete or run doctor_fix',
|
|
179
195
|
'detail.lock_exists': 'lock file exists — another instance may be running. {info}',
|
|
180
196
|
'detail.lock_none': 'no lock file (no running instance)',
|
|
181
197
|
'detail.node_ok': '{v}',
|
|
@@ -221,6 +237,12 @@ const I18N = {
|
|
|
221
237
|
'alert.title': '告警',
|
|
222
238
|
'alert.none': '无活跃告警',
|
|
223
239
|
'alert.active': '活跃告警',
|
|
240
|
+
'alert.triggered': '触发时间',
|
|
241
|
+
'alert.expires': '剩余有效',
|
|
242
|
+
'alert.detail': '{count} 个文件在 {window} 秒内变更(阈值:{threshold})',
|
|
243
|
+
'alert.expired': '已过期',
|
|
244
|
+
'alert.history': '近期告警历史',
|
|
245
|
+
'alert.noHistory': '暂无告警记录',
|
|
224
246
|
|
|
225
247
|
'backups.gitCommits': 'Git 提交数',
|
|
226
248
|
'backups.shadowSnapshots': '影子快照',
|
|
@@ -276,6 +298,7 @@ const I18N = {
|
|
|
276
298
|
'trigger.manual': '手动(Agent)',
|
|
277
299
|
'trigger.pre-restore': '恢复前快照',
|
|
278
300
|
'backups.col.summary': '变更',
|
|
301
|
+
'backups.search': '搜索文件…',
|
|
279
302
|
'summary.modified': '修改',
|
|
280
303
|
'summary.added': '新增',
|
|
281
304
|
'summary.deleted': '删除',
|
|
@@ -284,6 +307,13 @@ const I18N = {
|
|
|
284
307
|
'drawer.field.intent': '操作意图',
|
|
285
308
|
'drawer.field.agent': 'AI 模型',
|
|
286
309
|
'drawer.field.session': '会话 ID',
|
|
310
|
+
'drawer.field.from': '恢复前版本',
|
|
311
|
+
'drawer.field.restoreTo':'恢复目标',
|
|
312
|
+
'drawer.field.restoreFile':'恢复文件',
|
|
313
|
+
'drawer.restoreCmd': '复制恢复命令',
|
|
314
|
+
'drawer.restoreCmdFile':'复制文件恢复命令',
|
|
315
|
+
|
|
316
|
+
'watcher.lastScan': '最后扫描',
|
|
287
317
|
|
|
288
318
|
'error.fetchFailed': '数据拉取失败',
|
|
289
319
|
'error.sectionFailed': '此区块加载失败',
|
|
@@ -357,6 +387,8 @@ const I18N = {
|
|
|
357
387
|
'detail.disk_critical': '{gb} GB 可用——严重不足',
|
|
358
388
|
'detail.disk_free': '{gb} GB 可用',
|
|
359
389
|
'detail.disk_unknown': '无法检测可用空间',
|
|
390
|
+
'detail.lock_running': '守护进程运行中(pid={pid},启动于 {since})',
|
|
391
|
+
'detail.lock_stale': '残留锁文件(pid={pid} 已终止)——可安全删除或运行 doctor_fix',
|
|
360
392
|
'detail.lock_exists': '锁文件存在——可能有其他实例正在运行。{info}',
|
|
361
393
|
'detail.lock_none': '无锁文件(无运行中的实例)',
|
|
362
394
|
'detail.node_ok': '{v}',
|
|
@@ -379,10 +411,12 @@ const state = {
|
|
|
379
411
|
pageData: null,
|
|
380
412
|
filteredBackups: [],
|
|
381
413
|
backupFilter: 'all',
|
|
414
|
+
fileSearch: '',
|
|
382
415
|
refreshTimer: null,
|
|
383
416
|
tickTimer: null,
|
|
384
417
|
lastRefreshAt: null,
|
|
385
418
|
drawerOpen: null,
|
|
419
|
+
alertHistory: [],
|
|
386
420
|
};
|
|
387
421
|
|
|
388
422
|
const REFRESH_MS = 15000;
|
|
@@ -498,6 +532,8 @@ const DETAIL_PATTERNS = [
|
|
|
498
532
|
{ re: /^(.+?) GB free — critically low$/, key: 'detail.disk_critical', extract: ['gb'] },
|
|
499
533
|
{ re: /^could not determine free space$/, key: 'detail.disk_unknown' },
|
|
500
534
|
{ re: /^(.+?) GB free$/, key: 'detail.disk_free', extract: ['gb'] },
|
|
535
|
+
{ re: /^watcher running \(pid=(\d+), since (.+)\)$/, key: 'detail.lock_running', extract: ['pid', 'since'] },
|
|
536
|
+
{ re: /^stale lock file \(pid=(\d+) is dead\)/, key: 'detail.lock_stale', extract: ['pid'] },
|
|
501
537
|
{ re: /^lock file exists — another instance may be running\. ?(.*)$/,key: 'detail.lock_exists', extract: ['info'] },
|
|
502
538
|
{ re: /^no lock file/, key: 'detail.lock_none' },
|
|
503
539
|
{ re: /^(v\d+\.\d+\.\d+\S*) — recommended >=18$/, key: 'detail.node_old', extract: ['v'] },
|
|
@@ -775,6 +811,7 @@ function renderWatcherCard(watcher) {
|
|
|
775
811
|
let st = 'stopped';
|
|
776
812
|
if (watcher?.running) st = 'running';
|
|
777
813
|
else if (watcher?.stale) st = 'stale';
|
|
814
|
+
const lastScan = state.lastRefreshAt ? relativeTime(new Date(state.lastRefreshAt).toISOString()) : null;
|
|
778
815
|
el.innerHTML = `
|
|
779
816
|
<div class="card-label">${t('watcher.title')}</div>
|
|
780
817
|
<div class="card-status">
|
|
@@ -783,23 +820,55 @@ function renderWatcherCard(watcher) {
|
|
|
783
820
|
</div>
|
|
784
821
|
${watcher?.pid ? `<div class="card-detail text-muted text-sm">${t('watcher.pid')}: ${watcher.pid}</div>` : ''}
|
|
785
822
|
${watcher?.startedAt ? `<div class="card-detail text-muted text-sm">${t('watcher.since')}: ${esc(formatTime(watcher.startedAt))}</div>` : ''}
|
|
823
|
+
${watcher?.running && lastScan ? `<div class="card-detail text-muted text-sm">${t('watcher.lastScan')}: ${esc(lastScan)}</div>` : ''}
|
|
786
824
|
`;
|
|
787
825
|
}
|
|
788
826
|
|
|
789
827
|
function renderAlertCard(alerts) {
|
|
790
828
|
const el = $('#card-alert');
|
|
791
829
|
if (!alerts?.active) {
|
|
830
|
+
let historyHtml = '';
|
|
831
|
+
if (state.alertHistory.length > 0) {
|
|
832
|
+
const rows = state.alertHistory.slice(-5).reverse().map(h =>
|
|
833
|
+
`<div class="alert-history-row text-sm text-muted">
|
|
834
|
+
<span>${esc(formatTime(h.timestamp))}</span>
|
|
835
|
+
<span>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</span>
|
|
836
|
+
<span class="badge badge-expired">${t('alert.expired')}</span>
|
|
837
|
+
</div>`
|
|
838
|
+
).join('');
|
|
839
|
+
historyHtml = `<div class="alert-history"><div class="alert-history-label text-sm">${t('alert.history')}</div>${rows}</div>`;
|
|
840
|
+
}
|
|
792
841
|
el.innerHTML = `
|
|
793
842
|
<div class="card-label">${t('alert.title')}</div>
|
|
794
843
|
<div class="card-status"><span class="status-dot status-healthy"></span><span>${t('alert.none')}</span></div>
|
|
844
|
+
${historyHtml}
|
|
795
845
|
`;
|
|
796
846
|
return;
|
|
797
847
|
}
|
|
798
848
|
const a = alerts.latest || {};
|
|
849
|
+
|
|
850
|
+
// Track in history
|
|
851
|
+
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 });
|
|
853
|
+
if (state.alertHistory.length > 20) state.alertHistory = state.alertHistory.slice(-20);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const triggeredAt = a.timestamp ? formatTime(a.timestamp) : '-';
|
|
857
|
+
const expiresAt = a.expiresAt ? new Date(a.expiresAt) : null;
|
|
858
|
+
const remainMs = expiresAt ? expiresAt.getTime() - Date.now() : 0;
|
|
859
|
+
const remainSec = Math.max(0, Math.ceil(remainMs / 1000));
|
|
860
|
+
const remainMin = Math.floor(remainSec / 60);
|
|
861
|
+
const remainDisplay = remainMin > 0 ? `${remainMin}m ${remainSec % 60}s` : `${remainSec}s`;
|
|
862
|
+
const detailText = t('alert.detail', { count: a.fileCount || '?', window: a.windowSeconds || '?', threshold: a.threshold || '?' });
|
|
863
|
+
|
|
799
864
|
el.innerHTML = `
|
|
800
865
|
<div class="card-label">${t('alert.title')}</div>
|
|
801
866
|
<div class="card-status"><span class="status-dot status-warning"></span><span class="status-text status-warning">${t('alert.active')}</span></div>
|
|
802
|
-
<div class="
|
|
867
|
+
<div class="alert-details">
|
|
868
|
+
<div class="alert-detail-row"><span class="alert-detail-label">${t('alert.triggered')}</span><span>${esc(triggeredAt)}</span></div>
|
|
869
|
+
<div class="alert-detail-row"><span class="alert-detail-label">${t('alert.expires')}</span><span class="alert-countdown">${esc(remainDisplay)}</span></div>
|
|
870
|
+
<div class="alert-detail-row alert-numbers">${esc(detailText)}</div>
|
|
871
|
+
</div>
|
|
803
872
|
`;
|
|
804
873
|
}
|
|
805
874
|
|
|
@@ -807,10 +876,17 @@ function renderAlertCard(alerts) {
|
|
|
807
876
|
|
|
808
877
|
function renderBackupsSection(dashboard, backups) {
|
|
809
878
|
renderBackupStats(dashboard, backups);
|
|
810
|
-
renderFilterBar();
|
|
879
|
+
renderFilterBar(backups);
|
|
880
|
+
renderFileSearch();
|
|
811
881
|
renderBackupTable(backups);
|
|
812
882
|
}
|
|
813
883
|
|
|
884
|
+
function renderFileSearch() {
|
|
885
|
+
const el = $('#file-search-wrap');
|
|
886
|
+
if (!el) return;
|
|
887
|
+
el.innerHTML = `<input id="file-search" type="text" class="file-search-input" placeholder="${t('backups.search')}" value="${esc(state.fileSearch)}" />`;
|
|
888
|
+
}
|
|
889
|
+
|
|
814
890
|
function renderBackupStats(d, backups) {
|
|
815
891
|
const gitCount = d.counts?.git?.commits || 0;
|
|
816
892
|
const shadowCount = d.counts?.shadow?.snapshots || 0;
|
|
@@ -829,7 +905,11 @@ function renderBackupStats(d, backups) {
|
|
|
829
905
|
`;
|
|
830
906
|
}
|
|
831
907
|
|
|
832
|
-
function renderFilterBar() {
|
|
908
|
+
function renderFilterBar(backups) {
|
|
909
|
+
const allBackups = Array.isArray(backups) ? backups : (Array.isArray(state.pageData?.backups) ? state.pageData.backups : []);
|
|
910
|
+
const typeCounts = {};
|
|
911
|
+
for (const b of allBackups) { typeCounts[b.type] = (typeCounts[b.type] || 0) + 1; }
|
|
912
|
+
|
|
833
913
|
const types = [
|
|
834
914
|
{ key: 'all', label: 'backups.filterAll' },
|
|
835
915
|
{ key: 'git-auto-backup', label: 'type.git-auto-backup' },
|
|
@@ -838,9 +918,12 @@ function renderFilterBar() {
|
|
|
838
918
|
{ key: 'shadow', label: 'type.shadow' },
|
|
839
919
|
{ key: 'shadow-pre-restore',label: 'type.shadow-pre-restore' },
|
|
840
920
|
];
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
921
|
+
const total = allBackups.length;
|
|
922
|
+
$('#backup-filters').innerHTML = types.map(t2 => {
|
|
923
|
+
const count = t2.key === 'all' ? total : (typeCounts[t2.key] || 0);
|
|
924
|
+
if (t2.key !== 'all' && count === 0) return '';
|
|
925
|
+
return `<button class="filter-btn ${state.backupFilter === t2.key ? 'active' : ''}" data-filter="${t2.key}">${t(t2.label)} <span class="filter-count">(${count})</span></button>`;
|
|
926
|
+
}).join('');
|
|
844
927
|
}
|
|
845
928
|
|
|
846
929
|
function translateSummary(raw) {
|
|
@@ -859,7 +942,10 @@ function formatSummaryCell(b) {
|
|
|
859
942
|
}
|
|
860
943
|
|
|
861
944
|
let line2 = '';
|
|
862
|
-
if (b.
|
|
945
|
+
if (b.from && b.restoreTo) {
|
|
946
|
+
const label = b.restoreFile ? `${esc(b.restoreFile)}: ` : '';
|
|
947
|
+
line2 = `<div class="summary-restore-ctx">${label}<span class="text-mono">${esc(b.from)}</span> → <span class="text-mono">${esc(b.restoreTo)}</span></div>`;
|
|
948
|
+
} else if (b.intent) {
|
|
863
949
|
const intentShort = b.intent.length > 70 ? b.intent.substring(0, 67) + '...' : b.intent;
|
|
864
950
|
line2 = `<div class="summary-intent">${esc(intentShort)}</div>`;
|
|
865
951
|
} else if (b.message && !b.message.startsWith('guard:')) {
|
|
@@ -870,7 +956,15 @@ function formatSummaryCell(b) {
|
|
|
870
956
|
let line3 = '';
|
|
871
957
|
if (b.summary) {
|
|
872
958
|
const categories = b.summary.split('; ').map(s => translateSummary(s));
|
|
873
|
-
|
|
959
|
+
const MAX_VISIBLE = 2;
|
|
960
|
+
if (categories.length <= MAX_VISIBLE) {
|
|
961
|
+
line3 = categories.map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
|
|
962
|
+
} else {
|
|
963
|
+
const visible = categories.slice(0, MAX_VISIBLE).map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
|
|
964
|
+
const hidden = categories.slice(MAX_VISIBLE).map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
|
|
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>`;
|
|
967
|
+
}
|
|
874
968
|
}
|
|
875
969
|
|
|
876
970
|
if (!line1 && !line2 && !line3) return '<span class="text-muted text-sm">-</span>';
|
|
@@ -882,10 +976,21 @@ function renderBackupTable(backups) {
|
|
|
882
976
|
$('#backup-table-wrap').innerHTML = `<div class="error-panel">${t('error.sectionFailed')}</div>`;
|
|
883
977
|
return;
|
|
884
978
|
}
|
|
885
|
-
|
|
979
|
+
let filtered = state.backupFilter === 'all'
|
|
886
980
|
? backups
|
|
887
981
|
: backups.filter(b => b.type === state.backupFilter);
|
|
888
982
|
|
|
983
|
+
if (state.fileSearch) {
|
|
984
|
+
const q = state.fileSearch.toLowerCase();
|
|
985
|
+
filtered = filtered.filter(b =>
|
|
986
|
+
(b.summary && b.summary.toLowerCase().includes(q)) ||
|
|
987
|
+
(b.message && b.message.toLowerCase().includes(q)) ||
|
|
988
|
+
(b.intent && b.intent.toLowerCase().includes(q)) ||
|
|
989
|
+
(b.restoreFile && b.restoreFile.toLowerCase().includes(q))
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
state.filteredBackups = filtered;
|
|
993
|
+
|
|
889
994
|
if (state.filteredBackups.length === 0) {
|
|
890
995
|
$('#backup-table-wrap').innerHTML = `<div class="empty-state">${t('backups.noBackups')}</div>`;
|
|
891
996
|
return;
|
|
@@ -1004,6 +1109,9 @@ function openRestoreDrawer(backup) {
|
|
|
1004
1109
|
if (backup.intent) fields.push({ key: 'drawer.field.intent', val: backup.intent });
|
|
1005
1110
|
if (backup.agent) fields.push({ key: 'drawer.field.agent', val: backup.agent });
|
|
1006
1111
|
if (backup.session) fields.push({ key: 'drawer.field.session', val: backup.session });
|
|
1112
|
+
if (backup.from) fields.push({ key: 'drawer.field.from', val: backup.from });
|
|
1113
|
+
if (backup.restoreTo) fields.push({ key: 'drawer.field.restoreTo', val: backup.restoreTo });
|
|
1114
|
+
if (backup.restoreFile) fields.push({ key: 'drawer.field.restoreFile', val: backup.restoreFile });
|
|
1007
1115
|
if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
|
|
1008
1116
|
if (backup.summary) {
|
|
1009
1117
|
const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
|
|
@@ -1013,6 +1121,11 @@ function openRestoreDrawer(backup) {
|
|
|
1013
1121
|
const refText = backup.ref || backup.shortHash || backup.timestamp || '';
|
|
1014
1122
|
const jsonText = JSON.stringify(backup, null, 2);
|
|
1015
1123
|
|
|
1124
|
+
const isGit = backup.type?.startsWith('git');
|
|
1125
|
+
const hash = backup.commitHash || backup.shortHash || '';
|
|
1126
|
+
const restoreProjectCmd = isGit && hash ? `restore_project({ version: "${hash}", mode: "execute" })` : '';
|
|
1127
|
+
const restoreFileCmd = isGit && hash ? `restore_file({ file: "<filename>", version: "${hash}" })` : '';
|
|
1128
|
+
|
|
1016
1129
|
body.innerHTML = `
|
|
1017
1130
|
${fields.map(f => `
|
|
1018
1131
|
<div class="restore-field">
|
|
@@ -1028,12 +1141,29 @@ function openRestoreDrawer(backup) {
|
|
|
1028
1141
|
<button class="btn btn-sm" data-copy-json>${t('drawer.copyJson')}</button>
|
|
1029
1142
|
<button class="btn btn-sm" id="preview-toggle">${t('drawer.preview')}</button>
|
|
1030
1143
|
</div>
|
|
1144
|
+
${restoreProjectCmd ? `
|
|
1145
|
+
<div class="restore-cmd-section">
|
|
1146
|
+
<div class="restore-cmd-label text-sm text-muted">MCP Restore Commands</div>
|
|
1147
|
+
<div class="restore-cmd-row">
|
|
1148
|
+
<code class="restore-cmd-code">${esc(restoreProjectCmd)}</code>
|
|
1149
|
+
<button class="btn btn-sm btn-restore-cmd" data-copy-restore-project>${t('drawer.restoreCmd')}</button>
|
|
1150
|
+
</div>
|
|
1151
|
+
<div class="restore-cmd-row">
|
|
1152
|
+
<code class="restore-cmd-code">${esc(restoreFileCmd)}</code>
|
|
1153
|
+
<button class="btn btn-sm btn-restore-cmd" data-copy-restore-file>${t('drawer.restoreCmdFile')}</button>
|
|
1154
|
+
</div>
|
|
1155
|
+
</div>
|
|
1156
|
+
` : ''}
|
|
1031
1157
|
<div id="json-preview-wrap" class="hidden">
|
|
1032
1158
|
<pre class="json-preview">${esc(jsonText)}</pre>
|
|
1033
1159
|
</div>
|
|
1034
1160
|
`;
|
|
1035
1161
|
|
|
1036
1162
|
body.querySelector('[data-copy-json]')?.addEventListener('click', () => copyText(jsonText));
|
|
1163
|
+
if (restoreProjectCmd) {
|
|
1164
|
+
body.querySelector('[data-copy-restore-project]')?.addEventListener('click', () => copyText(restoreProjectCmd));
|
|
1165
|
+
body.querySelector('[data-copy-restore-file]')?.addEventListener('click', () => copyText(restoreFileCmd));
|
|
1166
|
+
}
|
|
1037
1167
|
body.querySelector('#preview-toggle')?.addEventListener('click', () => {
|
|
1038
1168
|
const wrap = body.querySelector('#json-preview-wrap');
|
|
1039
1169
|
wrap.classList.toggle('hidden');
|
|
@@ -1101,7 +1231,7 @@ function setupEvents() {
|
|
|
1101
1231
|
state.currentProjectId = e.target.value;
|
|
1102
1232
|
state.backupFilter = 'all';
|
|
1103
1233
|
stopRefresh();
|
|
1104
|
-
|
|
1234
|
+
showSkeleton();
|
|
1105
1235
|
try { await loadPageData(); renderAll(); }
|
|
1106
1236
|
catch (err) { showGlobalError(err.message); }
|
|
1107
1237
|
startRefresh();
|
|
@@ -1121,13 +1251,29 @@ function setupEvents() {
|
|
|
1121
1251
|
state.backupFilter = btn.dataset.filter;
|
|
1122
1252
|
const backups = state.pageData?.backups;
|
|
1123
1253
|
if (Array.isArray(backups)) {
|
|
1124
|
-
renderFilterBar();
|
|
1254
|
+
renderFilterBar(backups);
|
|
1125
1255
|
renderBackupTable(backups);
|
|
1126
1256
|
}
|
|
1127
1257
|
});
|
|
1128
1258
|
|
|
1129
|
-
//
|
|
1259
|
+
// File search (event delegation on parent)
|
|
1260
|
+
document.addEventListener('input', (e) => {
|
|
1261
|
+
if (e.target.id === 'file-search') {
|
|
1262
|
+
state.fileSearch = e.target.value;
|
|
1263
|
+
const backups = state.pageData?.backups;
|
|
1264
|
+
if (Array.isArray(backups)) renderBackupTable(backups);
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
// Summary expand toggle (must come before row click to prevent drawer open)
|
|
1130
1269
|
$('#backup-table-wrap').addEventListener('click', (e) => {
|
|
1270
|
+
const toggleBtn = e.target.closest('[data-summary-toggle]');
|
|
1271
|
+
if (toggleBtn) {
|
|
1272
|
+
e.stopPropagation();
|
|
1273
|
+
const cell = toggleBtn.closest('.summary-stack');
|
|
1274
|
+
if (cell) cell.classList.toggle('summary-expanded');
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1131
1277
|
const row = e.target.closest('tr[data-bi]');
|
|
1132
1278
|
if (!row) return;
|
|
1133
1279
|
const idx = parseInt(row.dataset.bi, 10);
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
<h2 class="section-title" data-i18n="backups.title">Backups & Recovery</h2>
|
|
58
58
|
<div id="backup-stats" class="stats-row"><div class="skeleton-row"></div></div>
|
|
59
59
|
<div id="backup-filters" class="filter-bar"></div>
|
|
60
|
+
<div id="file-search-wrap" class="file-search-wrap"></div>
|
|
60
61
|
<div id="backup-table-wrap" class="table-wrap"><div class="skeleton-table"></div></div>
|
|
61
62
|
</section>
|
|
62
63
|
|
|
@@ -349,6 +349,19 @@ main {
|
|
|
349
349
|
max-width: 400px;
|
|
350
350
|
line-height: 1.4;
|
|
351
351
|
}
|
|
352
|
+
.summary-restore-ctx {
|
|
353
|
+
font-size: 12px;
|
|
354
|
+
color: var(--amber);
|
|
355
|
+
background: var(--amber-bg);
|
|
356
|
+
padding: 4px 10px;
|
|
357
|
+
border-radius: 4px;
|
|
358
|
+
border-left: 3px solid var(--amber);
|
|
359
|
+
overflow: hidden;
|
|
360
|
+
text-overflow: ellipsis;
|
|
361
|
+
white-space: nowrap;
|
|
362
|
+
max-width: 400px;
|
|
363
|
+
line-height: 1.4;
|
|
364
|
+
}
|
|
352
365
|
.summary-message {
|
|
353
366
|
font-size: 12px;
|
|
354
367
|
color: var(--text-primary);
|
|
@@ -375,6 +388,25 @@ main {
|
|
|
375
388
|
}
|
|
376
389
|
.summary-detail-line:first-child { padding-top: 2px; }
|
|
377
390
|
|
|
391
|
+
.summary-collapsed {
|
|
392
|
+
display: none;
|
|
393
|
+
}
|
|
394
|
+
.summary-expanded .summary-collapsed {
|
|
395
|
+
display: block;
|
|
396
|
+
}
|
|
397
|
+
.summary-toggle-btn {
|
|
398
|
+
background: none;
|
|
399
|
+
border: none;
|
|
400
|
+
color: var(--blue);
|
|
401
|
+
font-size: 11px;
|
|
402
|
+
font-weight: 600;
|
|
403
|
+
cursor: pointer;
|
|
404
|
+
padding: 2px 0;
|
|
405
|
+
transition: color var(--transition);
|
|
406
|
+
}
|
|
407
|
+
.summary-toggle-btn:hover { color: var(--text-heading); }
|
|
408
|
+
.summary-expanded .summary-toggle-btn { display: none; }
|
|
409
|
+
|
|
378
410
|
.summary-pre {
|
|
379
411
|
font-size: 12px;
|
|
380
412
|
line-height: 1.6;
|
|
@@ -850,6 +882,143 @@ main {
|
|
|
850
882
|
}
|
|
851
883
|
.skeleton-table::after { width: 75%; opacity: .6; }
|
|
852
884
|
|
|
885
|
+
/* ── Alert Card Details ───────────────────────────────────── */
|
|
886
|
+
|
|
887
|
+
.alert-details {
|
|
888
|
+
margin-top: 10px;
|
|
889
|
+
display: flex;
|
|
890
|
+
flex-direction: column;
|
|
891
|
+
gap: 6px;
|
|
892
|
+
}
|
|
893
|
+
.alert-detail-row {
|
|
894
|
+
display: flex;
|
|
895
|
+
align-items: center;
|
|
896
|
+
gap: 8px;
|
|
897
|
+
font-size: 12px;
|
|
898
|
+
color: var(--text-secondary);
|
|
899
|
+
}
|
|
900
|
+
.alert-detail-label {
|
|
901
|
+
font-weight: 600;
|
|
902
|
+
color: var(--text-tertiary);
|
|
903
|
+
min-width: 72px;
|
|
904
|
+
font-size: 10px;
|
|
905
|
+
text-transform: uppercase;
|
|
906
|
+
letter-spacing: .06em;
|
|
907
|
+
}
|
|
908
|
+
.alert-countdown {
|
|
909
|
+
color: var(--yellow);
|
|
910
|
+
font-weight: 700;
|
|
911
|
+
font-variant-numeric: tabular-nums;
|
|
912
|
+
}
|
|
913
|
+
.alert-numbers {
|
|
914
|
+
margin-top: 4px;
|
|
915
|
+
font-size: 13px;
|
|
916
|
+
font-weight: 600;
|
|
917
|
+
color: var(--yellow);
|
|
918
|
+
background: var(--yellow-bg);
|
|
919
|
+
padding: 6px 12px;
|
|
920
|
+
border-radius: var(--radius-sm);
|
|
921
|
+
border-left: 3px solid var(--yellow);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
.alert-history {
|
|
925
|
+
margin-top: 12px;
|
|
926
|
+
border-top: 1px solid var(--border-subtle);
|
|
927
|
+
padding-top: 8px;
|
|
928
|
+
}
|
|
929
|
+
.alert-history-label {
|
|
930
|
+
font-size: 10px;
|
|
931
|
+
font-weight: 700;
|
|
932
|
+
text-transform: uppercase;
|
|
933
|
+
letter-spacing: .06em;
|
|
934
|
+
color: var(--text-tertiary);
|
|
935
|
+
margin-bottom: 6px;
|
|
936
|
+
}
|
|
937
|
+
.alert-history-row {
|
|
938
|
+
display: flex;
|
|
939
|
+
align-items: center;
|
|
940
|
+
gap: 8px;
|
|
941
|
+
padding: 3px 0;
|
|
942
|
+
font-size: 11px;
|
|
943
|
+
}
|
|
944
|
+
.badge-expired {
|
|
945
|
+
background: var(--gray-bg);
|
|
946
|
+
color: var(--gray);
|
|
947
|
+
border-color: rgba(100,116,139,.18);
|
|
948
|
+
font-size: 9px;
|
|
949
|
+
padding: 1px 6px;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/* ── File Search ─────────────────────────────────────────── */
|
|
953
|
+
|
|
954
|
+
.file-search-wrap {
|
|
955
|
+
margin-bottom: 12px;
|
|
956
|
+
}
|
|
957
|
+
.file-search-input {
|
|
958
|
+
width: 100%;
|
|
959
|
+
max-width: 360px;
|
|
960
|
+
background: var(--bg-secondary);
|
|
961
|
+
color: var(--text-primary);
|
|
962
|
+
border: 1px solid var(--border);
|
|
963
|
+
border-radius: var(--radius-sm);
|
|
964
|
+
padding: 8px 14px;
|
|
965
|
+
font-size: 13px;
|
|
966
|
+
font-family: inherit;
|
|
967
|
+
transition: border-color var(--transition);
|
|
968
|
+
}
|
|
969
|
+
.file-search-input::placeholder { color: var(--text-tertiary); }
|
|
970
|
+
.file-search-input:focus {
|
|
971
|
+
outline: none;
|
|
972
|
+
border-color: var(--blue);
|
|
973
|
+
box-shadow: 0 0 0 3px var(--blue-bg);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/* ── Filter Count ────────────────────────────────────────── */
|
|
977
|
+
|
|
978
|
+
.filter-count {
|
|
979
|
+
font-size: 10px;
|
|
980
|
+
opacity: .65;
|
|
981
|
+
font-weight: 400;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/* ── Restore Command Section ─────────────────────────────── */
|
|
985
|
+
|
|
986
|
+
.restore-cmd-section {
|
|
987
|
+
margin-top: 18px;
|
|
988
|
+
padding-top: 16px;
|
|
989
|
+
border-top: 1px solid var(--border);
|
|
990
|
+
}
|
|
991
|
+
.restore-cmd-label {
|
|
992
|
+
font-size: 10px;
|
|
993
|
+
font-weight: 700;
|
|
994
|
+
text-transform: uppercase;
|
|
995
|
+
letter-spacing: .08em;
|
|
996
|
+
color: var(--text-tertiary);
|
|
997
|
+
margin-bottom: 10px;
|
|
998
|
+
}
|
|
999
|
+
.restore-cmd-row {
|
|
1000
|
+
display: flex;
|
|
1001
|
+
align-items: center;
|
|
1002
|
+
gap: 8px;
|
|
1003
|
+
margin-bottom: 8px;
|
|
1004
|
+
}
|
|
1005
|
+
.restore-cmd-code {
|
|
1006
|
+
flex: 1;
|
|
1007
|
+
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
|
1008
|
+
font-size: 11px;
|
|
1009
|
+
background: var(--bg-primary);
|
|
1010
|
+
border: 1px solid var(--border-subtle);
|
|
1011
|
+
border-radius: var(--radius-sm);
|
|
1012
|
+
padding: 8px 12px;
|
|
1013
|
+
color: var(--green);
|
|
1014
|
+
word-break: break-all;
|
|
1015
|
+
line-height: 1.5;
|
|
1016
|
+
}
|
|
1017
|
+
.btn-restore-cmd {
|
|
1018
|
+
flex-shrink: 0;
|
|
1019
|
+
white-space: nowrap;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
853
1022
|
/* ── Utility ──────────────────────────────────────────────── */
|
|
854
1023
|
|
|
855
1024
|
.hidden { display: none !important; }
|
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const { execFileSync } = require('child_process');
|
|
6
5
|
const {
|
|
7
6
|
color, loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir,
|
|
8
7
|
walkDir, filterFiles, buildManifest, loadManifest, saveManifest,
|
|
9
|
-
manifestChanged, createLogger,
|
|
8
|
+
manifestChanged, createLogger,
|
|
10
9
|
} = require('./utils');
|
|
11
10
|
const { createGitSnapshot, createShadowCopy } = require('./core/snapshot');
|
|
12
11
|
const { cleanShadowRetention, cleanGitRetention } = require('./core/backups');
|
|
@@ -235,33 +234,9 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
|
235
234
|
}
|
|
236
235
|
if (!hasChanges) continue;
|
|
237
236
|
|
|
238
|
-
//
|
|
237
|
+
// Shadow: pre-compute changed file count from manifest diff (already accurate)
|
|
239
238
|
let changedFileCount = 0;
|
|
240
|
-
|
|
241
|
-
if (repo) {
|
|
242
|
-
// Use execFileSync directly — git() helper's trim() strips leading spaces
|
|
243
|
-
// from porcelain output, corrupting the first line when it starts with ' '.
|
|
244
|
-
try {
|
|
245
|
-
porcelain = execFileSync('git', ['status', '--porcelain'], {
|
|
246
|
-
cwd: projectDir, stdio: 'pipe', encoding: 'utf-8',
|
|
247
|
-
});
|
|
248
|
-
} catch { /* ignore */ }
|
|
249
|
-
if (porcelain) {
|
|
250
|
-
const lines = porcelain.split('\n').filter(Boolean);
|
|
251
|
-
if (cfg.protect.length === 0 && cfg.ignore.length === 0) {
|
|
252
|
-
changedFileCount = lines.length;
|
|
253
|
-
} else {
|
|
254
|
-
const changedPaths = lines.map(line => {
|
|
255
|
-
const filePart = line.substring(3);
|
|
256
|
-
const arrowIdx = filePart.indexOf(' -> ');
|
|
257
|
-
const raw = arrowIdx >= 0 ? filePart.substring(arrowIdx + 4) : filePart;
|
|
258
|
-
return unquoteGitPath(raw);
|
|
259
|
-
});
|
|
260
|
-
const fakeFiles = changedPaths.map(rel => ({ rel, full: path.join(projectDir, rel) }));
|
|
261
|
-
changedFileCount = filterFiles(fakeFiles, cfg).length;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
} else if (pendingManifest) {
|
|
239
|
+
if (!repo && pendingManifest) {
|
|
265
240
|
if (!lastManifest) {
|
|
266
241
|
changedFileCount = Object.keys(pendingManifest).length;
|
|
267
242
|
} else {
|
|
@@ -278,18 +253,12 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
|
278
253
|
}
|
|
279
254
|
}
|
|
280
255
|
|
|
281
|
-
|
|
282
|
-
const anomalyResult = checkAnomaly(tracker);
|
|
283
|
-
if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
|
|
284
|
-
saveAlert(projectDir, anomalyResult.alert);
|
|
285
|
-
logger.warn(`ALERT: ${anomalyResult.alert.fileCount} files changed in ${anomalyResult.alert.windowSeconds}s (threshold: ${anomalyResult.alert.threshold})`);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// Git snapshot via Core
|
|
256
|
+
// Git snapshot via Core — changedFileCount comes from diff-tree (accurate incremental)
|
|
289
257
|
if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
|
|
290
|
-
const context = { trigger: 'auto'
|
|
258
|
+
const context = { trigger: 'auto' };
|
|
291
259
|
const snapResult = createGitSnapshot(projectDir, cfg, { branchRef, context });
|
|
292
260
|
if (snapResult.status === 'created') {
|
|
261
|
+
changedFileCount = snapResult.changedCount != null ? snapResult.changedCount : 0;
|
|
293
262
|
let msg = `Git snapshot ${snapResult.shortHash} (${snapResult.fileCount} files)`;
|
|
294
263
|
if (snapResult.secretsExcluded) {
|
|
295
264
|
msg += ` [secrets excluded: ${snapResult.secretsExcluded.join(', ')}]`;
|
|
@@ -302,6 +271,14 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
|
302
271
|
}
|
|
303
272
|
}
|
|
304
273
|
|
|
274
|
+
// V4: Record change event and check for anomalies (after snapshot, using accurate count)
|
|
275
|
+
recordChange(tracker, changedFileCount);
|
|
276
|
+
const anomalyResult = checkAnomaly(tracker);
|
|
277
|
+
if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
|
|
278
|
+
saveAlert(projectDir, anomalyResult.alert);
|
|
279
|
+
logger.warn(`ALERT: ${anomalyResult.alert.fileCount} files changed in ${anomalyResult.alert.windowSeconds}s (threshold: ${anomalyResult.alert.threshold})`);
|
|
280
|
+
}
|
|
281
|
+
|
|
305
282
|
// Shadow copy via Core
|
|
306
283
|
if (cfg.backup_strategy === 'shadow' || cfg.backup_strategy === 'both') {
|
|
307
284
|
const shadowResult = createShadowCopy(projectDir, cfg, { backupDir });
|
|
@@ -48,6 +48,9 @@ const TRAILER_MAP = {
|
|
|
48
48
|
'Intent': { key: 'intent' },
|
|
49
49
|
'Agent': { key: 'agent' },
|
|
50
50
|
'Session': { key: 'session' },
|
|
51
|
+
'From': { key: 'from' },
|
|
52
|
+
'Restore-To': { key: 'restoreTo' },
|
|
53
|
+
'File': { key: 'restoreFile' },
|
|
51
54
|
};
|
|
52
55
|
|
|
53
56
|
function parseCommitTrailers(body) {
|
|
@@ -621,6 +621,46 @@ test('restoreFile respects pre_restore_backup=never from config', () => {
|
|
|
621
621
|
}
|
|
622
622
|
});
|
|
623
623
|
|
|
624
|
+
test('restoreFile rejects directory pathspec (git tree)', () => {
|
|
625
|
+
const tmpDir = createTempGitRepo();
|
|
626
|
+
try {
|
|
627
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
628
|
+
const result = restoreFile(tmpDir, 'src', headHash, { preserveCurrent: false });
|
|
629
|
+
assert.strictEqual(result.status, 'error');
|
|
630
|
+
assert.ok(result.error.includes('tree') || result.error.includes('directory'), `expected tree/directory error, got: ${result.error}`);
|
|
631
|
+
} finally {
|
|
632
|
+
cleanupDir(tmpDir);
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test('restoreFile rejects protected .cursor directory', () => {
|
|
637
|
+
const tmpDir = createTempGitRepo();
|
|
638
|
+
try {
|
|
639
|
+
fs.mkdirSync(path.join(tmpDir, '.cursor'), { recursive: true });
|
|
640
|
+
fs.writeFileSync(path.join(tmpDir, '.cursor', 'mcp.json'), '{}');
|
|
641
|
+
execFileSync('git', ['add', '-A'], { cwd: tmpDir, stdio: 'pipe' });
|
|
642
|
+
execFileSync('git', ['commit', '-m', 'add .cursor'], { cwd: tmpDir, stdio: 'pipe' });
|
|
643
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
644
|
+
const result = restoreFile(tmpDir, '.cursor', headHash, { preserveCurrent: false });
|
|
645
|
+
assert.strictEqual(result.status, 'error');
|
|
646
|
+
assert.ok(result.error.includes('protected'), `expected protected path error, got: ${result.error}`);
|
|
647
|
+
} finally {
|
|
648
|
+
cleanupDir(tmpDir);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
test('restoreFile rejects project root "."', () => {
|
|
653
|
+
const tmpDir = createTempGitRepo();
|
|
654
|
+
try {
|
|
655
|
+
const headHash = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
656
|
+
const result = restoreFile(tmpDir, '.', headHash, { preserveCurrent: false });
|
|
657
|
+
assert.strictEqual(result.status, 'error');
|
|
658
|
+
assert.ok(result.error.includes('project root') || result.error.includes('specific file'), `expected root rejection, got: ${result.error}`);
|
|
659
|
+
} finally {
|
|
660
|
+
cleanupDir(tmpDir);
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
624
664
|
test('createPreRestoreSnapshot creates ref under refs/guard/pre-restore/', () => {
|
|
625
665
|
const tmpDir = createTempGitRepo();
|
|
626
666
|
try {
|
|
@@ -217,14 +217,28 @@ function runDiagnostics(projectDir) {
|
|
|
217
217
|
check('Disk space', 'WARN', 'could not determine free space');
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
// 12. Lock file
|
|
220
|
+
// 12. Lock file — distinguish running watcher from stale lock
|
|
221
221
|
const lockFile = gDir
|
|
222
222
|
? path.join(gDir, 'cursor-guard.lock')
|
|
223
223
|
: path.join(backupDir, 'cursor-guard.lock');
|
|
224
224
|
if (fs.existsSync(lockFile)) {
|
|
225
225
|
let content = '';
|
|
226
226
|
try { content = fs.readFileSync(lockFile, 'utf-8').trim(); } catch { /* ignore */ }
|
|
227
|
-
|
|
227
|
+
const pidMatch = content.match(/pid=(\d+)/);
|
|
228
|
+
const startedMatch = content.match(/started=(.+)/);
|
|
229
|
+
const lockPid = pidMatch ? parseInt(pidMatch[1], 10) : null;
|
|
230
|
+
let pidAlive = false;
|
|
231
|
+
if (lockPid) {
|
|
232
|
+
try { process.kill(lockPid, 0); pidAlive = true; } catch { /* not running */ }
|
|
233
|
+
}
|
|
234
|
+
if (lockPid && pidAlive) {
|
|
235
|
+
const since = startedMatch ? startedMatch[1] : 'unknown';
|
|
236
|
+
check('Lock file', 'PASS', `watcher running (pid=${lockPid}, since ${since})`);
|
|
237
|
+
} else if (lockPid && !pidAlive) {
|
|
238
|
+
check('Lock file', 'WARN', `stale lock file (pid=${lockPid} is dead) — safe to delete or run doctor_fix`);
|
|
239
|
+
} else {
|
|
240
|
+
check('Lock file', 'WARN', `lock file exists — another instance may be running. ${content}`);
|
|
241
|
+
}
|
|
228
242
|
} else {
|
|
229
243
|
check('Lock file', 'PASS', 'no lock file (no running instance)');
|
|
230
244
|
}
|
|
@@ -31,7 +31,7 @@ const GUARD_CONFIGS = ['.cursor-guard.json', '.gitignore'];
|
|
|
31
31
|
|
|
32
32
|
function isToolPath(filePath) {
|
|
33
33
|
const normalized = filePath.replace(/\\/g, '/');
|
|
34
|
-
if (TOOL_DIRS.some(d => normalized.startsWith(d))) return true;
|
|
34
|
+
if (TOOL_DIRS.some(d => normalized.startsWith(d) || normalized === d.replace(/\/$/, ''))) return true;
|
|
35
35
|
if (GUARD_CONFIGS.includes(normalized)) return true;
|
|
36
36
|
return false;
|
|
37
37
|
}
|
|
@@ -93,7 +93,7 @@ function restoreFile(projectDir, file, source, opts = {}) {
|
|
|
93
93
|
|
|
94
94
|
// Pre-restore snapshot (git path)
|
|
95
95
|
if (preserveCurrent && repo) {
|
|
96
|
-
const preRestoreResult = createPreRestoreSnapshot(projectDir, file);
|
|
96
|
+
const preRestoreResult = createPreRestoreSnapshot(projectDir, file, { source, file: pathCheck.normalized });
|
|
97
97
|
if (preRestoreResult.status === 'created') {
|
|
98
98
|
result.preRestoreRef = preRestoreResult.ref;
|
|
99
99
|
result.preRestoreShortHash = preRestoreResult.shortHash;
|
|
@@ -131,6 +131,10 @@ function restoreFile(projectDir, file, source, opts = {}) {
|
|
|
131
131
|
// Restore from shadow copy
|
|
132
132
|
if (isShadowSource) {
|
|
133
133
|
try {
|
|
134
|
+
const stat = fs.statSync(shadowDir);
|
|
135
|
+
if (stat.isDirectory()) {
|
|
136
|
+
return { status: 'error', restoredFrom: source, error: `'${file}' is a directory, not a file — use restore_project for directory-level restores` };
|
|
137
|
+
}
|
|
134
138
|
const dest = path.join(projectDir, file);
|
|
135
139
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
136
140
|
fs.copyFileSync(shadowDir, dest);
|
|
@@ -144,24 +148,21 @@ function restoreFile(projectDir, file, source, opts = {}) {
|
|
|
144
148
|
|
|
145
149
|
// Restore from git
|
|
146
150
|
try {
|
|
147
|
-
// Verify the source ref/hash is valid
|
|
148
151
|
const resolved = git(['rev-parse', '--verify', source], { cwd: projectDir, allowFail: true });
|
|
149
152
|
if (!resolved) {
|
|
150
153
|
return { status: 'error', restoredFrom: source, error: `cannot resolve git source: ${source}` };
|
|
151
154
|
}
|
|
152
155
|
|
|
153
|
-
//
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return { status: 'error', restoredFrom: source, error: `file '${file}' not found in source ${source}` };
|
|
161
|
-
}
|
|
156
|
+
// Verify the target is a blob (file), not a tree (directory)
|
|
157
|
+
const objType = git(['cat-file', '-t', `${resolved}:${pathCheck.normalized}`], { cwd: projectDir, allowFail: true });
|
|
158
|
+
if (!objType) {
|
|
159
|
+
return { status: 'error', restoredFrom: source, error: `'${file}' not found in source ${source}` };
|
|
160
|
+
}
|
|
161
|
+
if (objType !== 'blob') {
|
|
162
|
+
return { status: 'error', restoredFrom: source, error: `'${file}' is a ${objType} (directory), not a file — use restore_project for directory-level restores` };
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
execFileSync('git', ['restore', `--source=${resolved}`, '--',
|
|
165
|
+
execFileSync('git', ['restore', `--source=${resolved}`, '--', pathCheck.normalized], {
|
|
165
166
|
cwd: projectDir, stdio: 'pipe',
|
|
166
167
|
});
|
|
167
168
|
|
|
@@ -295,7 +296,7 @@ function executeProjectRestore(projectDir, source, opts = {}) {
|
|
|
295
296
|
const result = { filesRestored: 0, files: effectiveFiles };
|
|
296
297
|
|
|
297
298
|
if (preserveCurrent) {
|
|
298
|
-
const snap = createPreRestoreSnapshot(projectDir, null);
|
|
299
|
+
const snap = createPreRestoreSnapshot(projectDir, null, { source });
|
|
299
300
|
if (snap.status === 'created') {
|
|
300
301
|
result.preRestoreRef = snap.ref;
|
|
301
302
|
result.preRestoreShortHash = snap.shortHash;
|
|
@@ -370,9 +371,12 @@ function executeProjectRestore(projectDir, source, opts = {}) {
|
|
|
370
371
|
*
|
|
371
372
|
* @param {string} projectDir
|
|
372
373
|
* @param {string} [scope] - Specific file to check for changes, or null for all
|
|
374
|
+
* @param {object} [opts]
|
|
375
|
+
* @param {string} [opts.source] - Target restore source (commit hash or ref)
|
|
376
|
+
* @param {string} [opts.file] - File being restored (single-file restore only)
|
|
373
377
|
* @returns {{ status: 'created'|'skipped'|'error', ref?: string, shortHash?: string, error?: string }}
|
|
374
378
|
*/
|
|
375
|
-
function createPreRestoreSnapshot(projectDir, scope) {
|
|
379
|
+
function createPreRestoreSnapshot(projectDir, scope, opts = {}) {
|
|
376
380
|
const gDir = getGitDir(projectDir);
|
|
377
381
|
if (!gDir) return { status: 'error', error: 'not a git repository' };
|
|
378
382
|
|
|
@@ -410,6 +414,12 @@ function createPreRestoreSnapshot(projectDir, scope) {
|
|
|
410
414
|
|
|
411
415
|
let msg = `guard: pre-restore snapshot ${ts}`;
|
|
412
416
|
msg += '\n\nTrigger: pre-restore';
|
|
417
|
+
msg += `\nFrom: ${head.substring(0, 7)}`;
|
|
418
|
+
if (opts.source) {
|
|
419
|
+
const targetShort = git(['rev-parse', '--short', opts.source], { cwd, allowFail: true }) || opts.source;
|
|
420
|
+
msg += `\nRestore-To: ${targetShort}`;
|
|
421
|
+
}
|
|
422
|
+
if (opts.file) msg += `\nFile: ${opts.file}`;
|
|
413
423
|
const commitHash = execFileSync('git', [
|
|
414
424
|
'commit-tree', tree, '-p', head, '-m', msg,
|
|
415
425
|
], { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
package/references/mcp/server.js
CHANGED
|
@@ -15,6 +15,8 @@ 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');
|
|
19
|
+
|
|
18
20
|
const pkg = require('../../package.json');
|
|
19
21
|
|
|
20
22
|
// ── Alert injection helper ──────────────────────────────────────
|
|
@@ -32,6 +34,33 @@ function injectAlert(projectPath, result) {
|
|
|
32
34
|
return result;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
// ── Watcher status check ────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function isWatcherRunning(projectDir) {
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const gDir = getGitDir(projectDir);
|
|
42
|
+
const lockFile = gDir
|
|
43
|
+
? path.join(gDir, 'cursor-guard.lock')
|
|
44
|
+
: path.join(projectDir, '.cursor-guard-backup', 'cursor-guard.lock');
|
|
45
|
+
if (!fs.existsSync(lockFile)) return false;
|
|
46
|
+
try {
|
|
47
|
+
const content = fs.readFileSync(lockFile, 'utf-8');
|
|
48
|
+
const pidMatch = content.match(/pid=(\d+)/);
|
|
49
|
+
if (pidMatch) {
|
|
50
|
+
process.kill(parseInt(pidMatch[1], 10), 0);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
} catch { /* pid gone or lock unreadable */ }
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function injectWatcherWarning(projectPath, result) {
|
|
58
|
+
if (!isWatcherRunning(projectPath)) {
|
|
59
|
+
result._warning = 'Watcher is NOT running — auto-backup protection is inactive. Any file changes made without a manual snapshot_now call will NOT be captured. Consider starting the watcher or calling snapshot_now before making changes.';
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
// ── Server ──────────────────────────────────────────────────────
|
|
36
65
|
|
|
37
66
|
const server = new McpServer({
|
|
@@ -50,6 +79,7 @@ server.tool(
|
|
|
50
79
|
async ({ path: projectPath }) => {
|
|
51
80
|
const resolved = path.resolve(projectPath);
|
|
52
81
|
const result = injectAlert(resolved, runDiagnostics(resolved));
|
|
82
|
+
injectWatcherWarning(resolved, result);
|
|
53
83
|
return {
|
|
54
84
|
content: [{
|
|
55
85
|
type: 'text',
|
|
@@ -128,6 +158,7 @@ server.tool(
|
|
|
128
158
|
}
|
|
129
159
|
|
|
130
160
|
injectAlert(resolved, results);
|
|
161
|
+
injectWatcherWarning(resolved, results);
|
|
131
162
|
|
|
132
163
|
return {
|
|
133
164
|
content: [{
|
|
@@ -154,6 +185,7 @@ server.tool(
|
|
|
154
185
|
const result = injectAlert(resolved, restoreFile(resolved, file, source, {
|
|
155
186
|
preserveCurrent: preserve_current,
|
|
156
187
|
}));
|
|
188
|
+
injectWatcherWarning(resolved, result);
|
|
157
189
|
return {
|
|
158
190
|
content: [{
|
|
159
191
|
type: 'text',
|
|
@@ -187,6 +219,7 @@ server.tool(
|
|
|
187
219
|
preserveCurrent: preserve_current,
|
|
188
220
|
cleanUntracked: clean_untracked,
|
|
189
221
|
}));
|
|
222
|
+
injectWatcherWarning(resolved, result);
|
|
190
223
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
191
224
|
}
|
|
192
225
|
);
|