cursor-guard 4.4.1 → 4.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ROADMAP.md +295 -3
- package/SKILL.md +2 -1
- package/package.json +1 -1
- package/references/dashboard/public/app.js +337 -21
- package/references/dashboard/public/index.html +1 -0
- package/references/dashboard/public/style.css +338 -0
- package/references/dashboard/server.js +8 -1
- package/references/lib/auto-backup.js +18 -38
- package/references/lib/core/anomaly.js +22 -0
- package/references/lib/core/backups.js +65 -1
- 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/lib/core/snapshot.js +54 -4
- package/references/lib/utils.js +6 -0
- package/references/mcp/server.js +67 -1
|
@@ -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;
|
|
@@ -386,6 +418,30 @@ main {
|
|
|
386
418
|
border-left: 3px solid var(--blue);
|
|
387
419
|
}
|
|
388
420
|
|
|
421
|
+
/* ── Summary inline file rows ────────────────────────────── */
|
|
422
|
+
|
|
423
|
+
.summary-file-row {
|
|
424
|
+
display: flex;
|
|
425
|
+
align-items: center;
|
|
426
|
+
gap: 6px;
|
|
427
|
+
padding: 1px 0;
|
|
428
|
+
font-size: 11px;
|
|
429
|
+
line-height: 1.4;
|
|
430
|
+
}
|
|
431
|
+
.summary-file-path {
|
|
432
|
+
flex: 1;
|
|
433
|
+
overflow: hidden;
|
|
434
|
+
text-overflow: ellipsis;
|
|
435
|
+
white-space: nowrap;
|
|
436
|
+
max-width: 220px;
|
|
437
|
+
font-size: 11px;
|
|
438
|
+
color: var(--text-primary);
|
|
439
|
+
}
|
|
440
|
+
.summary-file-more {
|
|
441
|
+
padding: 2px 0;
|
|
442
|
+
font-style: italic;
|
|
443
|
+
}
|
|
444
|
+
|
|
389
445
|
/* ── Stats Row ────────────────────────────────────────────── */
|
|
390
446
|
|
|
391
447
|
.stats-row {
|
|
@@ -717,6 +773,58 @@ main {
|
|
|
717
773
|
line-height: 1.6;
|
|
718
774
|
}
|
|
719
775
|
|
|
776
|
+
/* ── Drawer: Files Table ──────────────────────────────────── */
|
|
777
|
+
|
|
778
|
+
.drawer-files-container {
|
|
779
|
+
margin-top: 6px;
|
|
780
|
+
max-height: 340px;
|
|
781
|
+
overflow: auto;
|
|
782
|
+
border: 1px solid var(--border-subtle);
|
|
783
|
+
border-radius: var(--radius-sm);
|
|
784
|
+
}
|
|
785
|
+
.drawer-files-loading {
|
|
786
|
+
padding: 16px;
|
|
787
|
+
text-align: center;
|
|
788
|
+
}
|
|
789
|
+
.drawer-files-table {
|
|
790
|
+
width: 100%;
|
|
791
|
+
border-collapse: collapse;
|
|
792
|
+
font-size: 12px;
|
|
793
|
+
}
|
|
794
|
+
.drawer-files-table th {
|
|
795
|
+
text-align: left;
|
|
796
|
+
padding: 8px 12px;
|
|
797
|
+
font-size: 10px;
|
|
798
|
+
font-weight: 700;
|
|
799
|
+
text-transform: uppercase;
|
|
800
|
+
letter-spacing: .06em;
|
|
801
|
+
color: var(--text-tertiary);
|
|
802
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
803
|
+
background: var(--bg-elevated);
|
|
804
|
+
position: sticky;
|
|
805
|
+
top: 0;
|
|
806
|
+
z-index: 1;
|
|
807
|
+
}
|
|
808
|
+
.drawer-sort-header { cursor: pointer; transition: color var(--transition); }
|
|
809
|
+
.drawer-sort-header:hover { color: var(--blue); }
|
|
810
|
+
.drawer-files-table td {
|
|
811
|
+
padding: 5px 12px;
|
|
812
|
+
border-bottom: 1px solid rgba(71,85,105,.15);
|
|
813
|
+
vertical-align: middle;
|
|
814
|
+
}
|
|
815
|
+
.drawer-file-path {
|
|
816
|
+
max-width: 280px;
|
|
817
|
+
overflow: hidden;
|
|
818
|
+
text-overflow: ellipsis;
|
|
819
|
+
white-space: nowrap;
|
|
820
|
+
color: var(--text-primary);
|
|
821
|
+
}
|
|
822
|
+
.drawer-file-changes {
|
|
823
|
+
white-space: nowrap;
|
|
824
|
+
font-variant-numeric: tabular-nums;
|
|
825
|
+
color: var(--text-secondary);
|
|
826
|
+
}
|
|
827
|
+
|
|
720
828
|
/* ── Drawer: Doctor Checks ────────────────────────────────── */
|
|
721
829
|
|
|
722
830
|
.check-item {
|
|
@@ -850,6 +958,236 @@ main {
|
|
|
850
958
|
}
|
|
851
959
|
.skeleton-table::after { width: 75%; opacity: .6; }
|
|
852
960
|
|
|
961
|
+
/* ── Alert Card Details ───────────────────────────────────── */
|
|
962
|
+
|
|
963
|
+
.alert-details {
|
|
964
|
+
margin-top: 10px;
|
|
965
|
+
display: flex;
|
|
966
|
+
flex-direction: column;
|
|
967
|
+
gap: 6px;
|
|
968
|
+
}
|
|
969
|
+
.alert-detail-row {
|
|
970
|
+
display: flex;
|
|
971
|
+
align-items: center;
|
|
972
|
+
gap: 8px;
|
|
973
|
+
font-size: 12px;
|
|
974
|
+
color: var(--text-secondary);
|
|
975
|
+
}
|
|
976
|
+
.alert-detail-label {
|
|
977
|
+
font-weight: 600;
|
|
978
|
+
color: var(--text-tertiary);
|
|
979
|
+
min-width: 72px;
|
|
980
|
+
font-size: 10px;
|
|
981
|
+
text-transform: uppercase;
|
|
982
|
+
letter-spacing: .06em;
|
|
983
|
+
}
|
|
984
|
+
.alert-countdown {
|
|
985
|
+
color: var(--yellow);
|
|
986
|
+
font-weight: 700;
|
|
987
|
+
font-variant-numeric: tabular-nums;
|
|
988
|
+
}
|
|
989
|
+
.alert-numbers {
|
|
990
|
+
margin-top: 4px;
|
|
991
|
+
font-size: 13px;
|
|
992
|
+
font-weight: 600;
|
|
993
|
+
color: var(--yellow);
|
|
994
|
+
background: var(--yellow-bg);
|
|
995
|
+
padding: 6px 12px;
|
|
996
|
+
border-radius: var(--radius-sm);
|
|
997
|
+
border-left: 3px solid var(--yellow);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.alert-history-toggle-wrap {
|
|
1001
|
+
margin-top: 8px;
|
|
1002
|
+
}
|
|
1003
|
+
.alert-history-toggle-btn {
|
|
1004
|
+
background: none;
|
|
1005
|
+
border: none;
|
|
1006
|
+
color: var(--text-tertiary);
|
|
1007
|
+
font-size: 11px;
|
|
1008
|
+
cursor: pointer;
|
|
1009
|
+
padding: 2px 0;
|
|
1010
|
+
transition: color var(--transition);
|
|
1011
|
+
}
|
|
1012
|
+
.alert-history-toggle-btn:hover { color: var(--blue); }
|
|
1013
|
+
|
|
1014
|
+
.alert-history {
|
|
1015
|
+
margin-top: 8px;
|
|
1016
|
+
border-top: 1px solid var(--border-subtle);
|
|
1017
|
+
padding-top: 8px;
|
|
1018
|
+
}
|
|
1019
|
+
.alert-history.alert-history-collapsed { display: none; }
|
|
1020
|
+
.alert-history-label {
|
|
1021
|
+
font-size: 10px;
|
|
1022
|
+
font-weight: 700;
|
|
1023
|
+
text-transform: uppercase;
|
|
1024
|
+
letter-spacing: .06em;
|
|
1025
|
+
color: var(--text-tertiary);
|
|
1026
|
+
margin-bottom: 6px;
|
|
1027
|
+
}
|
|
1028
|
+
.alert-history-row {
|
|
1029
|
+
display: flex;
|
|
1030
|
+
align-items: center;
|
|
1031
|
+
gap: 8px;
|
|
1032
|
+
padding: 3px 0;
|
|
1033
|
+
font-size: 11px;
|
|
1034
|
+
}
|
|
1035
|
+
.badge-expired {
|
|
1036
|
+
background: var(--gray-bg);
|
|
1037
|
+
color: var(--gray);
|
|
1038
|
+
border-color: rgba(100,116,139,.18);
|
|
1039
|
+
font-size: 9px;
|
|
1040
|
+
padding: 1px 6px;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/* ── Alert Files Table ───────────────────────────────────── */
|
|
1044
|
+
|
|
1045
|
+
.alert-files-section {
|
|
1046
|
+
margin-top: 10px;
|
|
1047
|
+
border-top: 1px solid var(--border-subtle);
|
|
1048
|
+
padding-top: 8px;
|
|
1049
|
+
}
|
|
1050
|
+
.alert-files-toggle {
|
|
1051
|
+
background: none;
|
|
1052
|
+
border: none;
|
|
1053
|
+
color: var(--blue);
|
|
1054
|
+
font-size: 11px;
|
|
1055
|
+
font-weight: 600;
|
|
1056
|
+
cursor: pointer;
|
|
1057
|
+
padding: 2px 0;
|
|
1058
|
+
transition: color var(--transition);
|
|
1059
|
+
}
|
|
1060
|
+
.alert-files-toggle:hover { color: var(--text-heading); }
|
|
1061
|
+
|
|
1062
|
+
.alert-files-table-wrap {
|
|
1063
|
+
margin-top: 8px;
|
|
1064
|
+
max-height: 220px;
|
|
1065
|
+
overflow: auto;
|
|
1066
|
+
border: 1px solid var(--border-subtle);
|
|
1067
|
+
border-radius: var(--radius-sm);
|
|
1068
|
+
}
|
|
1069
|
+
.alert-files-hidden { display: none; }
|
|
1070
|
+
|
|
1071
|
+
.alert-files-table {
|
|
1072
|
+
width: 100%;
|
|
1073
|
+
border-collapse: collapse;
|
|
1074
|
+
font-size: 11px;
|
|
1075
|
+
}
|
|
1076
|
+
.alert-files-table th {
|
|
1077
|
+
text-align: left;
|
|
1078
|
+
padding: 6px 10px;
|
|
1079
|
+
font-size: 9px;
|
|
1080
|
+
font-weight: 700;
|
|
1081
|
+
text-transform: uppercase;
|
|
1082
|
+
letter-spacing: .06em;
|
|
1083
|
+
color: var(--text-tertiary);
|
|
1084
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
1085
|
+
background: var(--bg-elevated);
|
|
1086
|
+
position: sticky;
|
|
1087
|
+
top: 0;
|
|
1088
|
+
z-index: 1;
|
|
1089
|
+
}
|
|
1090
|
+
.alert-files-table td {
|
|
1091
|
+
padding: 4px 10px;
|
|
1092
|
+
border-bottom: 1px solid rgba(71,85,105,.15);
|
|
1093
|
+
vertical-align: middle;
|
|
1094
|
+
}
|
|
1095
|
+
.alert-file-path {
|
|
1096
|
+
max-width: 240px;
|
|
1097
|
+
overflow: hidden;
|
|
1098
|
+
text-overflow: ellipsis;
|
|
1099
|
+
white-space: nowrap;
|
|
1100
|
+
color: var(--text-primary);
|
|
1101
|
+
}
|
|
1102
|
+
.alert-file-changes {
|
|
1103
|
+
white-space: nowrap;
|
|
1104
|
+
font-variant-numeric: tabular-nums;
|
|
1105
|
+
color: var(--text-secondary);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
.alert-action-badge {
|
|
1109
|
+
display: inline-block;
|
|
1110
|
+
padding: 1px 6px;
|
|
1111
|
+
border-radius: 8px;
|
|
1112
|
+
font-size: 9px;
|
|
1113
|
+
font-weight: 600;
|
|
1114
|
+
letter-spacing: .02em;
|
|
1115
|
+
}
|
|
1116
|
+
.alert-action-modified { background: var(--blue-bg); color: var(--blue); }
|
|
1117
|
+
.alert-action-added { background: var(--green-bg); color: var(--green); }
|
|
1118
|
+
.alert-action-deleted { background: var(--red-bg); color: var(--red); }
|
|
1119
|
+
.alert-action-renamed { background: var(--purple-bg); color: var(--purple); }
|
|
1120
|
+
|
|
1121
|
+
/* ── File Search ─────────────────────────────────────────── */
|
|
1122
|
+
|
|
1123
|
+
.file-search-wrap {
|
|
1124
|
+
margin-bottom: 12px;
|
|
1125
|
+
}
|
|
1126
|
+
.file-search-input {
|
|
1127
|
+
width: 100%;
|
|
1128
|
+
max-width: 360px;
|
|
1129
|
+
background: var(--bg-secondary);
|
|
1130
|
+
color: var(--text-primary);
|
|
1131
|
+
border: 1px solid var(--border);
|
|
1132
|
+
border-radius: var(--radius-sm);
|
|
1133
|
+
padding: 8px 14px;
|
|
1134
|
+
font-size: 13px;
|
|
1135
|
+
font-family: inherit;
|
|
1136
|
+
transition: border-color var(--transition);
|
|
1137
|
+
}
|
|
1138
|
+
.file-search-input::placeholder { color: var(--text-tertiary); }
|
|
1139
|
+
.file-search-input:focus {
|
|
1140
|
+
outline: none;
|
|
1141
|
+
border-color: var(--blue);
|
|
1142
|
+
box-shadow: 0 0 0 3px var(--blue-bg);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/* ── Filter Count ────────────────────────────────────────── */
|
|
1146
|
+
|
|
1147
|
+
.filter-count {
|
|
1148
|
+
font-size: 10px;
|
|
1149
|
+
opacity: .65;
|
|
1150
|
+
font-weight: 400;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/* ── Restore Command Section ─────────────────────────────── */
|
|
1154
|
+
|
|
1155
|
+
.restore-cmd-section {
|
|
1156
|
+
margin-top: 18px;
|
|
1157
|
+
padding-top: 16px;
|
|
1158
|
+
border-top: 1px solid var(--border);
|
|
1159
|
+
}
|
|
1160
|
+
.restore-cmd-label {
|
|
1161
|
+
font-size: 10px;
|
|
1162
|
+
font-weight: 700;
|
|
1163
|
+
text-transform: uppercase;
|
|
1164
|
+
letter-spacing: .08em;
|
|
1165
|
+
color: var(--text-tertiary);
|
|
1166
|
+
margin-bottom: 10px;
|
|
1167
|
+
}
|
|
1168
|
+
.restore-cmd-row {
|
|
1169
|
+
display: flex;
|
|
1170
|
+
align-items: center;
|
|
1171
|
+
gap: 8px;
|
|
1172
|
+
margin-bottom: 8px;
|
|
1173
|
+
}
|
|
1174
|
+
.restore-cmd-code {
|
|
1175
|
+
flex: 1;
|
|
1176
|
+
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
|
1177
|
+
font-size: 11px;
|
|
1178
|
+
background: var(--bg-primary);
|
|
1179
|
+
border: 1px solid var(--border-subtle);
|
|
1180
|
+
border-radius: var(--radius-sm);
|
|
1181
|
+
padding: 8px 12px;
|
|
1182
|
+
color: var(--green);
|
|
1183
|
+
word-break: break-all;
|
|
1184
|
+
line-height: 1.5;
|
|
1185
|
+
}
|
|
1186
|
+
.btn-restore-cmd {
|
|
1187
|
+
flex-shrink: 0;
|
|
1188
|
+
white-space: nowrap;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
853
1191
|
/* ── Utility ──────────────────────────────────────────────── */
|
|
854
1192
|
|
|
855
1193
|
.hidden { display: none !important; }
|
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
|
|
9
9
|
const { getDashboard } = require('../lib/core/dashboard');
|
|
10
10
|
const { runDiagnostics } = require('../lib/core/doctor');
|
|
11
|
-
const { listBackups } = require('../lib/core/backups');
|
|
11
|
+
const { listBackups, getBackupFiles } = require('../lib/core/backups');
|
|
12
12
|
|
|
13
13
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
14
14
|
const DEFAULT_PORT = 3120;
|
|
@@ -171,6 +171,13 @@ function handleApi(pathname, query, registry, res) {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
if (pathname === '/api/backup-files') {
|
|
175
|
+
const hash = query.get('hash');
|
|
176
|
+
if (!hash) return json(res, { error: 'Missing hash parameter' }, 400);
|
|
177
|
+
try { return json(res, getBackupFiles(pp, hash)); }
|
|
178
|
+
catch (e) { return json(res, { error: e.message }, 500); }
|
|
179
|
+
}
|
|
180
|
+
|
|
174
181
|
if (pathname === '/api/doctor') {
|
|
175
182
|
try { return json(res, runDiagnostics(pp)); }
|
|
176
183
|
catch (e) { return json(res, { error: e.message }, 500); }
|
|
@@ -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,14 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
|
278
253
|
}
|
|
279
254
|
}
|
|
280
255
|
|
|
281
|
-
|
|
282
|
-
|
|
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)
|
|
257
|
+
let changedFiles;
|
|
289
258
|
if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
|
|
290
|
-
const context = { trigger: 'auto'
|
|
259
|
+
const context = { trigger: 'auto' };
|
|
291
260
|
const snapResult = createGitSnapshot(projectDir, cfg, { branchRef, context });
|
|
292
261
|
if (snapResult.status === 'created') {
|
|
262
|
+
changedFileCount = snapResult.changedCount != null ? snapResult.changedCount : 0;
|
|
263
|
+
changedFiles = snapResult.changedFiles;
|
|
293
264
|
let msg = `Git snapshot ${snapResult.shortHash} (${snapResult.fileCount} files)`;
|
|
294
265
|
if (snapResult.secretsExcluded) {
|
|
295
266
|
msg += ` [secrets excluded: ${snapResult.secretsExcluded.join(', ')}]`;
|
|
@@ -302,11 +273,20 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
|
302
273
|
}
|
|
303
274
|
}
|
|
304
275
|
|
|
276
|
+
// V4: Record change event and check for anomalies (after snapshot, using accurate count)
|
|
277
|
+
recordChange(tracker, changedFileCount, changedFiles);
|
|
278
|
+
const anomalyResult = checkAnomaly(tracker);
|
|
279
|
+
if (anomalyResult.anomaly && anomalyResult.alert && !anomalyResult.suppressed) {
|
|
280
|
+
saveAlert(projectDir, anomalyResult.alert);
|
|
281
|
+
logger.warn(`ALERT: ${anomalyResult.alert.fileCount} files changed in ${anomalyResult.alert.windowSeconds}s (threshold: ${anomalyResult.alert.threshold})`);
|
|
282
|
+
}
|
|
283
|
+
|
|
305
284
|
// Shadow copy via Core
|
|
306
285
|
if (cfg.backup_strategy === 'shadow' || cfg.backup_strategy === 'both') {
|
|
307
286
|
const shadowResult = createShadowCopy(projectDir, cfg, { backupDir });
|
|
308
287
|
if (shadowResult.status === 'created') {
|
|
309
|
-
|
|
288
|
+
const linkInfo = shadowResult.linkedCount ? ` [${shadowResult.linkedCount} hard-linked]` : '';
|
|
289
|
+
logger.log(`Shadow copy ${shadowResult.timestamp} (${shadowResult.fileCount} files${linkInfo})`);
|
|
310
290
|
if (pendingManifest) {
|
|
311
291
|
saveManifest(backupDir, pendingManifest);
|
|
312
292
|
pendingManifest = null;
|
|
@@ -84,6 +84,27 @@ function checkAnomaly(tracker) {
|
|
|
84
84
|
return { anomaly: true, alert: lastAlert, suppressed: true };
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Aggregate per-file details from recent events, deduplicated by path (latest wins)
|
|
88
|
+
const fileMap = new Map();
|
|
89
|
+
for (const e of recentEvents) {
|
|
90
|
+
if (Array.isArray(e.files)) {
|
|
91
|
+
for (const f of e.files) {
|
|
92
|
+
if (f && f.path) {
|
|
93
|
+
const existing = fileMap.get(f.path);
|
|
94
|
+
if (existing) {
|
|
95
|
+
existing.added = (existing.added || 0) + (f.added || 0);
|
|
96
|
+
existing.deleted = (existing.deleted || 0) + (f.deleted || 0);
|
|
97
|
+
} else {
|
|
98
|
+
fileMap.set(f.path, { path: f.path, action: f.action || 'modified', added: f.added || 0, deleted: f.deleted || 0 });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const alertFiles = [...fileMap.values()]
|
|
105
|
+
.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted))
|
|
106
|
+
.slice(0, 50);
|
|
107
|
+
|
|
87
108
|
const alert = {
|
|
88
109
|
type: 'high_change_velocity',
|
|
89
110
|
detectedAt: now,
|
|
@@ -93,6 +114,7 @@ function checkAnomaly(tracker) {
|
|
|
93
114
|
threshold: tracker.config.filesPerWindow,
|
|
94
115
|
expiresAt: new Date(now + 5 * 60 * 1000).toISOString(),
|
|
95
116
|
recommendation: 'High volume of file changes detected. Consider reviewing recent modifications and creating a manual snapshot.',
|
|
117
|
+
files: alertFiles.length > 0 ? alertFiles : undefined,
|
|
96
118
|
};
|
|
97
119
|
|
|
98
120
|
tracker.alerts.push(alert);
|
|
@@ -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) {
|
|
@@ -395,4 +398,65 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
|
|
|
395
398
|
return { kept: keepCount, pruned: total - keepCount, mode, rebuilt: true };
|
|
396
399
|
}
|
|
397
400
|
|
|
398
|
-
|
|
401
|
+
// ── Get backup file details ─────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Get structured file-level changes for a specific git backup commit.
|
|
405
|
+
* Runs diff-tree --numstat + --name-status against parent (or ls-tree for root).
|
|
406
|
+
*
|
|
407
|
+
* @param {string} projectDir
|
|
408
|
+
* @param {string} commitHash - Full or short commit hash
|
|
409
|
+
* @returns {{ files: Array<{path: string, action: string, added: number, deleted: number}>, error?: string }}
|
|
410
|
+
*/
|
|
411
|
+
function getBackupFiles(projectDir, commitHash) {
|
|
412
|
+
if (!isGitRepo(projectDir)) {
|
|
413
|
+
return { files: [], error: 'not a git repository' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const resolved = git(['rev-parse', '--verify', commitHash], { cwd: projectDir, allowFail: true });
|
|
417
|
+
if (!resolved) {
|
|
418
|
+
return { files: [], error: `cannot resolve commit: ${commitHash}` };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const parentCheck = git(['rev-parse', '--verify', `${resolved}^`], { cwd: projectDir, allowFail: true });
|
|
422
|
+
|
|
423
|
+
if (!parentCheck) {
|
|
424
|
+
const lsOut = git(['ls-tree', '--name-only', '-r', resolved], { cwd: projectDir, allowFail: true });
|
|
425
|
+
if (!lsOut) return { files: [] };
|
|
426
|
+
return {
|
|
427
|
+
files: lsOut.split('\n').filter(Boolean).map(f => ({ path: f, action: 'added', added: 0, deleted: 0 })),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const nameStatusOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', `${resolved}^`, resolved], { cwd: projectDir, allowFail: true });
|
|
432
|
+
const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', `${resolved}^`, resolved], { cwd: projectDir, allowFail: true });
|
|
433
|
+
|
|
434
|
+
const stats = {};
|
|
435
|
+
if (numstatOut) {
|
|
436
|
+
for (const line of numstatOut.split('\n').filter(Boolean)) {
|
|
437
|
+
const [add, del, ...nameParts] = line.split('\t');
|
|
438
|
+
const fname = nameParts.join('\t');
|
|
439
|
+
stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const ACTION_MAP = { M: 'modified', A: 'added', D: 'deleted' };
|
|
444
|
+
const files = [];
|
|
445
|
+
if (nameStatusOut) {
|
|
446
|
+
for (const line of nameStatusOut.split('\n').filter(Boolean)) {
|
|
447
|
+
const tab = line.indexOf('\t');
|
|
448
|
+
if (tab < 0) continue;
|
|
449
|
+
const code = line.substring(0, tab).trim();
|
|
450
|
+
const filePart = line.substring(tab + 1);
|
|
451
|
+
const action = code.startsWith('R') ? 'renamed' : (ACTION_MAP[code] || 'modified');
|
|
452
|
+
const fileName = filePart.split('\t').pop();
|
|
453
|
+
const s = stats[fileName] || { added: 0, deleted: 0 };
|
|
454
|
+
files.push({ path: fileName, action, added: s.added, deleted: s.deleted });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
files.sort((a, b) => (b.added + b.deleted) - (a.added + a.deleted));
|
|
459
|
+
return { files };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
module.exports = { listBackups, getBackupFiles, cleanShadowRetention, cleanGitRetention, parseShadowTimestamp };
|
|
@@ -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
|
}
|