universal-dev-standards 6.14.0-beta.4 → 6.14.0-beta.5
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/bin/uds.js +7 -1
- package/bundled/ai/standards/full-coverage-testing.ai.yaml +46 -5
- package/bundled/core/full-coverage-testing.md +57 -3
- package/bundled/locales/zh-CN/CHANGELOG.md +29 -2
- package/bundled/locales/zh-CN/README.md +1 -1
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/core/full-coverage-testing.md +61 -7
- package/bundled/locales/zh-CN/docs/CHEATSHEET.md +3 -1
- package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +8 -5
- package/bundled/locales/zh-TW/CHANGELOG.md +29 -2
- package/bundled/locales/zh-TW/README.md +1 -1
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/full-coverage-testing.md +61 -7
- package/bundled/locales/zh-TW/docs/CHEATSHEET.md +3 -1
- package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +8 -5
- package/bundled/templates/gates/check-anti-fake-tests.mjs +991 -0
- package/bundled/templates/gates/check-stubs.mjs +644 -0
- package/package.json +2 -2
- package/src/commands/audit.js +11 -0
- package/src/commands/check.js +124 -24
- package/src/commands/init.js +16 -0
- package/src/commands/update.js +180 -19
- package/src/core/install-records.js +2 -1
- package/src/i18n/messages.js +50 -9
- package/src/reconciler/backup-manager.js +418 -82
- package/src/reconciler/index.js +27 -5
- package/src/reconciler/install-roots.js +90 -0
- package/src/reconciler/plan-executor.js +23 -2
- package/src/uninstallers/hook-uninstaller.js +2 -1
- package/src/utils/command-hash-ownership.js +103 -0
- package/src/utils/copier.js +21 -1
- package/src/utils/gate-scripts.js +141 -0
- package/src/utils/health-scorer.js +10 -7
- package/src/utils/skill-hash-ownership.js +64 -0
- package/src/utils/skills-installer.js +12 -1
- package/src/utils/test-change-check.js +160 -0
- package/src/utils/test-policy.js +214 -0
- package/src/utils/update-summary.js +29 -0
- package/standards-registry.json +7 -7
- package/bundled/extensions/languages/php/fat-free-patterns.md +0 -915
- package/bundled/extensions/languages/php/php-style.md +0 -693
|
@@ -5,65 +5,155 @@
|
|
|
5
5
|
* Backup structure:
|
|
6
6
|
* .uds-backup-<ISO timestamp>/
|
|
7
7
|
* ├── backup-manifest.json # Backup metadata + rollback instructions
|
|
8
|
-
* ├── .standards/ # Backed up standard files
|
|
8
|
+
* ├── .standards/ # Backed up standard files (and manifest.json)
|
|
9
9
|
* ├── CLAUDE.md # Backed up integration files
|
|
10
10
|
* └── .claude/skills/... # Backed up skill files
|
|
11
11
|
*
|
|
12
|
-
* Only
|
|
13
|
-
*
|
|
12
|
+
* Only the paths a step will touch are backed up (minimal footprint). Keeps the 5 most recent
|
|
13
|
+
* backups; auto-cleans older ones.
|
|
14
|
+
*
|
|
15
|
+
* What a backup has to be able to undo (XSPEC-454 R1). A rollback that restores "the files that were
|
|
16
|
+
* overwritten" and nothing else leaves a state that neither version describes:
|
|
17
|
+
* - files the step CREATED (a new skill folder) have no earlier copy, so the backup has to name them
|
|
18
|
+
* (`createdFiles`, filled in by `finalizeBackup` once the step is done) or nothing can remove them;
|
|
19
|
+
* - `.standards/manifest.json` records hashes of everything else, so restoring files without it
|
|
20
|
+
* makes `uds check` call every restored file modified;
|
|
21
|
+
* - a directory has to be restored by walking it, not with `copyFileSync` (which throws on one);
|
|
22
|
+
* - `uds update --skills` / `--commands` write outside the reconciler, so they take a backup of
|
|
23
|
+
* their own (`createStepBackup`), and consecutive steps are chained: each backup records the hash
|
|
24
|
+
* of the manifest before and after, and `rollback` walks back through every backup whose "before"
|
|
25
|
+
* is the previous backup's "after" — i.e. through one unbroken series of UDS updates.
|
|
14
26
|
*/
|
|
15
27
|
|
|
16
28
|
import {
|
|
17
29
|
existsSync,
|
|
18
30
|
mkdirSync,
|
|
19
|
-
copyFileSync,
|
|
20
31
|
cpSync,
|
|
21
|
-
|
|
32
|
+
lstatSync,
|
|
33
|
+
readlinkSync,
|
|
22
34
|
readFileSync,
|
|
23
35
|
writeFileSync,
|
|
24
36
|
readdirSync,
|
|
25
|
-
rmSync
|
|
37
|
+
rmSync,
|
|
38
|
+
rmdirSync
|
|
26
39
|
} from 'fs';
|
|
27
|
-
import {
|
|
40
|
+
import { createHash } from 'crypto';
|
|
41
|
+
import { join, dirname, isAbsolute, sep } from 'path';
|
|
28
42
|
|
|
29
43
|
const MAX_BACKUPS = 5;
|
|
30
44
|
const BACKUP_PREFIX = '.uds-backup-';
|
|
45
|
+
const MANIFEST_REL = '.standards/manifest.json';
|
|
46
|
+
const FORMAT_VERSION = 2;
|
|
31
47
|
let _backupCounter = 0;
|
|
32
48
|
|
|
49
|
+
const toPosix = (p) => p.split(sep).join('/');
|
|
50
|
+
|
|
51
|
+
/** SHA-256 of a file's bytes, or null when it cannot be read. */
|
|
52
|
+
function hashFileBytes(path) {
|
|
53
|
+
try {
|
|
54
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* @param {import('./diff-engine.js').ReconciliationPlan} plan - The reconciliation plan
|
|
38
|
-
* @returns {{ backupId: string, backupDir: string, backedUp: string[], errors: string[] }}
|
|
61
|
+
* What a leaf IS, for comparing a restored leaf with its backup: the bytes of a file, the target of a
|
|
62
|
+
* symlink. (A symlink is copied as a link, never followed — a skills folder can hold links to
|
|
63
|
+
* directories, and following one would copy somebody else's tree into the backup.)
|
|
39
64
|
*/
|
|
40
|
-
|
|
65
|
+
function leafSignature(path) {
|
|
66
|
+
try {
|
|
67
|
+
if (lstatSync(path).isSymbolicLink()) return `link:${readlinkSync(path)}`;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return hashFileBytes(path);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Copy one leaf (file or symlink), overwriting a file or link at the destination — never a directory. */
|
|
75
|
+
function copyLeaf(src, dst) {
|
|
76
|
+
try {
|
|
77
|
+
if (lstatSync(dst).isSymbolicLink()) rmSync(dst, { force: true });
|
|
78
|
+
} catch {
|
|
79
|
+
// dst does not exist
|
|
80
|
+
}
|
|
81
|
+
cpSync(src, dst, { force: true, verbatimSymlinks: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Every file under `rel` (project-relative, posix). A file lists itself; a missing path lists nothing. */
|
|
85
|
+
function listFiles(root, rel) {
|
|
86
|
+
const abs = join(root, rel);
|
|
87
|
+
let st;
|
|
88
|
+
try {
|
|
89
|
+
st = lstatSync(abs);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
if (!st.isDirectory()) return [rel];
|
|
94
|
+
const out = [];
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
for (const e of entries) out.push(...listFiles(root, `${rel}/${e.name}`));
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every directory under (and including) `rel` that exists now. */
|
|
106
|
+
function listDirs(root, rel) {
|
|
107
|
+
const abs = join(root, rel);
|
|
108
|
+
let st;
|
|
109
|
+
try {
|
|
110
|
+
st = lstatSync(abs);
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
if (!st.isDirectory()) return [];
|
|
115
|
+
const out = [rel];
|
|
116
|
+
for (const e of readdirSync(abs, { withFileTypes: true })) {
|
|
117
|
+
if (e.isDirectory()) out.push(...listDirs(root, `${rel}/${e.name}`));
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Ancestor directories of a project-relative path, nearest last, project root excluded. */
|
|
123
|
+
function ancestorsOf(rel) {
|
|
124
|
+
const parts = rel.split('/');
|
|
125
|
+
const out = [];
|
|
126
|
+
for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join('/'));
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function newBackupId() {
|
|
41
131
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
42
132
|
const counter = String(++_backupCounter).padStart(4, '0');
|
|
43
|
-
|
|
133
|
+
return `${BACKUP_PREFIX}${timestamp}-${counter}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Snapshot `paths` into a new backup directory.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} projectPath
|
|
140
|
+
* @param {Object} spec
|
|
141
|
+
* @param {string} spec.label - What step this backs up ('reconcile', 'skills', 'commands')
|
|
142
|
+
* @param {string[]} spec.mustBackUp - Paths whose absence/copy failure counts against `coverage`
|
|
143
|
+
* @param {string[]} spec.alsoWatch - Extra project-relative paths: backed up when present, recorded as absent otherwise
|
|
144
|
+
* @param {Object} spec.plan - Plan summary stored in the backup manifest (optional)
|
|
145
|
+
* @param {Array<{path: string, reason: string}>} spec.notBackedUp - Things outside the project this step also writes
|
|
146
|
+
*/
|
|
147
|
+
function snapshot(projectPath, spec) {
|
|
148
|
+
const backupId = newBackupId();
|
|
44
149
|
const backupDir = join(projectPath, backupId);
|
|
45
150
|
const backedUp = [];
|
|
46
151
|
const errors = [];
|
|
47
152
|
|
|
48
|
-
// Only backup files that will be updated or deleted
|
|
49
|
-
const filesToBackup = plan.actions
|
|
50
|
-
.filter(a => a.type === 'update' || a.type === 'delete' || a.type === 'migrate_block')
|
|
51
|
-
.map(a => a.path);
|
|
52
|
-
|
|
53
|
-
if (filesToBackup.length === 0) {
|
|
54
|
-
return { backupId, backupDir, backedUp, errors };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Create backup directory
|
|
58
153
|
try {
|
|
59
154
|
mkdirSync(backupDir, { recursive: true });
|
|
60
155
|
} catch (err) {
|
|
61
|
-
return {
|
|
62
|
-
backupId,
|
|
63
|
-
backupDir,
|
|
64
|
-
backedUp,
|
|
65
|
-
errors: [`Failed to create backup directory: ${err.message}`]
|
|
66
|
-
};
|
|
156
|
+
return { backupId, backupDir, backedUp, errors: [`Failed to create backup directory: ${err.message}`] };
|
|
67
157
|
}
|
|
68
158
|
|
|
69
159
|
// Make the backup invisible to git, from inside itself.
|
|
@@ -88,6 +178,23 @@ export function createBackup(projectPath, plan) {
|
|
|
88
178
|
errors.push(`Failed to write backup .gitignore: ${err.message}`);
|
|
89
179
|
}
|
|
90
180
|
|
|
181
|
+
const notBackedUp = [...(spec.notBackedUp || [])];
|
|
182
|
+
const mustBackUp = [];
|
|
183
|
+
for (const p of spec.mustBackUp || []) {
|
|
184
|
+
if (isAbsolute(p)) {
|
|
185
|
+
// `join(projectPath, '/abs/path')` would silently point somewhere else entirely. A path outside
|
|
186
|
+
// the project (a user-level skills folder) is shared by every project; it is reported, not copied.
|
|
187
|
+
notBackedUp.push({ path: p, reason: 'outside this project (user-level); shared by every project, so it is not backed up or rolled back' });
|
|
188
|
+
} else {
|
|
189
|
+
mustBackUp.push(toPosix(p));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const extra = (spec.alsoWatch || []).map(toPosix);
|
|
193
|
+
|
|
194
|
+
// The manifest first: every other record in the project is a hash that only means something next to it.
|
|
195
|
+
const watched = [...new Set([...mustBackUp, ...extra, MANIFEST_REL])];
|
|
196
|
+
const preexistingDirs = new Set();
|
|
197
|
+
|
|
91
198
|
// Backup each entry.
|
|
92
199
|
//
|
|
93
200
|
// Skills are DIRECTORIES (`.claude/skills/<name>`), and `copyFileSync` on a
|
|
@@ -97,17 +204,25 @@ export function createBackup(projectPath, plan) {
|
|
|
97
204
|
// recorded 74 files for a plan of 129 actions, with **0 of the 55 skill
|
|
98
205
|
// directories** among them — a rollback point that did not cover the largest
|
|
99
206
|
// part of what was about to be overwritten. (XSPEC-382 R6)
|
|
100
|
-
for (const relativePath of
|
|
207
|
+
for (const relativePath of watched) {
|
|
208
|
+
for (const a of ancestorsOf(relativePath)) {
|
|
209
|
+
if (existsSync(join(projectPath, a))) preexistingDirs.add(a);
|
|
210
|
+
}
|
|
101
211
|
const sourcePath = join(projectPath, relativePath);
|
|
102
212
|
if (!existsSync(sourcePath)) continue;
|
|
103
213
|
|
|
104
214
|
const targetPath = join(backupDir, relativePath);
|
|
105
215
|
try {
|
|
106
216
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
107
|
-
if (
|
|
108
|
-
|
|
217
|
+
if (lstatSync(sourcePath).isDirectory()) {
|
|
218
|
+
for (const d of listDirs(projectPath, relativePath)) preexistingDirs.add(d);
|
|
219
|
+
mkdirSync(targetPath, { recursive: true });
|
|
220
|
+
for (const f of listFiles(projectPath, relativePath)) {
|
|
221
|
+
mkdirSync(dirname(join(backupDir, f)), { recursive: true });
|
|
222
|
+
copyLeaf(join(projectPath, f), join(backupDir, f));
|
|
223
|
+
}
|
|
109
224
|
} else {
|
|
110
|
-
|
|
225
|
+
copyLeaf(sourcePath, targetPath);
|
|
111
226
|
}
|
|
112
227
|
backedUp.push(relativePath);
|
|
113
228
|
} catch (err) {
|
|
@@ -115,20 +230,12 @@ export function createBackup(projectPath, plan) {
|
|
|
115
230
|
}
|
|
116
231
|
}
|
|
117
232
|
|
|
118
|
-
// Write backup manifest
|
|
119
233
|
const backupManifest = {
|
|
234
|
+
format: FORMAT_VERSION,
|
|
120
235
|
backupId,
|
|
236
|
+
label: spec.label || 'update',
|
|
121
237
|
createdAt: new Date().toISOString(),
|
|
122
|
-
plan: {
|
|
123
|
-
summary: plan.summary,
|
|
124
|
-
actionCount: plan.actions.length,
|
|
125
|
-
actions: plan.actions.map(a => ({
|
|
126
|
-
type: a.type,
|
|
127
|
-
category: a.category,
|
|
128
|
-
path: a.path,
|
|
129
|
-
reason: a.reason
|
|
130
|
-
}))
|
|
131
|
-
},
|
|
238
|
+
plan: spec.plan || { summary: null, actionCount: 0, actions: [] },
|
|
132
239
|
backedUpFiles: backedUp,
|
|
133
240
|
// What could NOT be backed up, and the resulting gap.
|
|
134
241
|
//
|
|
@@ -139,18 +246,24 @@ export function createBackup(projectPath, plan) {
|
|
|
139
246
|
// does not contain. (XSPEC-382 R6)
|
|
140
247
|
failedToBackUp: errors.slice(),
|
|
141
248
|
coverage: {
|
|
142
|
-
planned:
|
|
143
|
-
backedUp: backedUp.length,
|
|
144
|
-
failed:
|
|
249
|
+
planned: mustBackUp.length,
|
|
250
|
+
backedUp: mustBackUp.filter((p) => backedUp.includes(p)).length,
|
|
251
|
+
failed: mustBackUp.length - mustBackUp.filter((p) => backedUp.includes(p)).length
|
|
145
252
|
},
|
|
253
|
+
// XSPEC-454 R1: the parts a "copy what is about to change" backup cannot know on its own.
|
|
254
|
+
watched,
|
|
255
|
+
preexistingDirs: [...preexistingDirs].sort(),
|
|
256
|
+
createdFiles: [],
|
|
257
|
+
createdDirs: [],
|
|
258
|
+
notBackedUp,
|
|
259
|
+
preManifestHash: hashFileBytes(join(projectPath, MANIFEST_REL)),
|
|
260
|
+
postManifestHash: null,
|
|
261
|
+
finalized: false,
|
|
146
262
|
projectPath
|
|
147
263
|
};
|
|
148
264
|
|
|
149
265
|
try {
|
|
150
|
-
writeFileSync(
|
|
151
|
-
join(backupDir, 'backup-manifest.json'),
|
|
152
|
-
JSON.stringify(backupManifest, null, 2)
|
|
153
|
-
);
|
|
266
|
+
writeFileSync(join(backupDir, 'backup-manifest.json'), JSON.stringify(backupManifest, null, 2));
|
|
154
267
|
} catch (err) {
|
|
155
268
|
errors.push(`Failed to write backup manifest: ${err.message}`);
|
|
156
269
|
}
|
|
@@ -159,66 +272,278 @@ export function createBackup(projectPath, plan) {
|
|
|
159
272
|
}
|
|
160
273
|
|
|
161
274
|
/**
|
|
162
|
-
*
|
|
275
|
+
* Create a backup for the files that will be affected by a reconciliation plan.
|
|
163
276
|
*
|
|
164
277
|
* @param {string} projectPath - Project root
|
|
165
|
-
* @param {
|
|
166
|
-
* @
|
|
278
|
+
* @param {import('./diff-engine.js').ReconciliationPlan} plan - The reconciliation plan
|
|
279
|
+
* @param {Object} [options]
|
|
280
|
+
* @param {string[]} [options.alsoWatch] - Extra project-relative paths the step writes outside its plan
|
|
281
|
+
* (the skills/commands bookkeeping files the installers rewrite)
|
|
282
|
+
* @returns {{ backupId: string, backupDir: string, backedUp: string[], errors: string[] }}
|
|
167
283
|
*/
|
|
168
|
-
export function
|
|
169
|
-
|
|
170
|
-
const
|
|
284
|
+
export function createBackup(projectPath, plan, options = {}) {
|
|
285
|
+
// Only backup files that will be updated or deleted
|
|
286
|
+
const mustBackUp = plan.actions
|
|
287
|
+
.filter(a => a.type === 'update' || a.type === 'delete' || a.type === 'migrate_block')
|
|
288
|
+
.map(a => a.path);
|
|
289
|
+
// A `create` has no earlier copy to save, but it must be watched: the backup is what lets a rollback
|
|
290
|
+
// know that file did not exist before.
|
|
291
|
+
const created = plan.actions.filter(a => a.type === 'create').map(a => a.path).filter((p) => !isAbsolute(p));
|
|
171
292
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
293
|
+
return snapshot(projectPath, {
|
|
294
|
+
label: 'reconcile',
|
|
295
|
+
mustBackUp,
|
|
296
|
+
alsoWatch: [...created, ...(options.alsoWatch || [])],
|
|
297
|
+
plan: {
|
|
298
|
+
summary: plan.summary,
|
|
299
|
+
actionCount: plan.actions.length,
|
|
300
|
+
actions: plan.actions.map(a => ({
|
|
301
|
+
type: a.type,
|
|
302
|
+
category: a.category,
|
|
303
|
+
path: a.path,
|
|
304
|
+
reason: a.reason
|
|
305
|
+
}))
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Backup for a step that writes outside the reconciler: `uds update --skills`, `--commands`.
|
|
312
|
+
*
|
|
313
|
+
* @param {string} projectPath
|
|
314
|
+
* @param {Object} spec
|
|
315
|
+
* @param {string} spec.label - 'skills' | 'commands'
|
|
316
|
+
* @param {string[]} spec.paths - Project-relative directories the step writes into
|
|
317
|
+
* @param {Array<{path: string, reason: string}>} [spec.notBackedUp] - Targets outside the project
|
|
318
|
+
*/
|
|
319
|
+
export function createStepBackup(projectPath, spec) {
|
|
320
|
+
return snapshot(projectPath, {
|
|
321
|
+
label: spec.label,
|
|
322
|
+
mustBackUp: [],
|
|
323
|
+
alsoWatch: spec.paths || [],
|
|
324
|
+
notBackedUp: spec.notBackedUp || [],
|
|
325
|
+
plan: { summary: null, actionCount: 0, actions: [] }
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function readBackupManifest(projectPath, backupId) {
|
|
330
|
+
const manifestPath = join(projectPath, backupId, 'backup-manifest.json');
|
|
331
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Record what the step did, once it is done: the files it created, and the manifest it left behind.
|
|
336
|
+
*
|
|
337
|
+
* Call after the step's last write to `.standards/manifest.json`. A later write that is not followed by
|
|
338
|
+
* another call leaves `postManifestHash` stale, which only ever breaks the chain (the rollback then
|
|
339
|
+
* undoes this one step and says so) — it can never make a rollback restore the wrong thing.
|
|
340
|
+
* Never throws: a backup that cannot be finalized is still a backup.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} projectPath
|
|
343
|
+
* @param {string|null} backupId
|
|
344
|
+
* @returns {{ createdFiles: string[], errors: string[] }}
|
|
345
|
+
*/
|
|
346
|
+
export function finalizeBackup(projectPath, backupId) {
|
|
347
|
+
const errors = [];
|
|
348
|
+
if (!backupId) return { createdFiles: [], errors };
|
|
349
|
+
try {
|
|
350
|
+
const backupDir = join(projectPath, backupId);
|
|
351
|
+
const bm = readBackupManifest(projectPath, backupId);
|
|
352
|
+
const created = new Set();
|
|
353
|
+
for (const rel of bm.watched || []) {
|
|
354
|
+
const before = new Set(listFiles(backupDir, rel));
|
|
355
|
+
for (const f of listFiles(projectPath, rel)) {
|
|
356
|
+
if (!before.has(f)) created.add(f);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const pre = new Set(bm.preexistingDirs || []);
|
|
360
|
+
const createdDirs = new Set();
|
|
361
|
+
for (const f of created) {
|
|
362
|
+
for (const a of ancestorsOf(f)) if (!pre.has(a)) createdDirs.add(a);
|
|
177
363
|
}
|
|
178
|
-
|
|
364
|
+
bm.createdFiles = [...created].sort();
|
|
365
|
+
bm.createdDirs = [...createdDirs].sort((a, b) => b.length - a.length);
|
|
366
|
+
bm.postManifestHash = hashFileBytes(join(projectPath, MANIFEST_REL));
|
|
367
|
+
bm.finalized = true;
|
|
368
|
+
bm.finalizedAt = new Date().toISOString();
|
|
369
|
+
writeFileSync(join(backupDir, 'backup-manifest.json'), JSON.stringify(bm, null, 2));
|
|
370
|
+
return { createdFiles: bm.createdFiles, errors };
|
|
371
|
+
} catch (err) {
|
|
372
|
+
errors.push(`Failed to finalize ${backupId}: ${err.message}`);
|
|
373
|
+
return { createdFiles: [], errors };
|
|
179
374
|
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Undo one backup. Never throws. */
|
|
378
|
+
function restoreOne(projectPath, backupId) {
|
|
379
|
+
const restored = [];
|
|
380
|
+
const removed = [];
|
|
381
|
+
const errors = [];
|
|
382
|
+
const notRestored = [];
|
|
180
383
|
|
|
181
384
|
const backupDir = join(projectPath, backupId);
|
|
182
385
|
if (!existsSync(backupDir)) {
|
|
183
|
-
return {
|
|
386
|
+
return { restored, removed, errors: [`Backup not found: ${backupId}`], notRestored, label: null };
|
|
184
387
|
}
|
|
185
388
|
|
|
186
389
|
// Read backup manifest
|
|
187
|
-
|
|
188
|
-
let backupManifest;
|
|
390
|
+
let bm;
|
|
189
391
|
try {
|
|
190
|
-
|
|
392
|
+
bm = readBackupManifest(projectPath, backupId);
|
|
191
393
|
} catch (err) {
|
|
192
|
-
return {
|
|
394
|
+
return { restored, removed, errors: [`Failed to read backup manifest: ${err.message}`], notRestored, label: null };
|
|
193
395
|
}
|
|
194
396
|
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
397
|
+
// 1. Remove what the step created. This comes first so that a path that is a file in the backup and was
|
|
398
|
+
// replaced by a directory since (or the reverse) is clear before it is restored.
|
|
399
|
+
for (const rel of bm.createdFiles || []) {
|
|
400
|
+
const abs = join(projectPath, rel);
|
|
401
|
+
try {
|
|
402
|
+
if (existsSync(abs)) {
|
|
403
|
+
rmSync(abs, { force: true });
|
|
404
|
+
removed.push(rel);
|
|
405
|
+
}
|
|
406
|
+
} catch (err) {
|
|
407
|
+
errors.push(`Failed to remove ${rel}: ${err.message}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
199
410
|
|
|
411
|
+
// 2. Restore each backed-up entry. A directory is restored file by file: `copyFileSync` throws on one.
|
|
412
|
+
for (const relativePath of bm.backedUpFiles || []) {
|
|
413
|
+
const sourcePath = join(backupDir, relativePath);
|
|
200
414
|
if (!existsSync(sourcePath)) {
|
|
201
415
|
errors.push(`Backup file missing: ${relativePath}`);
|
|
202
416
|
continue;
|
|
203
417
|
}
|
|
204
|
-
|
|
205
418
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
419
|
+
if (lstatSync(sourcePath).isDirectory()) {
|
|
420
|
+
mkdirSync(join(projectPath, relativePath), { recursive: true });
|
|
421
|
+
for (const f of listFiles(backupDir, relativePath)) {
|
|
422
|
+
mkdirSync(dirname(join(projectPath, f)), { recursive: true });
|
|
423
|
+
copyLeaf(join(backupDir, f), join(projectPath, f));
|
|
424
|
+
restored.push(f);
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
mkdirSync(dirname(join(projectPath, relativePath)), { recursive: true });
|
|
428
|
+
copyLeaf(sourcePath, join(projectPath, relativePath));
|
|
429
|
+
restored.push(relativePath);
|
|
430
|
+
}
|
|
209
431
|
} catch (err) {
|
|
210
432
|
errors.push(`Failed to restore ${relativePath}: ${err.message}`);
|
|
211
433
|
}
|
|
212
434
|
}
|
|
213
435
|
|
|
214
|
-
|
|
436
|
+
// 3. Directories the step created, now empty. Only empty ones: rmdir refuses anything else, which is
|
|
437
|
+
// what keeps a file the user added afterwards from being swept away.
|
|
438
|
+
for (const rel of bm.createdDirs || []) {
|
|
439
|
+
try {
|
|
440
|
+
rmdirSync(join(projectPath, rel));
|
|
441
|
+
} catch {
|
|
442
|
+
// not empty, or already gone — leave it
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// 4. Read back. "Restored" is a claim; this is what makes it a fact.
|
|
447
|
+
for (const rel of new Set(restored)) {
|
|
448
|
+
const a = leafSignature(join(projectPath, rel));
|
|
449
|
+
const b = leafSignature(join(backupDir, rel));
|
|
450
|
+
if (a === null || a !== b) errors.push(`Restored file differs from the backup: ${rel}`);
|
|
451
|
+
}
|
|
452
|
+
for (const rel of removed) {
|
|
453
|
+
if (existsSync(join(projectPath, rel))) errors.push(`Created file is still there after rollback: ${rel}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
for (const item of bm.notBackedUp || []) {
|
|
457
|
+
notRestored.push(`${item.path} — ${item.reason}`);
|
|
458
|
+
}
|
|
459
|
+
if (bm.format !== FORMAT_VERSION) {
|
|
460
|
+
notRestored.push(
|
|
461
|
+
`${backupId} was made by an older UDS: files that update created, and .standards/manifest.json, ` +
|
|
462
|
+
'were not recorded and are not restored from it.'
|
|
463
|
+
);
|
|
464
|
+
} else if (!bm.finalized) {
|
|
465
|
+
notRestored.push(
|
|
466
|
+
`${backupId} was never finalized (the update that made it did not finish): files it created are not known, so none were removed.`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if ((bm.failedToBackUp || []).length > 0) {
|
|
470
|
+
notRestored.push(`${backupId} is incomplete: ${bm.failedToBackUp.join('; ')}`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return { restored, removed, errors, notRestored, label: bm.label || null };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Rollback.
|
|
478
|
+
*
|
|
479
|
+
* With no `backupId`, undoes the newest backup AND every earlier one that belongs to the same unbroken
|
|
480
|
+
* series of UDS updates (see the file header) — so `update --apply`, `--apply --skills`, `--apply --commands`
|
|
481
|
+
* followed by one `--rollback` returns to where the first of them started. With an explicit `backupId`,
|
|
482
|
+
* undoes only that backup.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} projectPath - Project root
|
|
485
|
+
* @param {string} [backupId] - Specific backup ID (defaults to the most recent series)
|
|
486
|
+
* @returns {{ success: boolean, restored: string[], removed: string[], errors: string[],
|
|
487
|
+
* notRestored: string[], steps: Array<Object>, olderBackups: number }}
|
|
488
|
+
*/
|
|
489
|
+
export function rollback(projectPath, backupId = null) {
|
|
490
|
+
const restored = [];
|
|
491
|
+
const removed = [];
|
|
492
|
+
const errors = [];
|
|
493
|
+
const notRestored = [];
|
|
494
|
+
const steps = [];
|
|
495
|
+
|
|
496
|
+
const all = listBackups(projectPath);
|
|
497
|
+
let chain;
|
|
498
|
+
if (backupId) {
|
|
499
|
+
if (!existsSync(join(projectPath, backupId))) {
|
|
500
|
+
return { success: false, restored, removed, errors: [`Backup not found: ${backupId}`], notRestored, steps, olderBackups: 0 };
|
|
501
|
+
}
|
|
502
|
+
chain = [backupId];
|
|
503
|
+
} else {
|
|
504
|
+
if (all.length === 0) {
|
|
505
|
+
return { success: false, restored, removed, errors: ['No backups found'], notRestored, steps, olderBackups: 0 };
|
|
506
|
+
}
|
|
507
|
+
chain = [all[0].backupId];
|
|
508
|
+
// Walk back while "the manifest before this step" is "the manifest the previous step left".
|
|
509
|
+
for (let i = 1; i < all.length; i++) {
|
|
510
|
+
const newer = all[i - 1];
|
|
511
|
+
const older = all[i];
|
|
512
|
+
if (newer.preManifestHash && older.postManifestHash && newer.preManifestHash === older.postManifestHash) {
|
|
513
|
+
chain.push(older.backupId);
|
|
514
|
+
} else {
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
for (const id of chain) {
|
|
521
|
+
const one = restoreOne(projectPath, id);
|
|
522
|
+
steps.push({ backupId: id, label: one.label, restored: one.restored, removed: one.removed, errors: one.errors, notRestored: one.notRestored });
|
|
523
|
+
restored.push(...one.restored);
|
|
524
|
+
removed.push(...one.removed);
|
|
525
|
+
errors.push(...one.errors);
|
|
526
|
+
notRestored.push(...one.notRestored);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const olderBackups = backupId ? 0 : all.length - chain.length;
|
|
530
|
+
return {
|
|
531
|
+
success: errors.length === 0,
|
|
532
|
+
restored: [...new Set(restored)],
|
|
533
|
+
removed: [...new Set(removed)],
|
|
534
|
+
errors,
|
|
535
|
+
notRestored,
|
|
536
|
+
steps,
|
|
537
|
+
olderBackups
|
|
538
|
+
};
|
|
215
539
|
}
|
|
216
540
|
|
|
217
541
|
/**
|
|
218
542
|
* List all backups for a project, sorted by creation time (newest first).
|
|
219
543
|
*
|
|
220
544
|
* @param {string} projectPath
|
|
221
|
-
* @returns {Array<{ backupId: string, createdAt: string, actionCount: number
|
|
545
|
+
* @returns {Array<{ backupId: string, createdAt: string, actionCount: number, label: string|null,
|
|
546
|
+
* preManifestHash: string|null, postManifestHash: string|null }>}
|
|
222
547
|
*/
|
|
223
548
|
export function listBackups(projectPath) {
|
|
224
549
|
try {
|
|
@@ -231,12 +556,22 @@ export function listBackups(projectPath) {
|
|
|
231
556
|
const backupDir = join(projectPath, entry.name);
|
|
232
557
|
const manifestPath = join(backupDir, 'backup-manifest.json');
|
|
233
558
|
|
|
234
|
-
|
|
559
|
+
const info = {
|
|
560
|
+
backupId: entry.name,
|
|
561
|
+
createdAt: '',
|
|
562
|
+
actionCount: 0,
|
|
563
|
+
label: null,
|
|
564
|
+
preManifestHash: null,
|
|
565
|
+
postManifestHash: null
|
|
566
|
+
};
|
|
235
567
|
if (existsSync(manifestPath)) {
|
|
236
568
|
try {
|
|
237
569
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
238
570
|
info.createdAt = manifest.createdAt || '';
|
|
239
571
|
info.actionCount = manifest.plan?.actionCount || 0;
|
|
572
|
+
info.label = manifest.label || null;
|
|
573
|
+
info.preManifestHash = manifest.preManifestHash || null;
|
|
574
|
+
info.postManifestHash = manifest.postManifestHash || null;
|
|
240
575
|
} catch {
|
|
241
576
|
// Use defaults
|
|
242
577
|
}
|
|
@@ -245,8 +580,9 @@ export function listBackups(projectPath) {
|
|
|
245
580
|
backups.push(info);
|
|
246
581
|
}
|
|
247
582
|
|
|
248
|
-
// Sort newest first
|
|
249
|
-
|
|
583
|
+
// Sort newest first. The id carries a per-process counter after the timestamp, so ties on
|
|
584
|
+
// `createdAt` (two steps in the same millisecond) still order by creation.
|
|
585
|
+
backups.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.backupId.localeCompare(a.backupId));
|
|
250
586
|
return backups;
|
|
251
587
|
} catch {
|
|
252
588
|
return [];
|