cursor-guard 4.3.5 → 4.4.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/README.md +9 -0
- package/README.zh-CN.md +9 -0
- package/ROADMAP.md +31 -17
- package/SKILL.md +4 -2
- package/package.json +1 -1
- package/references/bin/cursor-guard-backup.js +9 -1
- package/references/bin/cursor-guard-init.js +7 -0
- package/references/dashboard/public/app.js +61 -21
- package/references/dashboard/public/index.html +13 -19
- package/references/dashboard/public/style.css +88 -14
- package/references/dashboard/server.js +118 -51
- package/references/lib/auto-backup.js +13 -1
- package/references/lib/core/backups.js +15 -14
- package/references/lib/core/doctor-fix.js +5 -0
- package/references/lib/core/doctor.js +27 -0
- package/references/lib/core/restore.js +31 -7
- package/references/lib/core/snapshot.js +30 -4
package/README.md
CHANGED
|
@@ -377,6 +377,15 @@ The skill activates on these signals:
|
|
|
377
377
|
|
|
378
378
|
## Changelog
|
|
379
379
|
|
|
380
|
+
### v4.4.0 — V4 Final
|
|
381
|
+
|
|
382
|
+
- **Fix**: First snapshot now generates "Added N: file1, file2, ..." summary instead of blank — previously the very first backup had no summary because there was no parent tree to diff against
|
|
383
|
+
- **Feature**: `--dashboard` flag for watcher — `npx cursor-guard-backup --path <dir> --dashboard` starts the web dashboard alongside the watcher in a single process. Optional port: `--dashboard 4000`. Auto-increments if port is busy
|
|
384
|
+
- **Feature**: Doctor check "Git retention" — warns when git backup commits exceed 500 and `git_retention.enabled` is `false`, guiding users to enable auto-pruning before refs grow unbounded
|
|
385
|
+
- **Feature**: Doctor check "Backup integrity" — verifies that the latest auto-backup commit's tree object is reachable via `git cat-file -t`, catching silent corruption early
|
|
386
|
+
- **Improve**: `cursor-guard-init` now detects existing `.cursor-guard.json` and displays an upgrade notice instead of silently overwriting
|
|
387
|
+
- **Improve**: Dashboard server refactored to export `startDashboardServer()` for embedding into other processes
|
|
388
|
+
|
|
380
389
|
### v4.3.5
|
|
381
390
|
|
|
382
391
|
- **Fix**: Backup summary now uses incremental `diff-tree` instead of `git status --porcelain` — previously summary always showed cumulative changes since HEAD, now correctly shows changes since the last auto-backup
|
package/README.zh-CN.md
CHANGED
|
@@ -377,6 +377,15 @@ node references\dashboard\server.js --path "D:\MyProject"
|
|
|
377
377
|
|
|
378
378
|
## 更新日志
|
|
379
379
|
|
|
380
|
+
### v4.4.0 — V4 收官版
|
|
381
|
+
|
|
382
|
+
- **修复**:首次快照现在会生成 "Added N: file1, file2, ..." 摘要,而不是空白——之前第一次备份因为没有 parent tree 对比所以 summary 始终为空
|
|
383
|
+
- **功能**:Watcher `--dashboard` 参数——`npx cursor-guard-backup --path <dir> --dashboard` 启动时同时启动 Web 仪表盘,单进程完成监控+查看。可选端口:`--dashboard 4000`,端口被占自动递增
|
|
384
|
+
- **功能**:Doctor 新增 "Git retention" 检查——当 Git 备份 commit 数超过 500 且 `git_retention.enabled` 为 `false` 时发出 WARN,引导用户开启自动清理防止 ref 无限增长
|
|
385
|
+
- **功能**:Doctor 新增 "Backup integrity" 检查——通过 `git cat-file -t` 验证最近一次 auto-backup commit 的 tree 对象是否可达,尽早发现静默损坏
|
|
386
|
+
- **改进**:`cursor-guard-init` 现在检测已有 `.cursor-guard.json`,显示升级提示而非静默覆盖
|
|
387
|
+
- **改进**:Dashboard server 重构,导出 `startDashboardServer()` 供嵌入其他进程使用
|
|
388
|
+
|
|
380
389
|
### v4.3.5
|
|
381
390
|
|
|
382
391
|
- **修复**:备份摘要(Summary)现使用增量 `diff-tree` 替代 `git status --porcelain`——之前 summary 始终显示自 HEAD 以来的累计差异,现在正确显示自上次 auto-backup 以来的增量变化
|
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.4.0`(V4 收官版)
|
|
7
|
+
> **文档状态**:`V2` ~ `V4.4.0` 已实施(含 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
|
|
23
|
+
| `V4` | 更聪明 | + 主动检测 + 可观测 + Web 仪表盘 + Intent + 增量摘要 | "cursor-guard 会主动提醒你,能看到为什么备份、改了什么" ✅ |
|
|
24
24
|
| `V5` | 成闭环 | + 变更控制层 | "AI 代码变更可预防、可追溯、可按事件恢复"(intent 基础已在 V4.3 落地) |
|
|
25
25
|
| `V6` | 成标准 | + 开放协议 + 团队工作流 | "把 AI 代码变更安全做成跨工具标准" |
|
|
26
26
|
| `V7` | 可证明 | + 可验证信任 + 治理层 | "能证明安全流程被执行了" |
|
|
@@ -65,7 +65,7 @@ V2-V3 的目标是让用户"离不开",V6-V7 的目标是让行业"绕不开"
|
|
|
65
65
|
| `MCP Server` | `V3.1-V4.0` ✅ 已完成 | 9 个工具,Agent 标准调用入口,结构化 JSON 返回 | 否 |
|
|
66
66
|
| `智能提醒 / 可观测` | `V4.0` ✅ 已完成 | 主动发现风险、汇总健康状态(anomaly + dashboard) | 否 |
|
|
67
67
|
| `Web 仪表盘` | `V4.2` ✅ 已完成 | 本地只读 Web UI,备份/恢复点/诊断/保护范围,中英双语 | 否 |
|
|
68
|
-
| `备份上下文 + Intent` | `V4.3` ✅ 已完成 | 结构化 commit trailer(Files-Changed/Summary/Trigger/Intent/Agent/Session
|
|
68
|
+
| `备份上下文 + Intent` | `V4.3` ✅ 已完成 | 结构化 commit trailer(Files-Changed/Summary/Trigger/Intent/Agent/Session),增量 diff-tree 真实摘要,仪表盘可追溯 | 否 |
|
|
69
69
|
| `变更控制层` | `V5` 规划(intent 基础已在 V4.3 落地) | 覆盖编辑意图、冲突告警、影响半径、审计事件、按事件恢复 | 否 |
|
|
70
70
|
| `开放协议 / 团队工作流` | `V6` 规划 | 把变更控制能力提炼成跨工具协议、适配器和 CI 查询入口 | 否 |
|
|
71
71
|
| `治理 / 可验证层` | `V7` 规划 | 让“是否走过安全流程”变成可以证明、可以审计、可以验证的事实 | 否 |
|
|
@@ -454,6 +454,9 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
|
|
|
454
454
|
| V4.3.1 | `restore_project` 保护 `.gitignore`;`cursor-guard-index.lock` 清理;summary 按 protect/ignore 过滤 + 分类格式 | ✅ |
|
|
455
455
|
| V4.3.2 | `cursor-guard-init` 自动添加根目录 `node_modules/` 到 `.gitignore`;doctor MCP 版本提示含重载快捷键 | ✅ |
|
|
456
456
|
| V4.3.3 | **Intent 上下文**(V5 基础前置):`snapshot_now` 支持 `intent` / `agent` / `session` 参数,Git trailer 存储,仪表盘展示意图徽章和完整审计字段 | ✅ |
|
|
457
|
+
| V4.3.4 | **运维加固**:`backup.log` 日志轮转(1MB / 3 文件);watcher 单实例保护加固(锁文件时间戳 + 24h 超时);`previewProjectRestore` 保护路径分组摘要(降低 token 消耗);SKILL.md 硬规则 #15(升级后提交 skill 文件) | ✅ |
|
|
458
|
+
| V4.3.5 | **Summary 准确性修复 + UI 优化**:备份摘要改用 `diff-tree` 增量对比(修复 porcelain 假摘要 bug);仪表盘变更列三行堆叠布局;配色全面优化(背景层级 / 状态色 / 文字层级) | ✅ |
|
|
459
|
+
| V4.4.0 | **V4 收官版**:首次快照 summary(无 parent 时生成 Added N: ...);doctor 新增 Git retention 警告(>500 commits + disabled)和 Backup integrity 校验(`cat-file -t` tree 可达性);`cursor-guard-init` 升级检测(已有配置提示) | ✅ |
|
|
457
460
|
|
|
458
461
|
> **注**:V4.2 的 Web 仪表盘最初在 V4.0 规划中标记为"不做",但用户需求明确后实施。事实证明只读仪表盘投入产出比合理,且不违反安全原则。
|
|
459
462
|
|
|
@@ -511,7 +514,7 @@ V5 不是"三个方向选一个",而是把下面这条链路做完整:
|
|
|
511
514
|
|
|
512
515
|
| 模块 / 能力 | AI 要做什么 | 建议产物 | 完成标准 |
|
|
513
516
|
|---|---|---|---|
|
|
514
|
-
| `intent registry` | 在高风险写入前注册编辑意图,记录 agent、会话、工作区、分支、目标文件、风险级别 | `core/intent.*` 或同等模块 | 能列出活跃会话,能释放会话,能查到谁准备改哪个文件。**基础版已在 V4.3.3 落地**:`snapshot_now` 支持 `intent` / `agent` / `session` 参数,存储为 Git commit trailer
|
|
517
|
+
| `intent registry` | 在高风险写入前注册编辑意图,记录 agent、会话、工作区、分支、目标文件、风险级别 | `core/intent.*` 或同等模块 | 能列出活跃会话,能释放会话,能查到谁准备改哪个文件。**基础版已在 V4.3.3 落地**:`snapshot_now` 支持 `intent` / `agent` / `session` 参数,存储为 Git commit trailer,仪表盘可展示。**V4.3.5 修复**:summary 改用 `diff-tree` 增量对比,确保元数据准确 |
|
|
515
518
|
| `pre-edit snapshot` | 在每次高风险 AI 写入前创建 `refs/guard/pre-edit/*` 恢复点 | `refs/guard/pre-edit/<session>/<seq>` | 任意一条 AI 编辑事件都能关联到写前快照 |
|
|
516
519
|
| `conflict detection` | 先做文件路径级冲突检测,再预留符号级增强位 | `detectConflicts()` / `listConflicts()` | 两个会话同时改重叠文件时能给出 advisory warning |
|
|
517
520
|
| `audit store` | 以 append-only 方式保存 AI 编辑事件 | 默认本地 `JSONL`;后续可升级 `SQLite` | 能按文件 / 会话 / agent / 时间 / 风险级别查询。**雏形已在 V4.3.0-V4.3.3 落地**:审计元数据通过 Git commit trailer 持久化,`listBackups` 可按 trigger/intent/agent/session 解析 |
|
|
@@ -952,8 +955,8 @@ V7.2 完整 attestation(安全操作证明链)
|
|
|
952
955
|
|
|
953
956
|
| | V2 | V3 | V4 | V5 | V6 | V7 |
|
|
954
957
|
|---|---|---|---|---|---|---|
|
|
955
|
-
| **一句话** | 能恢复 | 更稳更省 | 主动提醒 + 可观测 +
|
|
956
|
-
| **核心架构** | Skill + Script | + Core + MCP | + 智能检测 + Web 仪表盘 + Intent
|
|
958
|
+
| **一句话** | 能恢复 | 更稳更省 | 主动提醒 + 可观测 + 可追溯 | 变更闭环 | 跨工具标准 | 可证明 |
|
|
959
|
+
| **核心架构** | Skill + Script | + Core + MCP | + 智能检测 + Web 仪表盘 + Intent + 增量摘要 | + 变更控制层 | + 开放协议 + 适配器 | + 治理层 |
|
|
957
960
|
| **Agent 调用** | 拼 shell | 优先 MCP | MCP + 主动建议 | MCP + 意图 / 审计 / 恢复 | 标准接口 + 适配器 | 标准接口 + 审计 |
|
|
958
961
|
| **安装门槛** | 最低 | 不变 | 不变 | 略增 | 看具体实现 | 看具体实现 |
|
|
959
962
|
| **适合谁** | 所有人 | 所有人 | 所有人 | 重度 AI 用户 + 团队试点 | 工具开发者 + 团队 | 企业 + 合规场景 |
|
|
@@ -975,10 +978,17 @@ V3.4 ────── ✅ MCP 自检
|
|
|
975
978
|
│ 前提:MCP 调用成功率 > 95%,token 消耗可观测下降
|
|
976
979
|
▼
|
|
977
980
|
V4.0 ────── ✅ 智能恢复建议 + 备份健康看板 + 4 轮代码审查加固(138 测试)
|
|
978
|
-
V4.1 ────── ✅
|
|
979
|
-
V4.2
|
|
980
|
-
V4.
|
|
981
|
-
V4.
|
|
981
|
+
V4.1 ────── ✅ 用户反馈修复(fileCount 精度、安装流程、PowerShell 兼容)
|
|
982
|
+
V4.2.0 ───── ✅ Web 仪表盘(只读、双语、多项目、聚合 API)
|
|
983
|
+
V4.2.1 ───── ✅ 代码审查修复(t() replaceAll、未用导入、过滤栏补全)
|
|
984
|
+
V4.2.2 ───── ✅ restore 保护 .cursor-guard.json + init 提示 git commit
|
|
985
|
+
V4.3.0 ───── ✅ 备份上下文元数据(Git trailer: Files-Changed / Summary / Trigger)
|
|
986
|
+
V4.3.1 ───── ✅ restore 保护 .gitignore + lock 清理 + summary 过滤/分类
|
|
987
|
+
V4.3.2 ───── ✅ init 自动添加 node_modules/ 到 .gitignore + doctor 重载提示
|
|
988
|
+
V4.3.3 ───── ✅ Intent 上下文(intent / agent / session trailer + 仪表盘展示)
|
|
989
|
+
V4.3.4 ───── ✅ 运维加固(日志轮转 / 锁文件时间戳 / preview 分组 / SKILL 规则)
|
|
990
|
+
V4.3.5 ───── ✅ Summary 增量 diff-tree 修复 + 变更列堆叠布局 + 配色优化
|
|
991
|
+
V4.4.0 ───── ✅ V4 收官:首次快照 summary + doctor 完整性/retention 检查 + init 升级检测 ← 当前版本
|
|
982
992
|
│
|
|
983
993
|
│ 前提:AI 编辑需要更强的追溯 / 恢复 / 查询闭环
|
|
984
994
|
│ 前提:多 Agent / 多工具协作成为真实场景
|
|
@@ -1022,14 +1032,18 @@ V7 的"可验证治理"是这条产品线的逻辑终点——该保护的都保
|
|
|
1022
1032
|
|
|
1023
1033
|
## 给用户说的话
|
|
1024
1034
|
|
|
1025
|
-
### 现在(V4.3)
|
|
1035
|
+
### 现在(V4.3.5)
|
|
1026
1036
|
|
|
1027
1037
|
> cursor-guard 已经能保护你的代码,而且越来越聪明。
|
|
1028
1038
|
> 自动备份、写前快照、确定性恢复——开箱即用。
|
|
1029
|
-
>
|
|
1030
|
-
>
|
|
1031
|
-
> V4.
|
|
1032
|
-
> V4.
|
|
1039
|
+
>
|
|
1040
|
+
> **V3**:MCP 工具调用(可选)让 AI 操作更稳、更快、更省 token。
|
|
1041
|
+
> **V4.0**:系统会主动监测异常变更并提醒你,一个 `dashboard` 就能看全局健康状态。
|
|
1042
|
+
> **V4.2**:本地 Web 仪表盘——健康、备份、恢复点、诊断一页可见,中英双语自动刷新。
|
|
1043
|
+
> **V4.3.0-4.3.3**:每次备份带上下文(改了什么、为什么备份、哪个 AI 在操作),Intent 意图可追溯。
|
|
1044
|
+
> **V4.3.4**:运维加固——日志轮转、锁文件保护、restore 预览分组降低 token 消耗。
|
|
1045
|
+
> **V4.3.5**:修复了备份摘要准确性(增量 diff-tree);仪表盘变更列分层展示,配色全面优化。
|
|
1046
|
+
>
|
|
1033
1047
|
> 经过 4 轮代码审查,138+ 个测试覆盖所有核心路径。
|
|
1034
1048
|
|
|
1035
1049
|
### 未来
|
|
@@ -1059,4 +1073,4 @@ V7 的"可验证治理"是这条产品线的逻辑终点——该保护的都保
|
|
|
1059
1073
|
---
|
|
1060
1074
|
|
|
1061
1075
|
*最后更新:2026-03-22*
|
|
1062
|
-
*版本:v1.
|
|
1076
|
+
*版本:v1.5(V4.4.0 收官版,含 Web 仪表盘、备份上下文元数据、Intent 基础、增量 summary、doctor 完整性校验、运维加固、UI 优化)*
|
package/SKILL.md
CHANGED
|
@@ -147,7 +147,7 @@ When the target file of an edit **falls outside the protected scope**, the agent
|
|
|
147
147
|
|
|
148
148
|
> **MCP shortcut**: if `snapshot_now` tool is available, call it with `{ "path": "<project>", "strategy": "git" }` instead of the shell commands below. The tool handles temp index, secrets exclusion, and ref creation internally, and returns `{ "git": { "status": "created", "commitHash": "...", "shortHash": "..." } }`. Report the `shortHash` to the user and proceed.
|
|
149
149
|
>
|
|
150
|
-
> **Best practice — intent context**:
|
|
150
|
+
> **Best practice — intent context**: Before making high-risk changes, call `snapshot_now` with `intent` to directly record what you are about to do. The intent is stored in the Git commit trailer — no intermediary, no file bridge, no concurrency issues. Example:
|
|
151
151
|
> ```json
|
|
152
152
|
> {
|
|
153
153
|
> "path": "/project",
|
|
@@ -158,7 +158,9 @@ When the target file of an edit **falls outside the protected scope**, the agent
|
|
|
158
158
|
> "session": "6290c87f"
|
|
159
159
|
> }
|
|
160
160
|
> ```
|
|
161
|
-
> The `intent`, `agent`, and `session` fields are stored as Git commit trailers and displayed in the dashboard restore-point list and detail drawer
|
|
161
|
+
> The `intent`, `agent`, and `session` fields are stored as Git commit trailers and displayed in the dashboard restore-point list and detail drawer.
|
|
162
|
+
>
|
|
163
|
+
> **Timeline the user sees**: manual snapshot with intent ("AI准备重构 calculator.js") → auto-backup with file changes ("Modified 2: src/app.js (+15 -3)"). The causal relationship is clear from ordering — the manual snapshot explains WHY, the auto-backup shows WHAT changed.
|
|
162
164
|
|
|
163
165
|
Use a **temporary index and dedicated ref** so the user's staged/unstaged state is never touched:
|
|
164
166
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-guard",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.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",
|
|
@@ -12,6 +12,7 @@ if (args.help || args.h) {
|
|
|
12
12
|
Options:
|
|
13
13
|
--path <dir> Project directory to watch (default: current dir)
|
|
14
14
|
--interval <sec> Override backup interval in seconds
|
|
15
|
+
--dashboard [port] Start dashboard server alongside watcher (default port: 3120)
|
|
15
16
|
--help, -h Show this help message
|
|
16
17
|
--version, -v Show version number`);
|
|
17
18
|
process.exit(0);
|
|
@@ -27,5 +28,12 @@ const targetPath = args.path || '.';
|
|
|
27
28
|
const interval = parseInt(args.interval, 10) || 0;
|
|
28
29
|
const resolved = path.resolve(targetPath);
|
|
29
30
|
|
|
31
|
+
const opts = {};
|
|
32
|
+
if (args.dashboard !== undefined) {
|
|
33
|
+
opts.dashboardPort = (typeof args.dashboard === 'string' && /^\d+$/.test(args.dashboard))
|
|
34
|
+
? parseInt(args.dashboard, 10)
|
|
35
|
+
: 3120;
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
const { runBackup } = require('../lib/auto-backup');
|
|
31
|
-
runBackup(resolved, interval);
|
|
39
|
+
runBackup(resolved, interval, opts);
|
|
@@ -56,6 +56,13 @@ console.log(` Source: ${skillSource}`);
|
|
|
56
56
|
console.log(` Target: ${skillTarget}`);
|
|
57
57
|
console.log(` Mode: ${isGlobal ? 'global (~/.cursor/skills/)' : 'project-local (.cursor/skills/)'}\n`);
|
|
58
58
|
|
|
59
|
+
// Pre-check: warn if .cursor-guard.json already exists (upgrade scenario)
|
|
60
|
+
const configPath = path.join(projectDir, '.cursor-guard.json');
|
|
61
|
+
if (fs.existsSync(configPath)) {
|
|
62
|
+
console.log(' NOTE: .cursor-guard.json already exists — your config will be preserved.');
|
|
63
|
+
console.log(' Only skill files will be updated (upgrade mode).\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
// Step 1: Copy skill files (excluding node_modules and .git)
|
|
60
67
|
console.log(' [1/4] Copying skill files...');
|
|
61
68
|
if (fs.existsSync(skillTarget)) {
|
|
@@ -565,7 +565,9 @@ function relativeTime(ts) {
|
|
|
565
565
|
/* ── Data fetching ────────────────────────────────────────── */
|
|
566
566
|
|
|
567
567
|
async function fetchJson(url) {
|
|
568
|
-
const
|
|
568
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
569
|
+
const tokenParam = window.__GUARD_TOKEN__ ? `${sep}token=${window.__GUARD_TOKEN__}` : '';
|
|
570
|
+
const r = await fetch(url + tokenParam);
|
|
569
571
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
570
572
|
return r.json();
|
|
571
573
|
}
|
|
@@ -577,10 +579,45 @@ async function loadProjects() {
|
|
|
577
579
|
}
|
|
578
580
|
}
|
|
579
581
|
|
|
580
|
-
async function loadPageData() {
|
|
582
|
+
async function loadPageData(opts = {}) {
|
|
581
583
|
if (!state.currentProjectId) return;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
+
const id = state.currentProjectId;
|
|
585
|
+
|
|
586
|
+
if (opts.progressive) {
|
|
587
|
+
state.pageData = { dashboard: null, doctor: null, backups: null };
|
|
588
|
+
const dashPromise = fetchJson(`/api/page-data?id=${id}&scope=dashboard`);
|
|
589
|
+
const restPromise = Promise.allSettled([
|
|
590
|
+
fetchJson(`/api/page-data?id=${id}&scope=backups`),
|
|
591
|
+
fetchJson(`/api/page-data?id=${id}&scope=doctor`),
|
|
592
|
+
]);
|
|
593
|
+
|
|
594
|
+
const dash = await dashPromise;
|
|
595
|
+
state.pageData.dashboard = dash.dashboard;
|
|
596
|
+
state.lastRefreshAt = Date.now();
|
|
597
|
+
showContent();
|
|
598
|
+
if (dash.dashboard && !dash.dashboard.error) {
|
|
599
|
+
renderStrategyBadge(dash.dashboard.strategy);
|
|
600
|
+
renderOverview(dash.dashboard);
|
|
601
|
+
renderProtection(dash.dashboard.protectionScope);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const [backupsResult, doctorResult] = await restPromise;
|
|
605
|
+
if (backupsResult.status === 'fulfilled') {
|
|
606
|
+
state.pageData.backups = backupsResult.value.backups;
|
|
607
|
+
if (state.pageData.dashboard) {
|
|
608
|
+
renderBackupsSection(state.pageData.dashboard, Array.isArray(state.pageData.backups) ? state.pageData.backups : []);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (doctorResult.status === 'fulfilled') {
|
|
612
|
+
state.pageData.doctor = doctorResult.value.doctor;
|
|
613
|
+
if (state.pageData.doctor && !state.pageData.doctor.error) {
|
|
614
|
+
renderDiagnostics(state.pageData.doctor);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} else {
|
|
618
|
+
state.pageData = await fetchJson(`/api/page-data?id=${state.currentProjectId}`);
|
|
619
|
+
state.lastRefreshAt = Date.now();
|
|
620
|
+
}
|
|
584
621
|
}
|
|
585
622
|
|
|
586
623
|
/* ── Refresh ──────────────────────────────────────────────── */
|
|
@@ -633,21 +670,18 @@ function renderStrategyBadge(strategy) {
|
|
|
633
670
|
|
|
634
671
|
/* ── Rendering: Global states ─────────────────────────────── */
|
|
635
672
|
|
|
636
|
-
function
|
|
637
|
-
show($('#loading-state'));
|
|
673
|
+
function showSkeleton() {
|
|
638
674
|
hide($('#error-state'));
|
|
639
|
-
$$('.screen').forEach(s =>
|
|
675
|
+
$$('.screen').forEach(s => show(s));
|
|
640
676
|
}
|
|
641
677
|
|
|
642
678
|
function showGlobalError(msg) {
|
|
643
|
-
hide($('#loading-state'));
|
|
644
679
|
show($('#error-state'));
|
|
645
680
|
$$('.screen').forEach(s => hide(s));
|
|
646
681
|
$('#error-message').textContent = msg || t('error.fetchFailed');
|
|
647
682
|
}
|
|
648
683
|
|
|
649
684
|
function showContent() {
|
|
650
|
-
hide($('#loading-state'));
|
|
651
685
|
hide($('#error-state'));
|
|
652
686
|
$$('.screen').forEach(s => show(s));
|
|
653
687
|
}
|
|
@@ -819,9 +853,10 @@ function translateSummary(raw) {
|
|
|
819
853
|
}
|
|
820
854
|
|
|
821
855
|
function formatSummaryCell(b) {
|
|
822
|
-
|
|
823
|
-
if (b.filesChanged != null)
|
|
824
|
-
|
|
856
|
+
let line1 = '';
|
|
857
|
+
if (b.filesChanged != null) {
|
|
858
|
+
line1 = `<div class="summary-meta"><span class="summary-files">${b.filesChanged} ${t('summary.files')}</span></div>`;
|
|
859
|
+
}
|
|
825
860
|
|
|
826
861
|
let line2 = '';
|
|
827
862
|
if (b.intent) {
|
|
@@ -834,13 +869,12 @@ function formatSummaryCell(b) {
|
|
|
834
869
|
|
|
835
870
|
let line3 = '';
|
|
836
871
|
if (b.summary) {
|
|
837
|
-
const
|
|
838
|
-
|
|
839
|
-
line3 = `<div class="summary-detail">${esc(short)}</div>`;
|
|
872
|
+
const categories = b.summary.split('; ').map(s => translateSummary(s));
|
|
873
|
+
line3 = categories.map(c => `<div class="summary-detail-line">${esc(c)}</div>`).join('');
|
|
840
874
|
}
|
|
841
875
|
|
|
842
|
-
if (!line1
|
|
843
|
-
return `<div class="summary-stack">${line1
|
|
876
|
+
if (!line1 && !line2 && !line3) return '<span class="text-muted text-sm">-</span>';
|
|
877
|
+
return `<div class="summary-stack">${line1}${line2}${line3}</div>`;
|
|
844
878
|
}
|
|
845
879
|
|
|
846
880
|
function renderBackupTable(backups) {
|
|
@@ -971,7 +1005,10 @@ function openRestoreDrawer(backup) {
|
|
|
971
1005
|
if (backup.agent) fields.push({ key: 'drawer.field.agent', val: backup.agent });
|
|
972
1006
|
if (backup.session) fields.push({ key: 'drawer.field.session', val: backup.session });
|
|
973
1007
|
if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
|
|
974
|
-
if (backup.summary)
|
|
1008
|
+
if (backup.summary) {
|
|
1009
|
+
const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
|
|
1010
|
+
fields.push({ key: 'drawer.field.summary', val: translated, pre: true });
|
|
1011
|
+
}
|
|
975
1012
|
|
|
976
1013
|
const refText = backup.ref || backup.shortHash || backup.timestamp || '';
|
|
977
1014
|
const jsonText = JSON.stringify(backup, null, 2);
|
|
@@ -980,7 +1017,10 @@ function openRestoreDrawer(backup) {
|
|
|
980
1017
|
${fields.map(f => `
|
|
981
1018
|
<div class="restore-field">
|
|
982
1019
|
<div class="restore-field-label">${t(f.key)}</div>
|
|
983
|
-
|
|
1020
|
+
${f.pre
|
|
1021
|
+
? `<pre class="restore-field-value text-mono summary-pre">${esc(f.val)}</pre>`
|
|
1022
|
+
: `<div class="restore-field-value text-mono">${esc(f.val)}</div>`
|
|
1023
|
+
}
|
|
984
1024
|
</div>
|
|
985
1025
|
`).join('')}
|
|
986
1026
|
<div class="restore-actions">
|
|
@@ -1123,12 +1163,12 @@ async function init() {
|
|
|
1123
1163
|
document.documentElement.lang = state.locale === 'zh-CN' ? 'zh-CN' : 'en';
|
|
1124
1164
|
document.title = t('app.title');
|
|
1125
1165
|
updateStaticI18n();
|
|
1126
|
-
|
|
1166
|
+
showSkeleton();
|
|
1127
1167
|
|
|
1128
1168
|
try {
|
|
1129
1169
|
await loadProjects();
|
|
1130
1170
|
renderProjectSelect();
|
|
1131
|
-
await loadPageData();
|
|
1171
|
+
await loadPageData({ progressive: true });
|
|
1132
1172
|
renderAll();
|
|
1133
1173
|
startRefresh();
|
|
1134
1174
|
} catch (e) {
|
|
@@ -33,12 +33,6 @@
|
|
|
33
33
|
|
|
34
34
|
<main id="content">
|
|
35
35
|
|
|
36
|
-
<!-- Loading -->
|
|
37
|
-
<div id="loading-state" class="state-panel">
|
|
38
|
-
<div class="spinner"></div>
|
|
39
|
-
<p data-i18n="state.loading">Loading…</p>
|
|
40
|
-
</div>
|
|
41
|
-
|
|
42
36
|
<!-- Global Error -->
|
|
43
37
|
<div id="error-state" class="state-panel hidden">
|
|
44
38
|
<div class="error-icon">⚠</div>
|
|
@@ -47,35 +41,35 @@
|
|
|
47
41
|
</div>
|
|
48
42
|
|
|
49
43
|
<!-- Screen 1: Overview ───────────────────────────────── -->
|
|
50
|
-
<section id="screen-overview" class="screen
|
|
44
|
+
<section id="screen-overview" class="screen">
|
|
51
45
|
<h2 class="section-title" data-i18n="overview.title">Overview</h2>
|
|
52
46
|
<div id="overview-grid" class="card-grid">
|
|
53
|
-
<div id="card-health" class="card card-health"></div>
|
|
54
|
-
<div id="card-git-backup" class="card"></div>
|
|
55
|
-
<div id="card-shadow-backup" class="card"></div>
|
|
56
|
-
<div id="card-watcher" class="card"></div>
|
|
57
|
-
<div id="card-alert" class="card"></div>
|
|
47
|
+
<div id="card-health" class="card card-health"><div class="skeleton-block"></div></div>
|
|
48
|
+
<div id="card-git-backup" class="card"><div class="skeleton-block"></div></div>
|
|
49
|
+
<div id="card-shadow-backup" class="card"><div class="skeleton-block"></div></div>
|
|
50
|
+
<div id="card-watcher" class="card"><div class="skeleton-block"></div></div>
|
|
51
|
+
<div id="card-alert" class="card"><div class="skeleton-block"></div></div>
|
|
58
52
|
</div>
|
|
59
53
|
</section>
|
|
60
54
|
|
|
61
55
|
<!-- Screen 2: Backups & Recovery ─────────────────────── -->
|
|
62
|
-
<section id="screen-backups" class="screen
|
|
56
|
+
<section id="screen-backups" class="screen">
|
|
63
57
|
<h2 class="section-title" data-i18n="backups.title">Backups & Recovery</h2>
|
|
64
|
-
<div id="backup-stats" class="stats-row"></div>
|
|
58
|
+
<div id="backup-stats" class="stats-row"><div class="skeleton-row"></div></div>
|
|
65
59
|
<div id="backup-filters" class="filter-bar"></div>
|
|
66
|
-
<div id="backup-table-wrap" class="table-wrap"></div>
|
|
60
|
+
<div id="backup-table-wrap" class="table-wrap"><div class="skeleton-table"></div></div>
|
|
67
61
|
</section>
|
|
68
62
|
|
|
69
63
|
<!-- Screen 3: Protection Scope ───────────────────────── -->
|
|
70
|
-
<section id="screen-protection" class="screen
|
|
64
|
+
<section id="screen-protection" class="screen">
|
|
71
65
|
<h2 class="section-title" data-i18n="protection.title">Protection Scope</h2>
|
|
72
|
-
<div id="protection-content"></div>
|
|
66
|
+
<div id="protection-content"><div class="skeleton-block"></div></div>
|
|
73
67
|
</section>
|
|
74
68
|
|
|
75
69
|
<!-- Screen 4: Diagnostics ────────────────────────────── -->
|
|
76
|
-
<section id="screen-diagnostics" class="screen
|
|
70
|
+
<section id="screen-diagnostics" class="screen">
|
|
77
71
|
<h2 class="section-title" data-i18n="diagnostics.title">Diagnostics</h2>
|
|
78
|
-
<div id="diagnostics-summary"></div>
|
|
72
|
+
<div id="diagnostics-summary"><div class="skeleton-block"></div></div>
|
|
79
73
|
</section>
|
|
80
74
|
|
|
81
75
|
</main>
|
|
@@ -326,38 +326,64 @@ main {
|
|
|
326
326
|
.badge-trigger { background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.7rem; border-color: var(--border-subtle); }
|
|
327
327
|
.badge-intent { background: var(--blue-bg); color: var(--blue); font-size: 0.7rem; border-color: rgba(59,130,246,.18); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: middle; }
|
|
328
328
|
|
|
329
|
-
.backup-summary-cell { max-width:
|
|
329
|
+
.backup-summary-cell { max-width: 420px; min-width: 180px; }
|
|
330
330
|
|
|
331
|
-
.summary-stack { display: flex; flex-direction: column; gap: 4px; }
|
|
332
|
-
.summary-meta { display: flex; align-items: center; gap:
|
|
333
|
-
.summary-files {
|
|
331
|
+
.summary-stack { display: flex; flex-direction: column; gap: 6px; padding: 4px 0; }
|
|
332
|
+
.summary-meta { display: flex; align-items: center; gap: 8px; }
|
|
333
|
+
.summary-files {
|
|
334
|
+
font-size: 13px;
|
|
335
|
+
font-weight: 700;
|
|
336
|
+
color: var(--text-heading);
|
|
337
|
+
font-variant-numeric: tabular-nums;
|
|
338
|
+
}
|
|
334
339
|
.summary-intent {
|
|
335
340
|
font-size: 12px;
|
|
336
341
|
color: var(--blue);
|
|
337
342
|
background: var(--blue-bg);
|
|
338
|
-
padding:
|
|
339
|
-
border-radius:
|
|
340
|
-
border-left:
|
|
343
|
+
padding: 4px 10px;
|
|
344
|
+
border-radius: 4px;
|
|
345
|
+
border-left: 3px solid var(--blue);
|
|
341
346
|
overflow: hidden;
|
|
342
347
|
text-overflow: ellipsis;
|
|
343
348
|
white-space: nowrap;
|
|
344
|
-
max-width:
|
|
349
|
+
max-width: 400px;
|
|
350
|
+
line-height: 1.4;
|
|
345
351
|
}
|
|
346
352
|
.summary-message {
|
|
347
353
|
font-size: 12px;
|
|
348
|
-
color: var(--text-
|
|
354
|
+
color: var(--text-primary);
|
|
355
|
+
background: var(--bg-tertiary);
|
|
356
|
+
padding: 3px 8px;
|
|
357
|
+
border-radius: 3px;
|
|
358
|
+
border-left: 3px solid var(--text-tertiary);
|
|
349
359
|
overflow: hidden;
|
|
350
360
|
text-overflow: ellipsis;
|
|
351
361
|
white-space: nowrap;
|
|
352
|
-
max-width:
|
|
362
|
+
max-width: 400px;
|
|
363
|
+
line-height: 1.4;
|
|
353
364
|
}
|
|
354
|
-
.summary-detail {
|
|
355
|
-
font-size:
|
|
356
|
-
color: var(--text-
|
|
365
|
+
.summary-detail-line {
|
|
366
|
+
font-size: 12px;
|
|
367
|
+
color: var(--text-secondary);
|
|
368
|
+
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
|
369
|
+
line-height: 1.5;
|
|
370
|
+
padding: 1px 0;
|
|
357
371
|
overflow: hidden;
|
|
358
372
|
text-overflow: ellipsis;
|
|
359
373
|
white-space: nowrap;
|
|
360
|
-
max-width:
|
|
374
|
+
max-width: 400px;
|
|
375
|
+
}
|
|
376
|
+
.summary-detail-line:first-child { padding-top: 2px; }
|
|
377
|
+
|
|
378
|
+
.summary-pre {
|
|
379
|
+
font-size: 12px;
|
|
380
|
+
line-height: 1.6;
|
|
381
|
+
white-space: pre-wrap;
|
|
382
|
+
margin: 0;
|
|
383
|
+
padding: 8px 10px;
|
|
384
|
+
background: var(--bg-tertiary);
|
|
385
|
+
border-radius: 4px;
|
|
386
|
+
border-left: 3px solid var(--blue);
|
|
361
387
|
}
|
|
362
388
|
|
|
363
389
|
/* ── Stats Row ────────────────────────────────────────────── */
|
|
@@ -776,6 +802,54 @@ main {
|
|
|
776
802
|
|
|
777
803
|
.icon-spin-active { animation: spin .6s linear infinite; }
|
|
778
804
|
|
|
805
|
+
/* ── Skeleton loading ────────────────────────────────────── */
|
|
806
|
+
|
|
807
|
+
@keyframes shimmer {
|
|
808
|
+
0% { background-position: -200% 0; }
|
|
809
|
+
100% { background-position: 200% 0; }
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
.skeleton-block {
|
|
813
|
+
height: 48px;
|
|
814
|
+
border-radius: var(--radius-sm);
|
|
815
|
+
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
|
|
816
|
+
background-size: 200% 100%;
|
|
817
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
.skeleton-row {
|
|
821
|
+
display: flex;
|
|
822
|
+
gap: 12px;
|
|
823
|
+
}
|
|
824
|
+
.skeleton-row::before,
|
|
825
|
+
.skeleton-row::after {
|
|
826
|
+
content: '';
|
|
827
|
+
flex: 1;
|
|
828
|
+
height: 64px;
|
|
829
|
+
border-radius: var(--radius-sm);
|
|
830
|
+
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
|
|
831
|
+
background-size: 200% 100%;
|
|
832
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
.skeleton-table {
|
|
836
|
+
display: flex;
|
|
837
|
+
flex-direction: column;
|
|
838
|
+
gap: 8px;
|
|
839
|
+
padding: 12px 0;
|
|
840
|
+
}
|
|
841
|
+
.skeleton-table::before,
|
|
842
|
+
.skeleton-table::after {
|
|
843
|
+
content: '';
|
|
844
|
+
display: block;
|
|
845
|
+
height: 36px;
|
|
846
|
+
border-radius: var(--radius-sm);
|
|
847
|
+
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-hover) 50%, var(--bg-tertiary) 75%);
|
|
848
|
+
background-size: 200% 100%;
|
|
849
|
+
animation: shimmer 1.5s ease-in-out infinite;
|
|
850
|
+
}
|
|
851
|
+
.skeleton-table::after { width: 75%; opacity: .6; }
|
|
852
|
+
|
|
779
853
|
/* ── Utility ──────────────────────────────────────────────── */
|
|
780
854
|
|
|
781
855
|
.hidden { display: none !important; }
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const http = require('http');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const path = require('path');
|
|
7
8
|
|
|
@@ -12,6 +13,7 @@ const { listBackups } = require('../lib/core/backups');
|
|
|
12
13
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
13
14
|
const DEFAULT_PORT = 3120;
|
|
14
15
|
const MAX_PORT_RETRIES = 10;
|
|
16
|
+
const ALLOWED_HOSTS = /^(127\.0\.0\.1|localhost)(:\d+)?$/;
|
|
15
17
|
|
|
16
18
|
const MIME = {
|
|
17
19
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -78,7 +80,7 @@ function forbidden(res) { res.writeHead(403); res.end('Forbidden'); }
|
|
|
78
80
|
|
|
79
81
|
/* ── Static file server (strict) ────────────────────────────── */
|
|
80
82
|
|
|
81
|
-
function serveStatic(reqUrl, res) {
|
|
83
|
+
function serveStatic(reqUrl, res, serverToken) {
|
|
82
84
|
let pathname;
|
|
83
85
|
try { pathname = decodeURIComponent(new URL(reqUrl, 'http://x').pathname); }
|
|
84
86
|
catch { return notFound(res); }
|
|
@@ -94,6 +96,23 @@ function serveStatic(reqUrl, res) {
|
|
|
94
96
|
fs.readFile(resolved, (err, data) => {
|
|
95
97
|
if (err) return notFound(res);
|
|
96
98
|
const ext = path.extname(resolved).toLowerCase();
|
|
99
|
+
|
|
100
|
+
// Inject per-process token into index.html so the frontend can authenticate API calls
|
|
101
|
+
if (pathname === '/index.html' && serverToken) {
|
|
102
|
+
const html = data.toString('utf-8').replace(
|
|
103
|
+
'</head>',
|
|
104
|
+
`<script>window.__GUARD_TOKEN__="${serverToken}";</script></head>`
|
|
105
|
+
);
|
|
106
|
+
const buf = Buffer.from(html, 'utf-8');
|
|
107
|
+
res.writeHead(200, {
|
|
108
|
+
'Content-Type': MIME[ext] || 'text/html; charset=utf-8',
|
|
109
|
+
'Content-Length': buf.length,
|
|
110
|
+
'X-Content-Type-Options': 'nosniff',
|
|
111
|
+
'Cache-Control': 'no-store',
|
|
112
|
+
});
|
|
113
|
+
return res.end(buf);
|
|
114
|
+
}
|
|
115
|
+
|
|
97
116
|
res.writeHead(200, {
|
|
98
117
|
'Content-Type': MIME[ext] || 'application/octet-stream',
|
|
99
118
|
'Content-Length': data.length,
|
|
@@ -121,13 +140,21 @@ function handleApi(pathname, query, registry, res) {
|
|
|
121
140
|
}
|
|
122
141
|
|
|
123
142
|
if (pathname === '/api/page-data') {
|
|
143
|
+
const scope = query.get('scope');
|
|
124
144
|
const result = { timestamp: new Date().toISOString() };
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
|
|
146
|
+
if (!scope || scope === 'dashboard') {
|
|
147
|
+
try { result.dashboard = getDashboard(pp); }
|
|
148
|
+
catch (e) { result.dashboard = { error: e.message }; }
|
|
149
|
+
}
|
|
150
|
+
if (!scope || scope === 'doctor') {
|
|
151
|
+
try { result.doctor = runDiagnostics(pp); }
|
|
152
|
+
catch (e) { result.doctor = { error: e.message }; }
|
|
153
|
+
}
|
|
154
|
+
if (!scope || scope === 'backups') {
|
|
155
|
+
try { result.backups = listBackups(pp, { limit: 50 }).sources || []; }
|
|
156
|
+
catch (e) { result.backups = { error: e.message }; }
|
|
157
|
+
}
|
|
131
158
|
return json(res, result);
|
|
132
159
|
}
|
|
133
160
|
|
|
@@ -154,53 +181,93 @@ function handleApi(pathname, query, registry, res) {
|
|
|
154
181
|
|
|
155
182
|
/* ── Server ─────────────────────────────────────────────────── */
|
|
156
183
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Start the dashboard HTTP server.
|
|
186
|
+
* Can be called standalone (CLI) or embedded (from watcher).
|
|
187
|
+
*
|
|
188
|
+
* @param {string[]} paths - Project directories to serve
|
|
189
|
+
* @param {object} [opts]
|
|
190
|
+
* @param {number} [opts.port=3120] - Starting port
|
|
191
|
+
* @param {boolean} [opts.silent=false] - Suppress banner output
|
|
192
|
+
* @returns {Promise<{server: http.Server, port: number, registry: Map}>}
|
|
193
|
+
*/
|
|
194
|
+
function startDashboardServer(paths, opts = {}) {
|
|
195
|
+
const port = opts.port || DEFAULT_PORT;
|
|
196
|
+
const silent = opts.silent || false;
|
|
197
|
+
const registry = buildRegistry(paths);
|
|
198
|
+
const token = crypto.randomBytes(16).toString('hex');
|
|
199
|
+
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
let currentPort = port;
|
|
202
|
+
let retries = 0;
|
|
203
|
+
|
|
204
|
+
const server = http.createServer((req, res) => {
|
|
205
|
+
// DNS rebinding protection: reject unexpected Host headers
|
|
206
|
+
const host = req.headers.host || '';
|
|
207
|
+
if (!ALLOWED_HOSTS.test(host)) {
|
|
208
|
+
res.writeHead(403);
|
|
209
|
+
return res.end('Forbidden: invalid host');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (req.method !== 'GET') {
|
|
213
|
+
res.writeHead(405);
|
|
214
|
+
return res.end('Method Not Allowed');
|
|
215
|
+
}
|
|
216
|
+
let parsed;
|
|
217
|
+
try { parsed = new URL(req.url, `http://${host}`); }
|
|
218
|
+
catch { return notFound(res); }
|
|
219
|
+
|
|
220
|
+
// API endpoints require per-process token
|
|
221
|
+
if (parsed.pathname.startsWith('/api/')) {
|
|
222
|
+
const reqToken = parsed.searchParams.get('token');
|
|
223
|
+
if (reqToken !== token) {
|
|
224
|
+
res.writeHead(403);
|
|
225
|
+
return res.end('Forbidden: invalid token');
|
|
226
|
+
}
|
|
227
|
+
handleApi(parsed.pathname, parsed.searchParams, registry, res);
|
|
228
|
+
} else {
|
|
229
|
+
serveStatic(req.url, res, token);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
178
232
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
});
|
|
233
|
+
server.on('error', (err) => {
|
|
234
|
+
if (err.code === 'EADDRINUSE' && retries < MAX_PORT_RETRIES) {
|
|
235
|
+
retries++;
|
|
236
|
+
currentPort++;
|
|
237
|
+
server.listen(currentPort, '127.0.0.1');
|
|
238
|
+
} else {
|
|
239
|
+
reject(err);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
189
242
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
243
|
+
server.on('listening', () => {
|
|
244
|
+
const addr = server.address();
|
|
245
|
+
if (!silent) {
|
|
246
|
+
console.log('');
|
|
247
|
+
console.log(' Cursor Guard Dashboard');
|
|
248
|
+
console.log(' ─────────────────────────');
|
|
249
|
+
console.log(` URL: http://127.0.0.1:${addr.port}`);
|
|
250
|
+
console.log(` Projects: ${registry.size}`);
|
|
251
|
+
for (const p of registry.values()) {
|
|
252
|
+
console.log(` [${p.id}] ${p.name} → ${p._path}`);
|
|
253
|
+
}
|
|
254
|
+
console.log('');
|
|
255
|
+
}
|
|
256
|
+
resolve({ server, port: addr.port, registry });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
server.listen(currentPort, '127.0.0.1');
|
|
201
260
|
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/* ── CLI entry ─────────────────────────────────────────────── */
|
|
202
264
|
|
|
203
|
-
|
|
265
|
+
if (require.main === module) {
|
|
266
|
+
const args = parseCliArgs();
|
|
267
|
+
startDashboardServer(args.paths, { port: args.port }).catch(err => {
|
|
268
|
+
console.error('Failed to start:', err.message);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
});
|
|
204
271
|
}
|
|
205
272
|
|
|
206
|
-
|
|
273
|
+
module.exports = { startDashboardServer };
|
|
@@ -25,7 +25,7 @@ function isProcessAlive(pid) {
|
|
|
25
25
|
|
|
26
26
|
// ── Main ────────────────────────────────────────────────────────
|
|
27
27
|
|
|
28
|
-
async function runBackup(projectDir, intervalOverride) {
|
|
28
|
+
async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
29
29
|
const hasGit = gitAvailable();
|
|
30
30
|
const repo = hasGit && isGitRepo(projectDir);
|
|
31
31
|
const gDir = repo ? getGitDir(projectDir) : null;
|
|
@@ -177,6 +177,18 @@ async function runBackup(projectDir, intervalOverride) {
|
|
|
177
177
|
console.log(color.cyan(`[guard] Watching '${projectDir}' every ${interval}s (Ctrl+C to stop)`));
|
|
178
178
|
console.log(color.cyan(`[guard] Strategy: ${cfg.backup_strategy} | Ref: ${branchRef} | Retention: ${cfg.retention.mode}`));
|
|
179
179
|
console.log(color.cyan(`[guard] Log: ${logFilePath}`));
|
|
180
|
+
|
|
181
|
+
// Optional embedded dashboard
|
|
182
|
+
if (opts.dashboardPort) {
|
|
183
|
+
try {
|
|
184
|
+
const { startDashboardServer } = require('../dashboard/server');
|
|
185
|
+
const { port } = await startDashboardServer([projectDir], { port: opts.dashboardPort, silent: true });
|
|
186
|
+
console.log(color.cyan(`[guard] Dashboard: http://127.0.0.1:${port}`));
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.log(color.yellow(`[guard] Dashboard failed to start: ${e.message}`));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
180
192
|
console.log('');
|
|
181
193
|
|
|
182
194
|
// Main loop
|
|
@@ -313,25 +313,25 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
|
|
|
313
313
|
return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'retention disabled' };
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
const
|
|
316
|
+
const RS = '\x1e', US = '\x1f';
|
|
317
|
+
const out = git(['log', branchRef, `--format=%H${US}%aI${US}%cI${US}%s${US}%B${RS}`], { cwd, allowFail: true });
|
|
317
318
|
if (!out) {
|
|
318
319
|
return { kept: 0, pruned: 0, mode, rebuilt: false, skipped: true, reason: 'no commits on ref' };
|
|
319
320
|
}
|
|
320
321
|
|
|
321
|
-
const
|
|
322
|
+
const records = out.split(RS).filter(r => r.trim());
|
|
322
323
|
const guardCommits = [];
|
|
323
|
-
for (const
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
const
|
|
330
|
-
const
|
|
324
|
+
for (const record of records) {
|
|
325
|
+
const fields = record.split(US);
|
|
326
|
+
if (fields.length < 5) continue;
|
|
327
|
+
const hash = fields[0].trim();
|
|
328
|
+
const authorDate = fields[1].trim();
|
|
329
|
+
const committerDate = fields[2].trim();
|
|
330
|
+
const subject = fields[3].trim();
|
|
331
|
+
const fullBody = fields[4].trim();
|
|
331
332
|
if (subject.startsWith('guard: auto-backup') || subject.startsWith('guard: snapshot')) {
|
|
332
|
-
guardCommits.push({ hash, authorDate, committerDate, subject });
|
|
333
|
+
guardCommits.push({ hash, authorDate, committerDate, subject, fullBody });
|
|
333
334
|
}
|
|
334
|
-
// Non-guard commits are silently skipped; continue scanning older history
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
const total = guardCommits.length;
|
|
@@ -373,7 +373,8 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
|
|
|
373
373
|
if (!rootTree) {
|
|
374
374
|
return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'could not resolve root tree' };
|
|
375
375
|
}
|
|
376
|
-
|
|
376
|
+
const msgOf = (c) => c.fullBody || c.subject;
|
|
377
|
+
let prevHash = commitTreeWithDate(['commit-tree', rootTree, '-m', msgOf(toKeep[0])], toKeep[0]);
|
|
377
378
|
if (!prevHash) {
|
|
378
379
|
return { kept: total, pruned: 0, mode, rebuilt: false, reason: 'commit-tree failed for root' };
|
|
379
380
|
}
|
|
@@ -383,7 +384,7 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
|
|
|
383
384
|
if (!tree) {
|
|
384
385
|
return { kept: total, pruned: 0, mode, rebuilt: false, reason: `could not resolve tree for commit ${i}` };
|
|
385
386
|
}
|
|
386
|
-
prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', toKeep[i]
|
|
387
|
+
prevHash = commitTreeWithDate(['commit-tree', tree, '-p', prevHash, '-m', msgOf(toKeep[i])], toKeep[i]);
|
|
387
388
|
if (!prevHash) {
|
|
388
389
|
return { kept: total, pruned: 0, mode, rebuilt: false, reason: `commit-tree failed at index ${i}` };
|
|
389
390
|
}
|
|
@@ -92,6 +92,11 @@ function runFixes(projectDir, opts = {}) {
|
|
|
92
92
|
if (!existingIgnore.includes('.cursor-guard-backup')) {
|
|
93
93
|
missingPatterns.push('# cursor-guard shadow copies', '.cursor-guard-backup/', '');
|
|
94
94
|
}
|
|
95
|
+
const nmEntries = ['node_modules/', '.cursor/skills/**/node_modules/'];
|
|
96
|
+
const missingNm = nmEntries.filter(e => !existingIgnore.includes(e));
|
|
97
|
+
if (missingNm.length > 0) {
|
|
98
|
+
missingPatterns.push('# Dependencies', ...missingNm, '');
|
|
99
|
+
}
|
|
95
100
|
const missingSecrets = initCfg.secrets_patterns.filter(p => !existingIgnore.includes(p));
|
|
96
101
|
if (missingSecrets.length > 0) {
|
|
97
102
|
missingPatterns.push('# Secrets (cursor-guard defaults)', ...missingSecrets, '');
|
|
@@ -92,6 +92,33 @@ function runDiagnostics(projectDir) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// 5b. Git retention warning
|
|
96
|
+
if (repo) {
|
|
97
|
+
const guardRef = 'refs/guard/auto-backup';
|
|
98
|
+
const countStr = git(['rev-list', '--count', guardRef], { cwd: projectDir, allowFail: true });
|
|
99
|
+
const commitCount = countStr ? parseInt(countStr, 10) : 0;
|
|
100
|
+
if (commitCount > 500 && !cfg.git_retention.enabled) {
|
|
101
|
+
check('Git retention', 'WARN',
|
|
102
|
+
`${commitCount} backup commits and git_retention is disabled — set git_retention.enabled=true in .cursor-guard.json to auto-prune old snapshots`);
|
|
103
|
+
} else if (commitCount > 0 && cfg.git_retention.enabled) {
|
|
104
|
+
check('Git retention', 'PASS', `${commitCount} commits, auto-prune enabled (${cfg.git_retention.mode}: ${cfg.git_retention.mode === 'days' ? cfg.git_retention.days + 'd' : cfg.git_retention.max_count})`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 5c. Backup integrity — verify latest auto-backup tree is reachable
|
|
109
|
+
if (repo) {
|
|
110
|
+
const guardRef = 'refs/guard/auto-backup';
|
|
111
|
+
const latestHash = git(['rev-parse', '--verify', guardRef], { cwd: projectDir, allowFail: true });
|
|
112
|
+
if (latestHash) {
|
|
113
|
+
const treeType = git(['cat-file', '-t', `${latestHash}^{tree}`], { cwd: projectDir, allowFail: true });
|
|
114
|
+
if (treeType === 'tree') {
|
|
115
|
+
check('Backup integrity', 'PASS', `latest auto-backup commit ${latestHash.substring(0, 7)} tree is valid`);
|
|
116
|
+
} else {
|
|
117
|
+
check('Backup integrity', 'FAIL', `latest auto-backup commit ${latestHash.substring(0, 7)} tree is corrupted or unreachable`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
95
122
|
// 6. Guard refs
|
|
96
123
|
if (repo) {
|
|
97
124
|
const refs = git(['for-each-ref', 'refs/guard/', '--format=%(refname)'], { cwd: projectDir, allowFail: true });
|
|
@@ -11,10 +11,16 @@ const { createGitSnapshot, formatTimestamp, removeSecretsFromIndex } = require('
|
|
|
11
11
|
// ── Path safety ─────────────────────────────────────────────────
|
|
12
12
|
|
|
13
13
|
function validateRelativePath(file) {
|
|
14
|
+
if (!file || typeof file !== 'string') {
|
|
15
|
+
return { valid: false, error: 'file path is required' };
|
|
16
|
+
}
|
|
14
17
|
const normalized = path.normalize(file).replace(/\\/g, '/');
|
|
15
18
|
if (path.isAbsolute(normalized) || normalized.startsWith('..')) {
|
|
16
19
|
return { valid: false, error: 'file path must be relative and within project directory' };
|
|
17
20
|
}
|
|
21
|
+
if (normalized === '.' || normalized === '') {
|
|
22
|
+
return { valid: false, error: 'file path must target a specific file, not the project root' };
|
|
23
|
+
}
|
|
18
24
|
return { valid: true, normalized };
|
|
19
25
|
}
|
|
20
26
|
|
|
@@ -66,6 +72,10 @@ function restoreFile(projectDir, file, source, opts = {}) {
|
|
|
66
72
|
return { status: 'error', restoredFrom: source, error: pathCheck.error };
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
if (isToolPath(pathCheck.normalized)) {
|
|
76
|
+
return { status: 'error', restoredFrom: source, error: `refusing to restore protected path '${pathCheck.normalized}' — use restore_project instead` };
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
const preserveCurrent = resolvePreserve(projectDir, opts);
|
|
70
80
|
const repo = isGitRepo(projectDir);
|
|
71
81
|
const result = { restoredFrom: source };
|
|
@@ -299,15 +309,29 @@ function executeProjectRestore(projectDir, source, opts = {}) {
|
|
|
299
309
|
cwd: projectDir, stdio: 'pipe',
|
|
300
310
|
});
|
|
301
311
|
|
|
302
|
-
// Restore protected paths
|
|
312
|
+
// Restore protected paths: keep HEAD state, don't let old snapshots resurrect deleted files
|
|
303
313
|
const head = git(['rev-parse', 'HEAD'], { cwd: projectDir, allowFail: true });
|
|
304
314
|
if (head) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
315
|
+
const protectedPatterns = ['.cursor/', ...GUARD_CONFIGS];
|
|
316
|
+
for (const p of protectedPatterns) {
|
|
317
|
+
const existsInHead = git(['ls-tree', '--name-only', head, '--', p], { cwd: projectDir, allowFail: true });
|
|
318
|
+
if (existsInHead) {
|
|
319
|
+
try {
|
|
320
|
+
execFileSync('git', ['restore', `--source=HEAD`, '--', p], {
|
|
321
|
+
cwd: projectDir, stdio: 'pipe',
|
|
322
|
+
});
|
|
323
|
+
} catch { /* restore failed, keep whatever is there */ }
|
|
324
|
+
} else {
|
|
325
|
+
// HEAD intentionally doesn't have this path — remove if old snapshot resurrected it
|
|
326
|
+
const fullPath = path.join(projectDir, p);
|
|
327
|
+
try {
|
|
328
|
+
if (p.endsWith('/')) {
|
|
329
|
+
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
330
|
+
} else {
|
|
331
|
+
fs.unlinkSync(fullPath);
|
|
332
|
+
}
|
|
333
|
+
} catch { /* already gone */ }
|
|
334
|
+
}
|
|
311
335
|
}
|
|
312
336
|
}
|
|
313
337
|
|
|
@@ -159,13 +159,39 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
159
159
|
const fileName = filePart.split('\t').pop();
|
|
160
160
|
groups[key].push(fileName);
|
|
161
161
|
}
|
|
162
|
+
|
|
163
|
+
const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', parentTree, newTree], { cwd, allowFail: true });
|
|
164
|
+
const stats = {};
|
|
165
|
+
if (numstatOut) {
|
|
166
|
+
for (const line of numstatOut.split('\n').filter(Boolean)) {
|
|
167
|
+
const [add, del, ...nameParts] = line.split('\t');
|
|
168
|
+
const fname = nameParts.join('\t');
|
|
169
|
+
if (add !== '-') stats[fname] = `+${add} -${del}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function fmtFiles(arr) {
|
|
174
|
+
return arr.slice(0, 5).map(f => {
|
|
175
|
+
const s = stats[f];
|
|
176
|
+
return s ? `${f} (${s})` : f;
|
|
177
|
+
}).join(', ');
|
|
178
|
+
}
|
|
179
|
+
|
|
162
180
|
const parts = [];
|
|
163
|
-
if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${groups.M.
|
|
164
|
-
if (groups.A.length) parts.push(`Added ${groups.A.length}: ${groups.A.
|
|
165
|
-
if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${groups.D.
|
|
166
|
-
if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${groups.R.
|
|
181
|
+
if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${fmtFiles(groups.M)}${groups.M.length > 5 ? ', ...' : ''}`);
|
|
182
|
+
if (groups.A.length) parts.push(`Added ${groups.A.length}: ${fmtFiles(groups.A)}${groups.A.length > 5 ? ', ...' : ''}`);
|
|
183
|
+
if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${fmtFiles(groups.D)}${groups.D.length > 5 ? ', ...' : ''}`);
|
|
184
|
+
if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${fmtFiles(groups.R)}${groups.R.length > 5 ? ', ...' : ''}`);
|
|
167
185
|
if (parts.length) incrementalSummary = parts.join('; ');
|
|
168
186
|
}
|
|
187
|
+
} else {
|
|
188
|
+
const lsInitial = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
|
|
189
|
+
if (lsInitial) {
|
|
190
|
+
const files = lsInitial.split('\n').filter(Boolean);
|
|
191
|
+
changedCount = files.length;
|
|
192
|
+
const sample = files.slice(0, 5).join(', ');
|
|
193
|
+
incrementalSummary = `Added ${files.length}: ${sample}${files.length > 5 ? ', ...' : ''}`;
|
|
194
|
+
}
|
|
169
195
|
}
|
|
170
196
|
|
|
171
197
|
// Override context summary with the accurate incremental one
|