cursor-guard 4.5.5 → 4.5.7
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 +54 -2
- package/package.json +1 -1
- package/references/bin/cursor-guard-init.js +20 -7
- package/references/dashboard/public/app.js +155 -47
- package/references/dashboard/public/index.html +11 -0
- package/references/dashboard/public/style.css +121 -0
- package/references/dashboard/server.js +35 -8
- package/references/lib/core/snapshot.js +6 -4
- package/references/lib/utils.js +6 -1
package/ROADMAP.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
> 本文档描述 cursor-guard 从 V2 到 V7 的长期演进方向。
|
|
4
4
|
> 每一代向下兼容,低版本功能永远不废弃。
|
|
5
5
|
>
|
|
6
|
-
> **当前版本**:`V4.5.
|
|
7
|
-
> **文档状态**:`V2` ~ `V4.5.
|
|
6
|
+
> **当前版本**:`V4.5.7`(V4 最终版)
|
|
7
|
+
> **文档状态**:`V2` ~ `V4.5.7` 已完成交付(含 V5 intent/audit 基础),`V5` 主体规划中
|
|
8
8
|
|
|
9
9
|
## 阅读导航
|
|
10
10
|
|
|
@@ -462,6 +462,8 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
|
|
|
462
462
|
| V4.5.2 | **告警结构化文件列表**:见下方详细说明 | ✅ |
|
|
463
463
|
| V4.5.3 | **告警历史 UX 优化 + 备份结构化文件表格**:见下方详细说明 | ✅ |
|
|
464
464
|
| V4.5.4 | **Shadow 硬链接增量优化 + always_watch 强保护模式**:见下方详细说明 | ✅ |
|
|
465
|
+
| V4.5.6 | **Bug 修复 + 告警 UX + init 优化**:见下方详细说明 | ✅ |
|
|
466
|
+
| V4.5.7 | **文件详情 Modal 修复 + Dashboard 端口复用**:见下方详细说明 | ✅ |
|
|
465
467
|
|
|
466
468
|
#### V4.4.1 详细内容
|
|
467
469
|
|
|
@@ -619,6 +621,56 @@ V4 经过 4 轮系统性代码审查,修复了以下关键问题:
|
|
|
619
621
|
|
|
620
622
|
> 这个特性直接填补了 V4 最大的架构缺口——"Watcher 停止 = 裸奔"。详见下方"V4 遗留的架构缺口"中该条目已标记为 **已解决**。
|
|
621
623
|
|
|
624
|
+
#### V4.5.6 详细内容
|
|
625
|
+
|
|
626
|
+
**Bug 修复**:
|
|
627
|
+
|
|
628
|
+
| 问题 | 严重度 | 根因 | 修复 |
|
|
629
|
+
|------|--------|------|------|
|
|
630
|
+
| Shadow hard-link 永远为 0 | 中 | `createShadowCopy` 中 `fs.mkdirSync(snapDir)` 在 `findPreviousSnapshot(backupDir)` 之前执行。新创建的空目录时间戳最新,被当作"上一个快照"返回,导致硬链接永远找不到文件 | 调换两行顺序:先 `findPreviousSnapshot`,再 `mkdirSync` |
|
|
631
|
+
| changedFiles 包含 ignore 路径 | 低 | `createGitSnapshot` 的 `diff-tree` 解析未按 `cfg.ignore` 过滤,`.cursor/` 等被忽略路径的文件混入告警的 files 数组和 changedCount | 在 diff-tree 解析循环中增加 `matchesAny(cfg.ignore, fileName)` 过滤;initial snapshot 的 `ls-tree` 列表同样过滤 |
|
|
632
|
+
|
|
633
|
+
**告警 UX 优化**:
|
|
634
|
+
|
|
635
|
+
| 改进 | 说明 |
|
|
636
|
+
|------|------|
|
|
637
|
+
| 告警详情增加文件类型摘要 | 活跃告警和历史告警均显示 "新增 N · 修改 N · 删除 N" 分类统计(`alertFileBreakdown` 辅助函数) |
|
|
638
|
+
| 活跃告警增加建议操作 | 告警卡片底部显示 "建议检查近期变更,并考虑手动创建快照" 提示文字 |
|
|
639
|
+
| i18n 补全 | 新增 `alert.breakdown`、`alert.suggestion` 双语 key |
|
|
640
|
+
|
|
641
|
+
**Dashboard UX 优化**:
|
|
642
|
+
|
|
643
|
+
| 改进 | 说明 |
|
|
644
|
+
|------|------|
|
|
645
|
+
| 文件详情弹出框(Modal) | 告警的"展开文件详情"改为全屏居中 Modal 弹窗,替代原本卡片内联展开(太小看不清)。Modal 支持排序、可滚动、720px 宽 |
|
|
646
|
+
| 每个文件可复制恢复命令 | Modal 和 Drawer 的文件表格中,每行增加"复制命令"按钮,一键生成 `restore_file({ path, file, source })` MCP 命令 |
|
|
647
|
+
| 备份表补全操作意图 | 备份表 summary 列:有 intent 时显示 "操作意图: xxx"(蓝色标签),无 intent 时显示 trigger 类型(自动/手动/恢复前)灰色文字 |
|
|
648
|
+
|
|
649
|
+
**工具链优化**:
|
|
650
|
+
|
|
651
|
+
| 改进 | 说明 |
|
|
652
|
+
|------|------|
|
|
653
|
+
| `cursor-guard-init` 自动创建配置 | init 流程新增 Step 4/5:若 `.cursor-guard.json` 不存在,自动从 `cursor-guard.example.json` 复制为项目根默认配置。升级场景下保留现有配置 |
|
|
654
|
+
| `backup_interval_seconds` 兼容别名 | `loadConfig` 支持 `backup_interval_seconds` 作为 `auto_backup_interval_seconds` 的别名(带 deprecation 警告) |
|
|
655
|
+
|
|
656
|
+
#### V4.5.7 详细内容
|
|
657
|
+
|
|
658
|
+
**Bug 修复**:
|
|
659
|
+
|
|
660
|
+
| 问题 | 根因 | 修复 |
|
|
661
|
+
|------|------|------|
|
|
662
|
+
| 告警"查看文件详情"Modal 点不开 | 事件处理中 `state.pageData?.alerts` 路径错误,告警数据实际存储在 `state.pageData.dashboard.alerts` | 修正为 `state.pageData?.dashboard?.alerts`,同时修正 projectPath 取值路径 |
|
|
663
|
+
|
|
664
|
+
**Dashboard 服务端口复用(单例模式)**:
|
|
665
|
+
|
|
666
|
+
| 改进 | 说明 |
|
|
667
|
+
|------|------|
|
|
668
|
+
| 模块级单例 `_instance` | `startDashboardServer` 首次调用时创建 HTTP 服务并缓存到 `_instance`(含 server/port/registry/token) |
|
|
669
|
+
| 热加载新项目 | 后续调用检测到 `_instance` 已存在时,不创建新服务,而是调用 `_mergeProjects` 将新路径合并到已有 registry 中 |
|
|
670
|
+
| 去重机制 | `_mergeProjects` 按 `_path.toLowerCase()` 去重,相同项目不重复注册 |
|
|
671
|
+
| 透明生效 | registry 是引用传递,已运行的 HTTP handler 闭包自动读取最新 registry,无需重启服务 |
|
|
672
|
+
| 导出 `getInstance()` | 外部可通过 `getInstance()` 获取当前运行实例的 server/port/registry 信息 |
|
|
673
|
+
|
|
622
674
|
#### V4.5.x 新增配置参考
|
|
623
675
|
|
|
624
676
|
| 字段 | 类型 | 默认值 | 引入版本 | 说明 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-guard",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.7",
|
|
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",
|
|
@@ -64,7 +64,7 @@ if (fs.existsSync(configPath)) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
// Step 1: Copy skill files (excluding node_modules and .git)
|
|
67
|
-
console.log(' [1/
|
|
67
|
+
console.log(' [1/5] Copying skill files...');
|
|
68
68
|
if (fs.existsSync(skillTarget)) {
|
|
69
69
|
fs.rmSync(skillTarget, { recursive: true, force: true });
|
|
70
70
|
}
|
|
@@ -72,7 +72,7 @@ copyRecursive(skillSource, skillTarget);
|
|
|
72
72
|
console.log(' Done.');
|
|
73
73
|
|
|
74
74
|
// Step 2: Install MCP dependencies in skill directory
|
|
75
|
-
console.log(' [2/
|
|
75
|
+
console.log(' [2/5] Installing MCP dependencies...');
|
|
76
76
|
try {
|
|
77
77
|
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
78
78
|
execFileSync(npmCmd, ['install', '--omit=dev', '--ignore-scripts'], {
|
|
@@ -86,7 +86,7 @@ try {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
// Step 3: Add .gitignore entries for skill node_modules
|
|
89
|
-
console.log(' [3/
|
|
89
|
+
console.log(' [3/5] Updating .gitignore...');
|
|
90
90
|
const gitignorePath = path.join(projectDir, '.gitignore');
|
|
91
91
|
const entries = ['node_modules/', '.cursor/skills/**/node_modules/'];
|
|
92
92
|
let gitignoreUpdated = false;
|
|
@@ -106,8 +106,22 @@ if (!isGlobal) {
|
|
|
106
106
|
console.log(' Skipped (global install, not inside a project).');
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
// Step 4:
|
|
110
|
-
console.log(' [4/
|
|
109
|
+
// Step 4: Create default config if missing
|
|
110
|
+
console.log(' [4/5] Checking .cursor-guard.json...');
|
|
111
|
+
if (!fs.existsSync(configPath)) {
|
|
112
|
+
const examplePath = path.join(skillTarget, 'references', 'cursor-guard.example.json');
|
|
113
|
+
if (fs.existsSync(examplePath)) {
|
|
114
|
+
fs.copyFileSync(examplePath, configPath);
|
|
115
|
+
console.log(' Created .cursor-guard.json with default settings.');
|
|
116
|
+
} else {
|
|
117
|
+
console.log(' Warning: example config not found, skipping.');
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
console.log(' Already exists — preserved.');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Step 5: Summary
|
|
124
|
+
console.log(' [5/5] Verifying...');
|
|
111
125
|
const serverExists = fs.existsSync(path.join(skillTarget, 'references', 'mcp', 'server.js'));
|
|
112
126
|
const sdkExists = fs.existsSync(path.join(skillTarget, 'node_modules', '@modelcontextprotocol', 'sdk'));
|
|
113
127
|
const skillMdExists = fs.existsSync(path.join(skillTarget, 'SKILL.md'));
|
|
@@ -130,8 +144,7 @@ console.log(' If MCP was already configured, restart Cursor (or Ctrl+Shift+P ->
|
|
|
130
144
|
console.log(' "Developer: Reload Window") to load the updated MCP server.\n');
|
|
131
145
|
console.log(' Next steps:');
|
|
132
146
|
console.log(' 1. The skill activates automatically in Cursor Agent conversations.');
|
|
133
|
-
console.log(' 2. (Optional)
|
|
134
|
-
console.log(` cp "${path.join(skillTarget, 'references', 'cursor-guard.example.json')}" .cursor-guard.json`);
|
|
147
|
+
console.log(' 2. (Optional) Edit .cursor-guard.json to customize protect/ignore patterns.');
|
|
135
148
|
console.log(' 3. (Optional) Enable MCP — add to .cursor/mcp.json:');
|
|
136
149
|
console.log(` { "mcpServers": { "cursor-guard": { "command": "node", "args": ["${path.join(skillTarget, 'references', 'mcp', 'server.js').replace(/\\/g, '/')}"] } } }`);
|
|
137
150
|
console.log(' 4. (Optional) Start auto-backup:');
|
|
@@ -56,6 +56,13 @@ const I18N = {
|
|
|
56
56
|
'alert.action.added': 'Added',
|
|
57
57
|
'alert.action.deleted': 'Deleted',
|
|
58
58
|
'alert.action.renamed': 'Renamed',
|
|
59
|
+
'alert.breakdown': '{added} added, {modified} modified, {deleted} deleted',
|
|
60
|
+
'alert.suggestion': 'Check recent changes and consider creating a manual snapshot',
|
|
61
|
+
'alert.viewFiles': 'View file details ({n} files)',
|
|
62
|
+
'modal.alertFiles': 'Alert File Details',
|
|
63
|
+
'modal.col.restore': 'Restore',
|
|
64
|
+
'modal.copyRestore': 'Copy cmd',
|
|
65
|
+
'modal.copied': 'Copied!',
|
|
59
66
|
|
|
60
67
|
'backups.gitCommits': 'Git Commits',
|
|
61
68
|
'backups.shadowSnapshots': 'Shadow Snapshots',
|
|
@@ -264,6 +271,13 @@ const I18N = {
|
|
|
264
271
|
'alert.action.added': '新增',
|
|
265
272
|
'alert.action.deleted': '删除',
|
|
266
273
|
'alert.action.renamed': '重命名',
|
|
274
|
+
'alert.breakdown': '新增 {added} · 修改 {modified} · 删除 {deleted}',
|
|
275
|
+
'alert.suggestion': '建议检查近期变更,并考虑手动创建快照',
|
|
276
|
+
'alert.viewFiles': '查看文件详情({n} 个文件)',
|
|
277
|
+
'modal.alertFiles': '告警文件详情',
|
|
278
|
+
'modal.col.restore': '恢复',
|
|
279
|
+
'modal.copyRestore': '复制命令',
|
|
280
|
+
'modal.copied': '已复制!',
|
|
267
281
|
|
|
268
282
|
'backups.gitCommits': 'Git 提交数',
|
|
269
283
|
'backups.shadowSnapshots': '影子快照',
|
|
@@ -846,19 +860,32 @@ function renderWatcherCard(watcher) {
|
|
|
846
860
|
`;
|
|
847
861
|
}
|
|
848
862
|
|
|
863
|
+
function alertFileBreakdown(files) {
|
|
864
|
+
if (!Array.isArray(files) || files.length === 0) return '';
|
|
865
|
+
let added = 0, modified = 0, deleted = 0;
|
|
866
|
+
for (const f of files) {
|
|
867
|
+
if (f.action === 'added') added++;
|
|
868
|
+
else if (f.action === 'deleted') deleted++;
|
|
869
|
+
else modified++;
|
|
870
|
+
}
|
|
871
|
+
return t('alert.breakdown', { added, modified, deleted });
|
|
872
|
+
}
|
|
873
|
+
|
|
849
874
|
function renderAlertCard(alerts) {
|
|
850
875
|
const el = $('#card-alert');
|
|
851
876
|
if (!alerts?.active) {
|
|
852
877
|
let historyHtml = '';
|
|
853
878
|
if (state.alertHistory.length > 0) {
|
|
854
879
|
const count = state.alertHistory.length;
|
|
855
|
-
const rows = state.alertHistory.slice(-5).reverse().map(h =>
|
|
856
|
-
|
|
857
|
-
|
|
880
|
+
const rows = state.alertHistory.slice(-5).reverse().map(h => {
|
|
881
|
+
const breakdown = alertFileBreakdown(h.files);
|
|
882
|
+
return `<div class="alert-history-row text-sm text-muted">
|
|
883
|
+
<span class="alert-history-time">${esc(formatTime(h.timestamp))}</span>
|
|
858
884
|
<span>${t('alert.detail', { count: h.fileCount, window: h.windowSeconds, threshold: h.threshold })}</span>
|
|
885
|
+
${breakdown ? `<span class="alert-history-breakdown">${esc(breakdown)}</span>` : ''}
|
|
859
886
|
<span class="badge badge-expired">${t('alert.expired')}</span>
|
|
860
|
-
</div
|
|
861
|
-
).join('');
|
|
887
|
+
</div>`;
|
|
888
|
+
}).join('');
|
|
862
889
|
historyHtml = `
|
|
863
890
|
<div class="alert-history-toggle-wrap">
|
|
864
891
|
<button class="alert-history-toggle-btn text-sm text-muted" data-alert-history-toggle>${t('alert.historyCount', { n: count })}</button>
|
|
@@ -893,25 +920,9 @@ function renderAlertCard(alerts) {
|
|
|
893
920
|
const files = Array.isArray(a.files) ? a.files : [];
|
|
894
921
|
let filesHtml = '';
|
|
895
922
|
if (files.length > 0) {
|
|
896
|
-
const actionBadge = (action) => {
|
|
897
|
-
const cls = action === 'deleted' ? 'alert-action-deleted'
|
|
898
|
-
: action === 'added' ? 'alert-action-added'
|
|
899
|
-
: action === 'renamed' ? 'alert-action-renamed'
|
|
900
|
-
: 'alert-action-modified';
|
|
901
|
-
return `<span class="alert-action-badge ${cls}">${t('alert.action.' + action)}</span>`;
|
|
902
|
-
};
|
|
903
|
-
const rows = files.map(f =>
|
|
904
|
-
`<tr><td class="text-mono alert-file-path">${esc(f.path)}</td><td>${actionBadge(f.action)}</td><td class="text-mono alert-file-changes">+${f.added || 0} -${f.deleted || 0}</td></tr>`
|
|
905
|
-
).join('');
|
|
906
923
|
filesHtml = `
|
|
907
924
|
<div class="alert-files-section">
|
|
908
|
-
<button class="alert-files-toggle" data-alert-files-
|
|
909
|
-
<div class="alert-files-table-wrap alert-files-hidden">
|
|
910
|
-
<table class="alert-files-table">
|
|
911
|
-
<thead><tr><th>${t('alert.col.file')}</th><th>${t('alert.col.action')}</th><th>${t('alert.col.changes')}</th></tr></thead>
|
|
912
|
-
<tbody>${rows}</tbody>
|
|
913
|
-
</table>
|
|
914
|
-
</div>
|
|
925
|
+
<button class="alert-files-toggle" data-alert-files-modal>${t('alert.viewFiles', { n: files.length })}</button>
|
|
915
926
|
</div>
|
|
916
927
|
`;
|
|
917
928
|
}
|
|
@@ -923,6 +934,8 @@ function renderAlertCard(alerts) {
|
|
|
923
934
|
<div class="alert-detail-row"><span class="alert-detail-label">${t('alert.triggered')}</span><span>${esc(triggeredAt)}</span></div>
|
|
924
935
|
<div class="alert-detail-row"><span class="alert-detail-label">${t('alert.expires')}</span><span class="alert-countdown">${esc(remainDisplay)}</span></div>
|
|
925
936
|
<div class="alert-detail-row alert-numbers">${esc(detailText)}</div>
|
|
937
|
+
${alertFileBreakdown(files) ? `<div class="alert-detail-row alert-breakdown text-sm">${esc(alertFileBreakdown(files))}</div>` : ''}
|
|
938
|
+
<div class="alert-detail-row alert-suggestion text-sm text-muted">${t('alert.suggestion')}</div>
|
|
926
939
|
</div>
|
|
927
940
|
${filesHtml}
|
|
928
941
|
`;
|
|
@@ -1046,11 +1059,14 @@ function formatSummaryCell(b) {
|
|
|
1046
1059
|
line2 = `<div class="summary-restore-ctx">${label}<span class="text-mono">${esc(b.from)}</span> → <span class="text-mono">${esc(b.restoreTo)}</span></div>`;
|
|
1047
1060
|
} else if (b.intent) {
|
|
1048
1061
|
const intentShort = b.intent.length > 70 ? b.intent.substring(0, 67) + '...' : b.intent;
|
|
1049
|
-
line2 = `<div class="summary-intent">${esc(intentShort)}</div>`;
|
|
1062
|
+
line2 = `<div class="summary-intent"><span class="summary-intent-label">${t('drawer.field.intent')}:</span> ${esc(intentShort)}</div>`;
|
|
1050
1063
|
} else if (b.message && !b.message.startsWith('guard:')) {
|
|
1051
1064
|
const msgShort = b.message.length > 70 ? b.message.substring(0, 67) + '...' : b.message;
|
|
1052
1065
|
line2 = `<div class="summary-message">${esc(msgShort)}</div>`;
|
|
1053
1066
|
}
|
|
1067
|
+
if (b.trigger && !line2) {
|
|
1068
|
+
line2 = `<div class="summary-trigger text-sm text-muted">${t('trigger.' + b.trigger)}</div>`;
|
|
1069
|
+
}
|
|
1054
1070
|
|
|
1055
1071
|
let line3 = '';
|
|
1056
1072
|
if (b.summary) {
|
|
@@ -1182,6 +1198,67 @@ function renderSectionError(elementId, msg) {
|
|
|
1182
1198
|
el.innerHTML = `<div class="error-panel"><div class="error-icon">⚠</div><p>${esc(msg || t('error.sectionFailed'))}</p></div>`;
|
|
1183
1199
|
}
|
|
1184
1200
|
|
|
1201
|
+
/* ── File Detail Modal ────────────────────────────────────── */
|
|
1202
|
+
|
|
1203
|
+
function openFileModal(title, files, projectPath, commitHash) {
|
|
1204
|
+
$('#file-modal-title').textContent = title;
|
|
1205
|
+
const body = $('#file-modal-body');
|
|
1206
|
+
let sortKey = 'changes';
|
|
1207
|
+
const render = () => {
|
|
1208
|
+
const sorted = [...files];
|
|
1209
|
+
if (sortKey === 'path') sorted.sort((a, b) => a.path.localeCompare(b.path));
|
|
1210
|
+
else if (sortKey === 'action') sorted.sort((a, b) => a.action.localeCompare(b.action));
|
|
1211
|
+
else sorted.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
|
|
1212
|
+
|
|
1213
|
+
const rows = sorted.map(f => {
|
|
1214
|
+
const restoreCmd = commitHash
|
|
1215
|
+
? `restore_file({ path: "${projectPath || ''}", file: "${f.path}", source: "${commitHash}" })`
|
|
1216
|
+
: '';
|
|
1217
|
+
return `<tr>
|
|
1218
|
+
<td class="text-mono modal-file-path" title="${esc(f.path)}">${esc(f.path)}</td>
|
|
1219
|
+
<td>${formatFileActionBadge(f.action)}</td>
|
|
1220
|
+
<td class="text-mono modal-file-changes">+${f.added || 0} -${f.deleted || 0}</td>
|
|
1221
|
+
${commitHash ? `<td><button class="modal-restore-btn" data-restore-cmd="${esc(restoreCmd)}">${t('modal.copyRestore')}</button></td>` : ''}
|
|
1222
|
+
</tr>`;
|
|
1223
|
+
}).join('');
|
|
1224
|
+
|
|
1225
|
+
body.innerHTML = `<table>
|
|
1226
|
+
<thead><tr>
|
|
1227
|
+
<th data-msort="path">${t('alert.col.file')} ↕</th>
|
|
1228
|
+
<th data-msort="action">${t('alert.col.action')} ↕</th>
|
|
1229
|
+
<th data-msort="changes">${t('alert.col.changes')} ↕</th>
|
|
1230
|
+
${commitHash ? `<th>${t('modal.col.restore')}</th>` : ''}
|
|
1231
|
+
</tr></thead>
|
|
1232
|
+
<tbody>${rows}</tbody>
|
|
1233
|
+
</table>`;
|
|
1234
|
+
};
|
|
1235
|
+
render();
|
|
1236
|
+
|
|
1237
|
+
body.addEventListener('click', (e) => {
|
|
1238
|
+
const th = e.target.closest('[data-msort]');
|
|
1239
|
+
if (th) {
|
|
1240
|
+
sortKey = th.dataset.msort;
|
|
1241
|
+
render();
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
const btn = e.target.closest('[data-restore-cmd]');
|
|
1245
|
+
if (btn) {
|
|
1246
|
+
copyText(btn.dataset.restoreCmd);
|
|
1247
|
+
btn.textContent = t('modal.copied');
|
|
1248
|
+
btn.classList.add('copied');
|
|
1249
|
+
setTimeout(() => { btn.textContent = t('modal.copyRestore'); btn.classList.remove('copied'); }, 1500);
|
|
1250
|
+
}
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
$('#file-modal-overlay').classList.add('active');
|
|
1254
|
+
document.body.style.overflow = 'hidden';
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function closeFileModal() {
|
|
1258
|
+
$('#file-modal-overlay').classList.remove('active');
|
|
1259
|
+
document.body.style.overflow = state.drawerOpen ? 'hidden' : '';
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1185
1262
|
/* ── Drawers ──────────────────────────────────────────────── */
|
|
1186
1263
|
|
|
1187
1264
|
function openDrawer(name) {
|
|
@@ -1199,19 +1276,27 @@ function closeDrawer() {
|
|
|
1199
1276
|
state.drawerOpen = null;
|
|
1200
1277
|
}
|
|
1201
1278
|
|
|
1202
|
-
function renderDrawerFilesTable(files, sortKey) {
|
|
1279
|
+
function renderDrawerFilesTable(files, sortKey, commitHash, projectPath) {
|
|
1203
1280
|
const sorted = [...files];
|
|
1204
1281
|
if (sortKey === 'path') sorted.sort((a, b) => a.path.localeCompare(b.path));
|
|
1205
1282
|
else if (sortKey === 'action') sorted.sort((a, b) => a.action.localeCompare(b.action));
|
|
1206
1283
|
else sorted.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1209
|
-
|
|
1284
|
+
const hasRestore = !!commitHash;
|
|
1285
|
+
const rows = sorted.map(f => {
|
|
1286
|
+
const cmd = hasRestore ? `restore_file({ path: "${projectPath || ''}", file: "${f.path}", source: "${commitHash}" })` : '';
|
|
1287
|
+
return `<tr>
|
|
1288
|
+
<td class="text-mono drawer-file-path">${esc(f.path)}</td>
|
|
1289
|
+
<td>${formatFileActionBadge(f.action)}</td>
|
|
1290
|
+
<td class="text-mono drawer-file-changes">+${f.added} -${f.deleted}</td>
|
|
1291
|
+
${hasRestore ? `<td><button class="modal-restore-btn" data-restore-cmd="${esc(cmd)}">${t('modal.copyRestore')}</button></td>` : ''}
|
|
1292
|
+
</tr>`;
|
|
1293
|
+
}).join('');
|
|
1210
1294
|
return `<table class="drawer-files-table">
|
|
1211
1295
|
<thead><tr>
|
|
1212
1296
|
<th data-sort="path" class="drawer-sort-header">${t('alert.col.file')} ↕</th>
|
|
1213
1297
|
<th data-sort="action" class="drawer-sort-header">${t('alert.col.action')} ↕</th>
|
|
1214
1298
|
<th data-sort="changes" class="drawer-sort-header">${t('alert.col.changes')} ↕</th>
|
|
1299
|
+
${hasRestore ? `<th>${t('modal.col.restore')}</th>` : ''}
|
|
1215
1300
|
</tr></thead>
|
|
1216
1301
|
<tbody>${rows}</tbody>
|
|
1217
1302
|
</table>`;
|
|
@@ -1292,28 +1377,40 @@ function openRestoreDrawer(backup) {
|
|
|
1292
1377
|
wrap.classList.toggle('hidden');
|
|
1293
1378
|
});
|
|
1294
1379
|
|
|
1380
|
+
const projPath = backup.path || state.pageData?.status?.config?.path || '';
|
|
1381
|
+
|
|
1295
1382
|
// Lazy-load full file list for summary section
|
|
1296
1383
|
if (backup.summary && isGit && hash) {
|
|
1297
1384
|
let currentFiles = [];
|
|
1298
1385
|
let currentSort = 'changes';
|
|
1386
|
+
const setupContainer = (container) => {
|
|
1387
|
+
container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort, hash, projPath);
|
|
1388
|
+
container.addEventListener('click', (e) => {
|
|
1389
|
+
const th = e.target.closest('[data-sort]');
|
|
1390
|
+
if (th) {
|
|
1391
|
+
currentSort = th.dataset.sort;
|
|
1392
|
+
container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort, hash, projPath);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const restoreBtn = e.target.closest('[data-restore-cmd]');
|
|
1396
|
+
if (restoreBtn) {
|
|
1397
|
+
copyText(restoreBtn.dataset.restoreCmd);
|
|
1398
|
+
restoreBtn.textContent = t('modal.copied');
|
|
1399
|
+
restoreBtn.classList.add('copied');
|
|
1400
|
+
setTimeout(() => { restoreBtn.textContent = t('modal.copyRestore'); restoreBtn.classList.remove('copied'); }, 1500);
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
};
|
|
1299
1404
|
fetchBackupFiles(hash).then(files => {
|
|
1300
1405
|
currentFiles = files.length > 0 ? files : parseSummaryToFiles(backup.summary);
|
|
1301
1406
|
const container = body.querySelector('#drawer-files-container');
|
|
1302
|
-
if (container)
|
|
1303
|
-
container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort);
|
|
1304
|
-
container.addEventListener('click', (e) => {
|
|
1305
|
-
const th = e.target.closest('[data-sort]');
|
|
1306
|
-
if (!th) return;
|
|
1307
|
-
currentSort = th.dataset.sort;
|
|
1308
|
-
container.innerHTML = renderDrawerFilesTable(currentFiles, currentSort);
|
|
1309
|
-
});
|
|
1310
|
-
}
|
|
1407
|
+
if (container) setupContainer(container);
|
|
1311
1408
|
});
|
|
1312
1409
|
} else if (backup.summary) {
|
|
1313
1410
|
const fallback = parseSummaryToFiles(backup.summary);
|
|
1314
1411
|
const container = body.querySelector('#drawer-files-container');
|
|
1315
1412
|
if (container && fallback.length > 0) {
|
|
1316
|
-
container.innerHTML = renderDrawerFilesTable(fallback, 'changes');
|
|
1413
|
+
container.innerHTML = renderDrawerFilesTable(fallback, 'changes', hash, projPath);
|
|
1317
1414
|
} else if (container) {
|
|
1318
1415
|
const translated = backup.summary.split('; ').map(s => translateSummary(s)).join('\n');
|
|
1319
1416
|
container.innerHTML = `<pre class="restore-field-value text-mono summary-pre">${esc(translated)}</pre>`;
|
|
@@ -1432,7 +1529,7 @@ function setupEvents() {
|
|
|
1432
1529
|
if (backup) openRestoreDrawer(backup);
|
|
1433
1530
|
});
|
|
1434
1531
|
|
|
1435
|
-
// Alert history +
|
|
1532
|
+
// Alert history toggle + file modal (event delegation)
|
|
1436
1533
|
$('#card-alert').addEventListener('click', (e) => {
|
|
1437
1534
|
const historyToggle = e.target.closest('[data-alert-history-toggle]');
|
|
1438
1535
|
if (historyToggle) {
|
|
@@ -1441,14 +1538,16 @@ function setupEvents() {
|
|
|
1441
1538
|
if (history) history.classList.toggle('alert-history-collapsed');
|
|
1442
1539
|
return;
|
|
1443
1540
|
}
|
|
1444
|
-
const
|
|
1445
|
-
if (
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1541
|
+
const modalBtn = e.target.closest('[data-alert-files-modal]');
|
|
1542
|
+
if (modalBtn) {
|
|
1543
|
+
const alerts = state.pageData?.dashboard?.alerts;
|
|
1544
|
+
const files = alerts?.latest?.files || [];
|
|
1545
|
+
if (files.length > 0) {
|
|
1546
|
+
const proj = state.pageData?.dashboard?.watcher?.path || '';
|
|
1547
|
+
openFileModal(t('modal.alertFiles'), files, proj, '');
|
|
1548
|
+
}
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1452
1551
|
});
|
|
1453
1552
|
|
|
1454
1553
|
// Diagnostics summary click
|
|
@@ -1467,8 +1566,17 @@ function setupEvents() {
|
|
|
1467
1566
|
document.querySelectorAll('[data-action="close-drawer"]').forEach(btn => {
|
|
1468
1567
|
btn.addEventListener('click', closeDrawer);
|
|
1469
1568
|
});
|
|
1569
|
+
|
|
1570
|
+
// Close modal
|
|
1571
|
+
$('#file-modal-overlay').addEventListener('click', (e) => {
|
|
1572
|
+
if (e.target === $('#file-modal-overlay')) closeFileModal();
|
|
1573
|
+
});
|
|
1574
|
+
document.querySelectorAll('[data-action="close-modal"]').forEach(btn => {
|
|
1575
|
+
btn.addEventListener('click', closeFileModal);
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1470
1578
|
document.addEventListener('keydown', (e) => {
|
|
1471
|
-
if (e.key === 'Escape') closeDrawer();
|
|
1579
|
+
if (e.key === 'Escape') { closeFileModal(); closeDrawer(); }
|
|
1472
1580
|
});
|
|
1473
1581
|
}
|
|
1474
1582
|
|
|
@@ -75,6 +75,17 @@
|
|
|
75
75
|
|
|
76
76
|
</main>
|
|
77
77
|
|
|
78
|
+
<!-- ── File Detail Modal ──────────────────────────────────── -->
|
|
79
|
+
<div id="file-modal-overlay" class="modal-overlay">
|
|
80
|
+
<div id="file-modal" class="file-modal">
|
|
81
|
+
<div class="file-modal-header">
|
|
82
|
+
<h3 id="file-modal-title"></h3>
|
|
83
|
+
<button class="drawer-close" data-action="close-modal">×</button>
|
|
84
|
+
</div>
|
|
85
|
+
<div id="file-modal-body" class="file-modal-body"></div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
78
89
|
<!-- ── Drawer Overlay ───────────────────────────────────── -->
|
|
79
90
|
<div id="drawer-overlay" class="drawer-overlay"></div>
|
|
80
91
|
|
|
@@ -349,6 +349,14 @@ main {
|
|
|
349
349
|
max-width: 400px;
|
|
350
350
|
line-height: 1.4;
|
|
351
351
|
}
|
|
352
|
+
.summary-intent-label {
|
|
353
|
+
font-weight: 600;
|
|
354
|
+
opacity: 0.7;
|
|
355
|
+
}
|
|
356
|
+
.summary-trigger {
|
|
357
|
+
font-size: 11px;
|
|
358
|
+
padding: 2px 0;
|
|
359
|
+
}
|
|
352
360
|
.summary-restore-ctx {
|
|
353
361
|
font-size: 12px;
|
|
354
362
|
color: var(--amber);
|
|
@@ -997,6 +1005,22 @@ main {
|
|
|
997
1005
|
border-left: 3px solid var(--yellow);
|
|
998
1006
|
}
|
|
999
1007
|
|
|
1008
|
+
.alert-breakdown {
|
|
1009
|
+
color: var(--text-secondary);
|
|
1010
|
+
font-weight: 500;
|
|
1011
|
+
padding: 2px 12px;
|
|
1012
|
+
}
|
|
1013
|
+
.alert-suggestion {
|
|
1014
|
+
padding: 4px 12px;
|
|
1015
|
+
font-style: italic;
|
|
1016
|
+
}
|
|
1017
|
+
.alert-history-breakdown {
|
|
1018
|
+
display: block;
|
|
1019
|
+
font-size: 10px;
|
|
1020
|
+
color: var(--text-tertiary);
|
|
1021
|
+
margin-top: 1px;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1000
1024
|
.alert-history-toggle-wrap {
|
|
1001
1025
|
margin-top: 8px;
|
|
1002
1026
|
}
|
|
@@ -1236,6 +1260,103 @@ main {
|
|
|
1236
1260
|
color: var(--text-heading);
|
|
1237
1261
|
}
|
|
1238
1262
|
|
|
1263
|
+
/* ── File Detail Modal ────────────────────────────────────── */
|
|
1264
|
+
|
|
1265
|
+
.modal-overlay {
|
|
1266
|
+
position: fixed;
|
|
1267
|
+
inset: 0;
|
|
1268
|
+
background: rgba(0, 0, 0, 0.55);
|
|
1269
|
+
z-index: 2000;
|
|
1270
|
+
display: none;
|
|
1271
|
+
align-items: center;
|
|
1272
|
+
justify-content: center;
|
|
1273
|
+
backdrop-filter: blur(2px);
|
|
1274
|
+
}
|
|
1275
|
+
.modal-overlay.active {
|
|
1276
|
+
display: flex;
|
|
1277
|
+
}
|
|
1278
|
+
.file-modal {
|
|
1279
|
+
background: var(--card-bg);
|
|
1280
|
+
border: 1px solid var(--border);
|
|
1281
|
+
border-radius: var(--radius);
|
|
1282
|
+
width: 90vw;
|
|
1283
|
+
max-width: 720px;
|
|
1284
|
+
max-height: 80vh;
|
|
1285
|
+
display: flex;
|
|
1286
|
+
flex-direction: column;
|
|
1287
|
+
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.3);
|
|
1288
|
+
}
|
|
1289
|
+
.file-modal-header {
|
|
1290
|
+
display: flex;
|
|
1291
|
+
justify-content: space-between;
|
|
1292
|
+
align-items: center;
|
|
1293
|
+
padding: 14px 20px;
|
|
1294
|
+
border-bottom: 1px solid var(--border);
|
|
1295
|
+
}
|
|
1296
|
+
.file-modal-header h3 {
|
|
1297
|
+
margin: 0;
|
|
1298
|
+
font-size: 15px;
|
|
1299
|
+
font-weight: 600;
|
|
1300
|
+
}
|
|
1301
|
+
.file-modal-body {
|
|
1302
|
+
padding: 16px 20px;
|
|
1303
|
+
overflow-y: auto;
|
|
1304
|
+
flex: 1;
|
|
1305
|
+
}
|
|
1306
|
+
.file-modal-body table {
|
|
1307
|
+
width: 100%;
|
|
1308
|
+
border-collapse: collapse;
|
|
1309
|
+
}
|
|
1310
|
+
.file-modal-body th,
|
|
1311
|
+
.file-modal-body td {
|
|
1312
|
+
padding: 6px 10px;
|
|
1313
|
+
text-align: left;
|
|
1314
|
+
border-bottom: 1px solid var(--border);
|
|
1315
|
+
font-size: 12px;
|
|
1316
|
+
}
|
|
1317
|
+
.file-modal-body th {
|
|
1318
|
+
font-weight: 600;
|
|
1319
|
+
font-size: 11px;
|
|
1320
|
+
text-transform: uppercase;
|
|
1321
|
+
letter-spacing: 0.3px;
|
|
1322
|
+
color: var(--text-secondary);
|
|
1323
|
+
cursor: pointer;
|
|
1324
|
+
user-select: none;
|
|
1325
|
+
}
|
|
1326
|
+
.file-modal-body th:hover {
|
|
1327
|
+
color: var(--accent);
|
|
1328
|
+
}
|
|
1329
|
+
.modal-file-path {
|
|
1330
|
+
max-width: 340px;
|
|
1331
|
+
overflow: hidden;
|
|
1332
|
+
text-overflow: ellipsis;
|
|
1333
|
+
white-space: nowrap;
|
|
1334
|
+
}
|
|
1335
|
+
.modal-file-changes {
|
|
1336
|
+
white-space: nowrap;
|
|
1337
|
+
}
|
|
1338
|
+
.modal-restore-btn {
|
|
1339
|
+
padding: 2px 8px;
|
|
1340
|
+
font-size: 10px;
|
|
1341
|
+
border: 1px solid var(--border);
|
|
1342
|
+
border-radius: var(--radius-sm);
|
|
1343
|
+
background: var(--bg);
|
|
1344
|
+
color: var(--text-secondary);
|
|
1345
|
+
cursor: pointer;
|
|
1346
|
+
white-space: nowrap;
|
|
1347
|
+
transition: all 0.15s;
|
|
1348
|
+
}
|
|
1349
|
+
.modal-restore-btn:hover {
|
|
1350
|
+
background: var(--accent);
|
|
1351
|
+
color: #fff;
|
|
1352
|
+
border-color: var(--accent);
|
|
1353
|
+
}
|
|
1354
|
+
.modal-restore-btn.copied {
|
|
1355
|
+
background: var(--green);
|
|
1356
|
+
color: #fff;
|
|
1357
|
+
border-color: var(--green);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1239
1360
|
/* ── Responsive ───────────────────────────────────────────── */
|
|
1240
1361
|
|
|
1241
1362
|
@media (max-width: 768px) {
|
|
@@ -186,19 +186,47 @@ function handleApi(pathname, query, registry, res) {
|
|
|
186
186
|
return notFound(res);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
/* ── Server
|
|
189
|
+
/* ── Server (singleton) ─────────────────────────────────────── */
|
|
190
|
+
|
|
191
|
+
let _instance = null;
|
|
192
|
+
|
|
193
|
+
function _mergeProjects(registry, paths) {
|
|
194
|
+
const seen = new Set([...registry.values()].map(p => p._path.toLowerCase()));
|
|
195
|
+
let maxIdx = registry.size;
|
|
196
|
+
let added = 0;
|
|
197
|
+
for (const raw of paths) {
|
|
198
|
+
const resolved = path.resolve(raw);
|
|
199
|
+
if (seen.has(resolved.toLowerCase())) continue;
|
|
200
|
+
seen.add(resolved.toLowerCase());
|
|
201
|
+
const id = `p${maxIdx++}`;
|
|
202
|
+
const name = path.basename(resolved) || resolved;
|
|
203
|
+
const label = resolved.length > 50 ? '...' + resolved.slice(-47) : resolved;
|
|
204
|
+
registry.set(id, { id, name, pathLabel: label, _path: resolved });
|
|
205
|
+
added++;
|
|
206
|
+
}
|
|
207
|
+
return added;
|
|
208
|
+
}
|
|
190
209
|
|
|
191
210
|
/**
|
|
192
|
-
* Start the dashboard HTTP server.
|
|
193
|
-
*
|
|
211
|
+
* Start the dashboard HTTP server, or hot-add projects to an existing instance.
|
|
212
|
+
* Uses a module-level singleton: subsequent calls reuse the same port and server,
|
|
213
|
+
* merging new project paths into the live registry.
|
|
194
214
|
*
|
|
195
215
|
* @param {string[]} paths - Project directories to serve
|
|
196
216
|
* @param {object} [opts]
|
|
197
|
-
* @param {number} [opts.port=3120] - Starting port
|
|
217
|
+
* @param {number} [opts.port=3120] - Starting port (ignored if server already running)
|
|
198
218
|
* @param {boolean} [opts.silent=false] - Suppress banner output
|
|
199
219
|
* @returns {Promise<{server: http.Server, port: number, registry: Map}>}
|
|
200
220
|
*/
|
|
201
221
|
function startDashboardServer(paths, opts = {}) {
|
|
222
|
+
if (_instance) {
|
|
223
|
+
const added = _mergeProjects(_instance.registry, paths);
|
|
224
|
+
if (added > 0 && !opts.silent) {
|
|
225
|
+
console.log(` [dashboard] Hot-added ${added} project(s) — total: ${_instance.registry.size} on port ${_instance.port}`);
|
|
226
|
+
}
|
|
227
|
+
return Promise.resolve(_instance);
|
|
228
|
+
}
|
|
229
|
+
|
|
202
230
|
const port = opts.port || DEFAULT_PORT;
|
|
203
231
|
const silent = opts.silent || false;
|
|
204
232
|
const registry = buildRegistry(paths);
|
|
@@ -209,7 +237,6 @@ function startDashboardServer(paths, opts = {}) {
|
|
|
209
237
|
let retries = 0;
|
|
210
238
|
|
|
211
239
|
const server = http.createServer((req, res) => {
|
|
212
|
-
// DNS rebinding protection: reject unexpected Host headers
|
|
213
240
|
const host = req.headers.host || '';
|
|
214
241
|
if (!ALLOWED_HOSTS.test(host)) {
|
|
215
242
|
res.writeHead(403);
|
|
@@ -224,7 +251,6 @@ function startDashboardServer(paths, opts = {}) {
|
|
|
224
251
|
try { parsed = new URL(req.url, `http://${host}`); }
|
|
225
252
|
catch { return notFound(res); }
|
|
226
253
|
|
|
227
|
-
// API endpoints require per-process token
|
|
228
254
|
if (parsed.pathname.startsWith('/api/')) {
|
|
229
255
|
const reqToken = parsed.searchParams.get('token');
|
|
230
256
|
if (reqToken !== token) {
|
|
@@ -249,6 +275,7 @@ function startDashboardServer(paths, opts = {}) {
|
|
|
249
275
|
|
|
250
276
|
server.on('listening', () => {
|
|
251
277
|
const addr = server.address();
|
|
278
|
+
_instance = { server, port: addr.port, registry, token };
|
|
252
279
|
if (!silent) {
|
|
253
280
|
console.log('');
|
|
254
281
|
console.log(' Cursor Guard Dashboard');
|
|
@@ -260,7 +287,7 @@ function startDashboardServer(paths, opts = {}) {
|
|
|
260
287
|
}
|
|
261
288
|
console.log('');
|
|
262
289
|
}
|
|
263
|
-
resolve(
|
|
290
|
+
resolve(_instance);
|
|
264
291
|
});
|
|
265
292
|
|
|
266
293
|
server.listen(currentPort, '127.0.0.1');
|
|
@@ -277,4 +304,4 @@ if (require.main === module) {
|
|
|
277
304
|
});
|
|
278
305
|
}
|
|
279
306
|
|
|
280
|
-
module.exports = { startDashboardServer };
|
|
307
|
+
module.exports = { startDashboardServer, getInstance: () => _instance };
|
|
@@ -146,7 +146,6 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
146
146
|
const diffOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', parentTree, newTree], { cwd, allowFail: true });
|
|
147
147
|
if (diffOut) {
|
|
148
148
|
const diffLines = diffOut.split('\n').filter(Boolean);
|
|
149
|
-
changedCount = diffLines.length;
|
|
150
149
|
const groups = { M: [], A: [], D: [], R: [] };
|
|
151
150
|
for (const line of diffLines) {
|
|
152
151
|
const tab = line.indexOf('\t');
|
|
@@ -158,8 +157,10 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
158
157
|
: code === 'A' ? 'A'
|
|
159
158
|
: 'M';
|
|
160
159
|
const fileName = filePart.split('\t').pop();
|
|
160
|
+
if (matchesAny(cfg.ignore, fileName) || matchesAny(cfg.ignore, path.basename(fileName))) continue;
|
|
161
161
|
groups[key].push(fileName);
|
|
162
162
|
}
|
|
163
|
+
changedCount = Object.values(groups).reduce((sum, arr) => sum + arr.length, 0);
|
|
163
164
|
|
|
164
165
|
const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', parentTree, newTree], { cwd, allowFail: true });
|
|
165
166
|
const stats = {};
|
|
@@ -199,7 +200,8 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
199
200
|
} else {
|
|
200
201
|
const lsInitial = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
|
|
201
202
|
if (lsInitial) {
|
|
202
|
-
const files = lsInitial.split('\n').filter(Boolean)
|
|
203
|
+
const files = lsInitial.split('\n').filter(Boolean)
|
|
204
|
+
.filter(f => !matchesAny(cfg.ignore, f) && !matchesAny(cfg.ignore, path.basename(f)));
|
|
203
205
|
changedCount = files.length;
|
|
204
206
|
const sample = files.slice(0, 5).join(', ');
|
|
205
207
|
incrementalSummary = `Added ${files.length}: ${sample}${files.length > 5 ? ', ...' : ''}`;
|
|
@@ -290,10 +292,10 @@ function createShadowCopy(projectDir, cfg, opts = {}) {
|
|
|
290
292
|
snapDir = path.join(backupDir, ts);
|
|
291
293
|
}
|
|
292
294
|
}
|
|
293
|
-
fs.mkdirSync(snapDir, { recursive: true });
|
|
294
|
-
|
|
295
295
|
const prevSnapDir = findPreviousSnapshot(backupDir);
|
|
296
296
|
|
|
297
|
+
fs.mkdirSync(snapDir, { recursive: true });
|
|
298
|
+
|
|
297
299
|
const allFiles = walkDir(projectDir, projectDir);
|
|
298
300
|
const files = filterFiles(allFiles, cfg);
|
|
299
301
|
|
package/references/lib/utils.js
CHANGED
|
@@ -177,7 +177,12 @@ function loadConfig(projectDir) {
|
|
|
177
177
|
warnings.push(`Unknown backup_strategy "${raw.backup_strategy}", using default "${cfg.backup_strategy}"`);
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
|
-
if (typeof raw.auto_backup_interval_seconds === 'number')
|
|
180
|
+
if (typeof raw.auto_backup_interval_seconds === 'number') {
|
|
181
|
+
cfg.auto_backup_interval_seconds = raw.auto_backup_interval_seconds;
|
|
182
|
+
} else if (typeof raw.backup_interval_seconds === 'number') {
|
|
183
|
+
cfg.auto_backup_interval_seconds = raw.backup_interval_seconds;
|
|
184
|
+
warnings.push('backup_interval_seconds is a deprecated alias — please use auto_backup_interval_seconds');
|
|
185
|
+
}
|
|
181
186
|
if (typeof raw.pre_restore_backup === 'string') {
|
|
182
187
|
if (VALID_PRE_RESTORE.includes(raw.pre_restore_backup)) {
|
|
183
188
|
cfg.pre_restore_backup = raw.pre_restore_backup;
|