devflow-kit 3.10.0 → 3.11.0

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.
@@ -0,0 +1,780 @@
1
+ import { promises as fs } from 'fs';
2
+ import * as path from 'path';
3
+ import { isContainedIn } from '../../core/paths.js';
4
+ import { createPathSet } from '../../core/path-set.js';
5
+ /**
6
+ * @file install-backup.ts
7
+ *
8
+ * The backup session of one install run: before a step removes files from a folder devflow
9
+ * installs into, the session moves every file devflow did not ship out of that folder into a
10
+ * run folder under `<devflowDir>/install-backups/`, and records what it moved, what it could
11
+ * not move and what it removed.
12
+ *
13
+ * Nothing here deletes a file the user may own. Removal is limited to the entries the scan
14
+ * listed as shipped, retired, a devflow leftover (sidecar) or `.DS_Store` junk, then a
15
+ * non-recursive `rmdir`; a folder that still holds anything stays. The one other removal is a file
16
+ * the caller names by its exact path from a frozen list of retired names
17
+ * ({@link BackupSession.removeExact}). A move is a single `rename`:
18
+ * no copy, so a rename that fails (another volume, no permission) leaves the file where it is.
19
+ * Nothing is read through a link, and a special file is renamed, never opened.
20
+ *
21
+ * Failures are records in {@link BackupSession.snapshot}, never throws.
22
+ *
23
+ * This module imports only node builtins and `core/`. The installer and the converge steps import
24
+ * it, so the shipped and retired sets reach it as {@link PathSet} inputs, a type that `core/path-set`
25
+ * declares and this module re-exports.
26
+ */
27
+ /** Entry cap for one walked known folder. */
28
+ export const DEFAULT_ENTRY_CAP = 10_000;
29
+ /** The deepest known folder a scan descends into, counting the scanned root as 0. */
30
+ export const MAX_WALK_DEPTH = 8;
31
+ /** Run folder names tried before the move step gives up. */
32
+ export const MAX_RUN_FOLDER_ATTEMPTS = 5;
33
+ /** The folder under the devflow dir that holds the run folders. */
34
+ export const BACKUP_ROOT_NAME = 'install-backups';
35
+ const BACKUP_DIR_MODE = 0o700;
36
+ const RUN_FOLDER_BYTES = 4;
37
+ const MAX_ANCESTOR_STEPS = 256;
38
+ const SHOWN_NAMES = 5;
39
+ const JUNK_FILE_NAME = '.DS_Store';
40
+ const COMPLIANCE_SKILL_SIDECAR = 'skills/devflow:compliance.tmp';
41
+ /**
42
+ * What an interrupted write leaves after a file name: `.tmp.<pid>`, the temp file of the atomic
43
+ * writers. No devflow code writes a file named `.old`, a bare `.tmp` or `.<pid>-<time>.tmp`, so a
44
+ * file or a link with such a name is the user's.
45
+ */
46
+ const FILE_SIDECAR_SUFFIX = /\.tmp\.\d+$/;
47
+ /**
48
+ * What an interrupted swap leaves after a folder name: `.tmp`, `.old` and the reference overlay's
49
+ * `.<pid>-<time>.tmp`. Every folder swap in the installer makes a folder.
50
+ */
51
+ const DIRECTORY_SIDECAR_SUFFIX = /(?:\.\d+-[0-9a-z]+\.tmp|\.tmp|\.old)$/;
52
+ /**
53
+ * The scratch folder of a pr-host or cross-cutting reference overlay unit, which sits in the
54
+ * tracker references: `.<kind>.<slug>` with `.old` (the displaced copy of a directory unit) or
55
+ * `.<pid>-<time>.tmp` (the staging tree). The installer makes the slug from the unit's folder with
56
+ * `/` written as `-`, and `root` for the references folder. Group 1 is the references folder, group
57
+ * 2 the slug.
58
+ */
59
+ const OVERLAY_SCRATCH_FOLDER = /^(skills\/[^/]+\/references)\/tracker\/\.(?:pr-host|cross-cutting)\.([a-z0-9-]+)\.(?:\d+-[0-9a-z]+\.tmp|old)$/;
60
+ const OVERLAY_ROOT_SLUG = 'root';
61
+ /**
62
+ * Scratch folders whose base name is no shipped path, matched on the whole path: the curation
63
+ * swap and the workflow folder swap, which sit directly in the Claude dir, and the scratch of the
64
+ * pr-host and cross-cutting reference overlay units.
65
+ */
66
+ const SCRATCH_FOLDER_PATHS = [
67
+ /^\.devflow-curation-\d+-[0-9a-z]+\.tmp(?:\.old)?$/,
68
+ /^\.devflow-workflow-[a-z0-9._-]{1,64}-\d+-[A-Za-z0-9]{6}(?:\.old)?$/,
69
+ OVERLAY_SCRATCH_FOLDER,
70
+ ];
71
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
72
+ export { createPathSet };
73
+ function kindOf(probe) {
74
+ if (probe.isSymbolicLink())
75
+ return 'link';
76
+ if (probe.isDirectory())
77
+ return 'directory';
78
+ return probe.isFile() ? 'file' : 'special';
79
+ }
80
+ /**
81
+ * Whether `rel` is named by `set`. A folder is named when the set holds it. A file or a special
82
+ * file is named only when it is a listed file path. A link is named at a listed file path and at a
83
+ * folder the set holds: it stands in the place of an entry devflow writes, and is unlinked as the
84
+ * entry it is, never followed.
85
+ */
86
+ function inSet(set, rel, kind) {
87
+ switch (kind) {
88
+ case 'directory':
89
+ case 'link':
90
+ return set.holds(rel);
91
+ case 'file':
92
+ case 'special':
93
+ return set.files.has(rel);
94
+ default: {
95
+ const unreachable = kind;
96
+ return unreachable;
97
+ }
98
+ }
99
+ }
100
+ /** The leftover suffix that devflow makes for an entry of this kind. */
101
+ function sidecarSuffixFor(kind) {
102
+ switch (kind) {
103
+ case 'directory':
104
+ return DIRECTORY_SIDECAR_SUFFIX;
105
+ case 'file':
106
+ case 'link':
107
+ case 'special':
108
+ return FILE_SIDECAR_SUFFIX;
109
+ default: {
110
+ const unreachable = kind;
111
+ return unreachable;
112
+ }
113
+ }
114
+ }
115
+ /** `rel` without the leftover suffix of its kind (see {@link sidecarSuffixFor}), or null when it has none. */
116
+ function sidecarBase(rel, kind) {
117
+ const base = rel.replace(sidecarSuffixFor(kind), '');
118
+ return base === rel || base === '' ? null : base;
119
+ }
120
+ /**
121
+ * The path that tells the entries of a leftover folder apart: the shipped path of the folder it was
122
+ * made from. An overlay scratch folder holds the files of one unit (`.pr-host.pr` the `pr/` folder,
123
+ * `.cross-cutting.tracker` the documents directly in `tracker/`, `.cross-cutting.root` those
124
+ * directly in the references folder), so its entries are told apart as entries of that folder.
125
+ * Another leftover folder is told apart by the name it copies, and any other folder by its own path.
126
+ */
127
+ function leftoverLogicalPath(rel) {
128
+ const scratch = OVERLAY_SCRATCH_FOLDER.exec(rel);
129
+ if (scratch !== null) {
130
+ const [, references, slug] = scratch;
131
+ return slug === OVERLAY_ROOT_SLUG ? references : `${references}/${slug.split('-').join('/')}`;
132
+ }
133
+ return sidecarBase(rel, 'directory') ?? rel;
134
+ }
135
+ function isSidecar(rel, kind, sets) {
136
+ if (kind === 'directory' && (rel === COMPLIANCE_SKILL_SIDECAR || SCRATCH_FOLDER_PATHS.some(re => re.test(rel)))) {
137
+ return true;
138
+ }
139
+ const base = sidecarBase(rel, kind);
140
+ if (base === null)
141
+ return false;
142
+ return inSet(sets.shipped, base, kind) || inSet(sets.retired, base, kind);
143
+ }
144
+ /**
145
+ * Decide what the scan does with the entry at `relPath` (relative to the Claude dir, `/`
146
+ * separators). The order is fixed: shipped, retired, sidecar, junk, unknown. A name in both sets
147
+ * is shipped. Only `.DS_Store` is junk, and only as a file; any other dot file is unknown.
148
+ */
149
+ export function classify(relPath, kind, sets) {
150
+ if (inSet(sets.shipped, relPath, kind))
151
+ return 'shipped';
152
+ if (inSet(sets.retired, relPath, kind))
153
+ return 'retired';
154
+ if (isSidecar(relPath, kind, sets))
155
+ return 'sidecar';
156
+ if (kind === 'file' && relPath.split('/').pop() === JUNK_FILE_NAME)
157
+ return 'junk';
158
+ return 'unknown';
159
+ }
160
+ /** Print form of a file name: ESC, C0 and C1 control characters as `\xNN`. The file keeps its name. */
161
+ export function escapeName(name) {
162
+ return name.replace(CONTROL_CHARACTERS, ch => `\\x${ch.charCodeAt(0).toString(16).padStart(2, '0')}`);
163
+ }
164
+ /** `YYYYMMDDTHHMMSSZ-<8 hex>` from a UTC clock reading and four random bytes. Missing bytes read as zero. */
165
+ export function runFolderName(now, bytes) {
166
+ const two = (n) => String(n).padStart(2, '0');
167
+ const valid = !Number.isNaN(now.getTime());
168
+ const stamp = valid
169
+ ? `${String(now.getUTCFullYear()).padStart(4, '0')}${two(now.getUTCMonth() + 1)}${two(now.getUTCDate())}` +
170
+ `T${two(now.getUTCHours())}${two(now.getUTCMinutes())}${two(now.getUTCSeconds())}Z`
171
+ : '00000000T000000Z';
172
+ const hex = Array.from(bytes.subarray(0, RUN_FOLDER_BYTES), b => b.toString(16).padStart(2, '0')).join('');
173
+ return `${stamp}-${hex.padEnd(RUN_FOLDER_BYTES * 2, '0')}`;
174
+ }
175
+ /** The first five names (escaped) and `N more` for the rest, joined with commas. */
176
+ export function formatCappedList(names, shown = SHOWN_NAMES) {
177
+ const head = names.slice(0, shown).map(escapeName);
178
+ const rest = names.length - head.length;
179
+ return rest > 0 ? [...head, `${rest} more`].join(', ') : head.join(', ');
180
+ }
181
+ function errnoCode(err) {
182
+ const code = typeof err === 'object' && err !== null ? err.code : undefined;
183
+ return typeof code === 'string' ? code : 'UNKNOWN';
184
+ }
185
+ async function lstatBig(target) {
186
+ try {
187
+ return { ok: true, stats: await fs.lstat(target, { bigint: true }) };
188
+ }
189
+ catch (err) {
190
+ return { ok: false, code: errnoCode(err) };
191
+ }
192
+ }
193
+ const sameIdentity = (a, b) => a.dev === b.dev && a.ino === b.ino;
194
+ const toPosix = (rel) => rel.split(path.sep).join('/');
195
+ const freezeList = (items) => Object.freeze(items.slice());
196
+ const wholeResult = (result) => Object.freeze(result);
197
+ const exactResult = (result) => Object.freeze(result);
198
+ function exhaustive(value) {
199
+ void value;
200
+ }
201
+ const shownPath = (value) => (value === '' ? '(empty)' : value);
202
+ /**
203
+ * Why a session over these two folders must change nothing, or null. Both folders must be absolute:
204
+ * `path.resolve` turns an empty or relative path into the working directory. The backup root must
205
+ * neither lie inside the Claude dir nor hold it, or a move would put a file inside the folder it
206
+ * leaves, or sweep the backup folder itself.
207
+ */
208
+ function sessionPathsFault(claudeDirOption, devflowDirOption) {
209
+ if (!path.isAbsolute(claudeDirOption)) {
210
+ return Object.freeze({ path: shownPath(claudeDirOption), reason: 'claude-dir-not-absolute' });
211
+ }
212
+ if (!path.isAbsolute(devflowDirOption)) {
213
+ return Object.freeze({ path: shownPath(devflowDirOption), reason: 'devflow-dir-not-absolute' });
214
+ }
215
+ const claude = path.resolve(claudeDirOption);
216
+ const root = path.join(path.resolve(devflowDirOption), BACKUP_ROOT_NAME);
217
+ if (root === claude || isContainedIn(claude, root) || isContainedIn(root, claude)) {
218
+ return Object.freeze({ path: root, reason: 'backup-root-overlaps-claude-dir' });
219
+ }
220
+ return null;
221
+ }
222
+ /**
223
+ * Open a backup session for one install run.
224
+ *
225
+ * The backup root and the run folder are made when the first move is ready to rename, after the
226
+ * checks of its source. A move that fails removes the parent folders it made, and a run folder
227
+ * that holds no move is removed again with the backup root this session made, each with a
228
+ * non-recursive `rmdir`, so a run that moves nothing leaves nothing behind. One session holds at
229
+ * most one run folder.
230
+ *
231
+ * A session whose folders fail {@link sessionPathsFault} changes nothing: it reports the fault as
232
+ * the root refusal, and every step records the fault and keeps its path.
233
+ *
234
+ * Every step that changes a path also refuses a target with a link at a path part between the
235
+ * install folder (the Claude dir or the devflow dir, which may be a link) and the target, with the
236
+ * reason `linked-ancestor`. The containment checks compare paths as text and do not see such a link.
237
+ */
238
+ export function createBackupSession(options) {
239
+ const claudeDir = path.resolve(options.claudeDir);
240
+ const devflowDir = path.resolve(options.devflowDir);
241
+ const backupRoot = path.join(devflowDir, BACKUP_ROOT_NAME);
242
+ const pathsFault = sessionPathsFault(options.claudeDir, options.devflowDir);
243
+ const sets = { shipped: options.shipped, retired: options.retired };
244
+ const requestedCap = options.entryCap ?? DEFAULT_ENTRY_CAP;
245
+ const entryCap = Number.isInteger(requestedCap) && requestedCap > 0 ? requestedCap : DEFAULT_ENTRY_CAP;
246
+ const scanned = new Map();
247
+ const kept = new Set();
248
+ const moved = [];
249
+ const retiredRemoved = [];
250
+ const failures = [];
251
+ const failureKeys = new Set();
252
+ const capHits = [];
253
+ const capKeys = new Set();
254
+ let rootRefusal = pathsFault;
255
+ let runFolder = null;
256
+ let rootMade = false;
257
+ let backup = null;
258
+ const relFromClaude = (target) => toPosix(path.relative(claudeDir, target));
259
+ function fail(target, action, reason, keep = true) {
260
+ if (keep)
261
+ kept.add(target);
262
+ const key = `${action}:${target}`;
263
+ if (failureKeys.has(key))
264
+ return;
265
+ failureKeys.add(key);
266
+ failures.push(Object.freeze({ path: target, action, reason }));
267
+ }
268
+ function refuse(reason, at) {
269
+ rootRefusal = Object.freeze({ path: at, reason });
270
+ return null;
271
+ }
272
+ /** `rmdir` without recursion. True when the folder is gone; false when it holds something or could not go. */
273
+ async function removeEmptyFolder(target) {
274
+ try {
275
+ await fs.rmdir(target);
276
+ return true;
277
+ }
278
+ catch (err) {
279
+ const code = errnoCode(err);
280
+ if (code === 'ENOENT')
281
+ return true;
282
+ if (code !== 'ENOTEMPTY' && code !== 'EEXIST')
283
+ fail(target, 'remove', code, false);
284
+ return false;
285
+ }
286
+ }
287
+ async function dropMadeRoot() {
288
+ if (rootMade && (await removeEmptyFolder(backupRoot)))
289
+ rootMade = false;
290
+ }
291
+ async function refuseOpening(reason, at) {
292
+ await dropMadeRoot();
293
+ return refuse(reason, at);
294
+ }
295
+ async function openBackup() {
296
+ const existing = await lstatBig(backupRoot);
297
+ if (existing.ok) {
298
+ if (existing.stats.isSymbolicLink())
299
+ return refuse('link', backupRoot);
300
+ if (!existing.stats.isDirectory())
301
+ return refuse('not-a-directory', backupRoot);
302
+ }
303
+ else if (existing.code !== 'ENOENT') {
304
+ return refuse(existing.code, backupRoot);
305
+ }
306
+ else {
307
+ try {
308
+ await fs.mkdir(backupRoot, { recursive: true, mode: BACKUP_DIR_MODE });
309
+ rootMade = true;
310
+ }
311
+ catch (err) {
312
+ return refuse(errnoCode(err), backupRoot);
313
+ }
314
+ }
315
+ for (let attempt = 0; attempt < MAX_RUN_FOLDER_ATTEMPTS; attempt++) {
316
+ const candidate = path.join(backupRoot, runFolderName(options.now(), options.bytes(RUN_FOLDER_BYTES)));
317
+ try {
318
+ await fs.mkdir(candidate, { mode: BACKUP_DIR_MODE });
319
+ runFolder = candidate;
320
+ return { runFolder: candidate };
321
+ }
322
+ catch (err) {
323
+ const code = errnoCode(err);
324
+ if (code !== 'EEXIST')
325
+ return refuseOpening(code, candidate);
326
+ }
327
+ }
328
+ return refuseOpening('run-folder-collisions', backupRoot);
329
+ }
330
+ function ensureBackup() {
331
+ backup ??= pathsFault === null ? openBackup() : Promise.resolve(null);
332
+ return backup;
333
+ }
334
+ /** The install folder that holds `target`: the Claude dir first, then the devflow dir. */
335
+ function installRootOf(target) {
336
+ if (isContainedIn(claudeDir, target))
337
+ return claudeDir;
338
+ return isContainedIn(devflowDir, target) ? devflowDir : null;
339
+ }
340
+ /**
341
+ * Walk up from `target` to its install folder, both left out, and refuse a link at any step. An
342
+ * ancestor that is absent passes, as the target below it is then absent too. One that cannot be
343
+ * inspected, and a walk that ends at the step bound, refuse.
344
+ */
345
+ async function checkAncestors(target) {
346
+ const root = installRootOf(target);
347
+ if (root === null)
348
+ return { ok: false, reason: 'outside-install-dirs' };
349
+ let current = path.dirname(target);
350
+ for (let step = 0; step < MAX_ANCESTOR_STEPS; step++) {
351
+ if (!isContainedIn(root, current))
352
+ return { ok: true };
353
+ const probe = await lstatBig(current);
354
+ if (probe.ok) {
355
+ if (probe.stats.isSymbolicLink())
356
+ return { ok: false, reason: 'linked-ancestor' };
357
+ }
358
+ else if (probe.code !== 'ENOENT') {
359
+ return { ok: false, reason: probe.code };
360
+ }
361
+ current = path.dirname(current);
362
+ }
363
+ return { ok: false, reason: 'ancestor-depth-bound' };
364
+ }
365
+ /**
366
+ * Remove the run folder, and the backup root this session made, when no move succeeded. A
367
+ * folder that holds anything stays, and the next move reuses it.
368
+ */
369
+ async function releaseEmptyRun() {
370
+ if (moved.length > 0 || runFolder === null)
371
+ return;
372
+ if (!(await removeEmptyFolder(runFolder)))
373
+ return;
374
+ runFolder = null;
375
+ backup = null;
376
+ await dropMadeRoot();
377
+ }
378
+ /**
379
+ * Make the folders of `parent` that are missing below `runDir`, one `mkdir` each from the top.
380
+ * `made` lists the folders this call made, top first, also when a later `mkdir` failed.
381
+ */
382
+ async function makeMissingParents(parent, runDir) {
383
+ const missing = [];
384
+ let current = parent;
385
+ for (let step = 0; step < MAX_ANCESTOR_STEPS && isContainedIn(runDir, current); step++) {
386
+ const probe = await lstatBig(current);
387
+ if (probe.ok)
388
+ break;
389
+ if (probe.code !== 'ENOENT')
390
+ return { made: [], code: probe.code };
391
+ missing.push(current);
392
+ current = path.dirname(current);
393
+ }
394
+ const made = [];
395
+ for (const folder of [...missing].reverse()) {
396
+ try {
397
+ await fs.mkdir(folder, { mode: BACKUP_DIR_MODE });
398
+ made.push(folder);
399
+ }
400
+ catch (err) {
401
+ return { made, code: errnoCode(err) };
402
+ }
403
+ }
404
+ return { made, code: null };
405
+ }
406
+ /** `rmdir` the folders a failed move made, deepest first, up to the first one that holds something. */
407
+ async function removeMadeParents(made) {
408
+ for (const folder of [...made].reverse()) {
409
+ if (!(await removeEmptyFolder(folder)))
410
+ return;
411
+ }
412
+ }
413
+ async function placeInRun(source, kind, runDir) {
414
+ const destination = path.join(runDir, ...relFromClaude(source).split('/'));
415
+ if (!isContainedIn(runDir, destination)) {
416
+ fail(source, 'move', 'outside-run-folder');
417
+ return { status: 'kept', reason: 'outside-run-folder' };
418
+ }
419
+ const occupied = await lstatBig(destination);
420
+ if (occupied.ok || occupied.code !== 'ENOENT') {
421
+ const reason = occupied.ok ? 'destination-exists' : occupied.code;
422
+ fail(source, 'move', reason);
423
+ return { status: 'kept', reason };
424
+ }
425
+ const parents = await makeMissingParents(path.dirname(destination), runDir);
426
+ let failure = parents.code;
427
+ if (failure === null) {
428
+ try {
429
+ await fs.rename(source, destination);
430
+ }
431
+ catch (err) {
432
+ failure = errnoCode(err);
433
+ }
434
+ }
435
+ if (failure !== null) {
436
+ fail(source, 'move', failure);
437
+ await removeMadeParents(parents.made);
438
+ return { status: 'kept', reason: failure };
439
+ }
440
+ const unit = Object.freeze({ original: source, backup: destination, kind });
441
+ kept.delete(source);
442
+ moved.push(unit);
443
+ return { status: 'moved', unit };
444
+ }
445
+ async function moveUnit(source, scanRoot, atScan) {
446
+ if (!isContainedIn(scanRoot, source) || !isContainedIn(claudeDir, source)) {
447
+ fail(source, 'move', 'outside-scan-root');
448
+ return { status: 'kept', reason: 'outside-scan-root' };
449
+ }
450
+ const current = await lstatBig(source);
451
+ if (!current.ok) {
452
+ if (current.code === 'ENOENT')
453
+ return { status: 'vanished' };
454
+ fail(source, 'move', current.code);
455
+ return { status: 'kept', reason: current.code };
456
+ }
457
+ if (!sameIdentity(current.stats, atScan)) {
458
+ fail(source, 'move', 'changed-since-scan');
459
+ return { status: 'kept', reason: 'changed-since-scan' };
460
+ }
461
+ const ready = await ensureBackup();
462
+ if (ready === null) {
463
+ kept.add(source);
464
+ return { status: 'kept', reason: rootRefusal?.reason ?? 'backup-unavailable' };
465
+ }
466
+ const outcome = await placeInRun(source, kindOf(current.stats), ready.runFolder);
467
+ if (outcome.status !== 'moved')
468
+ await releaseEmptyRun();
469
+ return outcome;
470
+ }
471
+ async function scanFolder(dirPath, logicalRel, depth, scanRoot, include) {
472
+ if (depth > MAX_WALK_DEPTH) {
473
+ fail(dirPath, 'scan', 'depth-bound');
474
+ return null;
475
+ }
476
+ const own = await lstatBig(dirPath);
477
+ if (!own.ok) {
478
+ if (own.code !== 'ENOENT')
479
+ fail(dirPath, 'scan', own.code);
480
+ return null;
481
+ }
482
+ if (!own.stats.isDirectory()) {
483
+ fail(dirPath, 'scan', 'not-a-real-folder');
484
+ return null;
485
+ }
486
+ const identity = { dev: own.stats.dev, ino: own.stats.ino };
487
+ let entries;
488
+ try {
489
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
490
+ }
491
+ catch (err) {
492
+ fail(dirPath, 'scan', errnoCode(err));
493
+ return null;
494
+ }
495
+ if (entries.length > entryCap) {
496
+ kept.add(dirPath);
497
+ if (!capKeys.has(dirPath)) {
498
+ capKeys.add(dirPath);
499
+ capHits.push(Object.freeze({ folder: dirPath, entries: entries.length, cap: entryCap }));
500
+ }
501
+ const whole = { path: dirPath, identity, listed: [], dirs: [], capped: true };
502
+ scanned.set(dirPath, whole);
503
+ return whole;
504
+ }
505
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
506
+ const folder = { path: dirPath, identity, listed: [], dirs: [], capped: false };
507
+ scanned.set(dirPath, folder);
508
+ const toMove = [];
509
+ for (const entry of entries) {
510
+ const childPath = path.join(dirPath, entry.name);
511
+ const childLogical = logicalRel === '' ? entry.name : `${logicalRel}/${entry.name}`;
512
+ const kind = kindOf(entry);
513
+ if (include !== undefined && !include(toPosix(path.relative(scanRoot, childPath)), kind))
514
+ continue;
515
+ const verdict = classify(childLogical, kind, sets);
516
+ switch (verdict) {
517
+ case 'shipped':
518
+ case 'retired': {
519
+ if (kind === 'directory') {
520
+ const sub = await scanFolder(childPath, childLogical, depth + 1, scanRoot, include);
521
+ if (sub !== null)
522
+ folder.dirs.push(sub);
523
+ }
524
+ else {
525
+ folder.listed.push({ path: childPath, retired: verdict === 'retired' });
526
+ }
527
+ break;
528
+ }
529
+ case 'sidecar': {
530
+ if (kind === 'directory') {
531
+ const sub = await scanFolder(childPath, leftoverLogicalPath(childLogical), depth + 1, scanRoot, include);
532
+ if (sub !== null)
533
+ folder.dirs.push(sub);
534
+ }
535
+ else {
536
+ folder.listed.push({ path: childPath, retired: false });
537
+ }
538
+ break;
539
+ }
540
+ case 'junk':
541
+ folder.listed.push({ path: childPath, retired: false });
542
+ break;
543
+ case 'unknown': {
544
+ const found = await lstatBig(childPath);
545
+ if (found.ok)
546
+ toMove.push({ path: childPath, stats: found.stats });
547
+ else if (found.code !== 'ENOENT')
548
+ fail(childPath, 'scan', found.code);
549
+ break;
550
+ }
551
+ default:
552
+ exhaustive(verdict);
553
+ }
554
+ }
555
+ for (const unit of toMove) {
556
+ await moveUnit(unit.path, scanRoot, unit.stats);
557
+ }
558
+ return folder;
559
+ }
560
+ async function prepass(targets) {
561
+ const movedBefore = moved.length;
562
+ const failuresBefore = failures.length;
563
+ const capsBefore = capHits.length;
564
+ for (const target of targets) {
565
+ const { folder, include, displacedFrom } = typeof target === 'string' ? { folder: target, include: undefined, displacedFrom: undefined } : target;
566
+ const absolute = path.resolve(folder);
567
+ const stoodAt = displacedFrom === undefined ? absolute : path.resolve(displacedFrom);
568
+ if (pathsFault !== null) {
569
+ fail(absolute, 'scan', pathsFault.reason);
570
+ continue;
571
+ }
572
+ if (!isContainedIn(claudeDir, absolute) || !isContainedIn(claudeDir, stoodAt)) {
573
+ fail(absolute, 'scan', 'outside-claude-dir');
574
+ continue;
575
+ }
576
+ const ancestors = await checkAncestors(absolute);
577
+ if (!ancestors.ok) {
578
+ fail(absolute, 'scan', ancestors.reason);
579
+ continue;
580
+ }
581
+ const rel = relFromClaude(stoodAt);
582
+ const logical = classify(rel, 'directory', sets) === 'sidecar' ? leftoverLogicalPath(rel) : rel;
583
+ await scanFolder(absolute, logical, 0, absolute, include);
584
+ }
585
+ return Object.freeze({
586
+ moved: freezeList(moved.slice(movedBefore)),
587
+ failures: freezeList(failures.slice(failuresBefore)),
588
+ capHits: freezeList(capHits.slice(capsBefore)),
589
+ });
590
+ }
591
+ async function removeFolder(folder, removed) {
592
+ if (folder.capped)
593
+ return false;
594
+ const current = await lstatBig(folder.path);
595
+ if (!current.ok) {
596
+ if (current.code === 'ENOENT')
597
+ return true;
598
+ fail(folder.path, 'remove', current.code);
599
+ return false;
600
+ }
601
+ if (!sameIdentity(current.stats, folder.identity)) {
602
+ fail(folder.path, 'remove', 'changed-since-scan');
603
+ return false;
604
+ }
605
+ for (const entry of folder.listed) {
606
+ if (kept.has(entry.path))
607
+ continue;
608
+ try {
609
+ await fs.unlink(entry.path);
610
+ removed.push(entry.path);
611
+ if (entry.retired)
612
+ retiredRemoved.push(entry.path);
613
+ }
614
+ catch (err) {
615
+ const code = errnoCode(err);
616
+ if (code !== 'ENOENT')
617
+ fail(entry.path, 'remove', code);
618
+ }
619
+ }
620
+ for (const sub of folder.dirs) {
621
+ await removeFolder(sub, removed);
622
+ }
623
+ try {
624
+ await fs.rmdir(folder.path);
625
+ return true;
626
+ }
627
+ catch (err) {
628
+ const code = errnoCode(err);
629
+ if (code === 'ENOENT')
630
+ return true;
631
+ if (code !== 'ENOTEMPTY' && code !== 'EEXIST')
632
+ fail(folder.path, 'remove', code);
633
+ return false;
634
+ }
635
+ }
636
+ async function removeListed(folder) {
637
+ const target = path.resolve(folder);
638
+ const removed = [];
639
+ const known = scanned.get(target);
640
+ if (known === undefined) {
641
+ const state = await lstatBig(target);
642
+ return Object.freeze({ removed: freezeList(removed), folderRemoved: !state.ok && state.code === 'ENOENT' });
643
+ }
644
+ const ancestors = await checkAncestors(target);
645
+ if (!ancestors.ok) {
646
+ fail(target, 'remove', ancestors.reason);
647
+ return Object.freeze({ removed: freezeList(removed), folderRemoved: false });
648
+ }
649
+ const folderRemoved = await removeFolder(known, removed);
650
+ return Object.freeze({ removed: freezeList(removed), folderRemoved });
651
+ }
652
+ async function moveWhole(entry) {
653
+ const source = path.resolve(entry);
654
+ if (pathsFault !== null) {
655
+ fail(source, 'move', pathsFault.reason);
656
+ return wholeResult({ status: 'kept', reason: pathsFault.reason });
657
+ }
658
+ if (!isContainedIn(claudeDir, source)) {
659
+ fail(source, 'move', 'outside-claude-dir');
660
+ return wholeResult({ status: 'kept', reason: 'outside-claude-dir' });
661
+ }
662
+ if (isKept(source))
663
+ return wholeResult({ status: 'kept', reason: 'kept-by-earlier-failure' });
664
+ const ancestors = await checkAncestors(source);
665
+ if (!ancestors.ok) {
666
+ fail(source, 'move', ancestors.reason);
667
+ return wholeResult({ status: 'kept', reason: ancestors.reason });
668
+ }
669
+ const found = await lstatBig(source);
670
+ if (!found.ok) {
671
+ if (found.code === 'ENOENT')
672
+ return wholeResult({ status: 'absent' });
673
+ fail(source, 'move', found.code);
674
+ return wholeResult({ status: 'kept', reason: found.code });
675
+ }
676
+ const outcome = await moveUnit(source, claudeDir, found.stats);
677
+ switch (outcome.status) {
678
+ case 'moved':
679
+ return wholeResult({ status: 'moved', unit: outcome.unit });
680
+ case 'vanished':
681
+ return wholeResult({ status: 'absent' });
682
+ case 'kept':
683
+ return wholeResult({ status: 'kept', reason: outcome.reason });
684
+ }
685
+ }
686
+ async function removeExact(file) {
687
+ const target = path.resolve(file);
688
+ if (pathsFault !== null) {
689
+ fail(target, 'remove', pathsFault.reason);
690
+ return exactResult({ status: 'kept', reason: pathsFault.reason });
691
+ }
692
+ const inInstallDirs = isContainedIn(claudeDir, target) || isContainedIn(devflowDir, target);
693
+ if (!inInstallDirs || isContainedIn(backupRoot, target)) {
694
+ fail(target, 'remove', 'outside-install-dirs');
695
+ return exactResult({ status: 'kept', reason: 'outside-install-dirs' });
696
+ }
697
+ if (isKept(target))
698
+ return exactResult({ status: 'kept', reason: 'kept-by-earlier-failure' });
699
+ const parent = await lstatBig(path.dirname(target));
700
+ if (!parent.ok) {
701
+ if (parent.code === 'ENOENT')
702
+ return exactResult({ status: 'absent' });
703
+ fail(target, 'remove', parent.code);
704
+ return exactResult({ status: 'kept', reason: parent.code });
705
+ }
706
+ if (!parent.stats.isDirectory()) {
707
+ fail(target, 'remove', 'parent-not-a-folder');
708
+ return exactResult({ status: 'kept', reason: 'parent-not-a-folder' });
709
+ }
710
+ const ancestors = await checkAncestors(target);
711
+ if (!ancestors.ok) {
712
+ fail(target, 'remove', ancestors.reason);
713
+ return exactResult({ status: 'kept', reason: ancestors.reason });
714
+ }
715
+ const found = await lstatBig(target);
716
+ if (!found.ok) {
717
+ if (found.code === 'ENOENT')
718
+ return exactResult({ status: 'absent' });
719
+ fail(target, 'remove', found.code);
720
+ return exactResult({ status: 'kept', reason: found.code });
721
+ }
722
+ if (found.stats.isDirectory())
723
+ return exactResult({ status: 'kept', reason: 'is-a-folder' });
724
+ try {
725
+ await fs.unlink(target);
726
+ }
727
+ catch (err) {
728
+ const code = errnoCode(err);
729
+ if (code === 'ENOENT')
730
+ return exactResult({ status: 'absent' });
731
+ fail(target, 'remove', code);
732
+ return exactResult({ status: 'kept', reason: code });
733
+ }
734
+ retiredRemoved.push(target);
735
+ return exactResult({ status: 'removed' });
736
+ }
737
+ function keptUnder(folder) {
738
+ const base = path.resolve(folder);
739
+ const inside = [...kept].filter(entry => entry === base || entry.startsWith(base + path.sep));
740
+ return freezeList(inside.sort());
741
+ }
742
+ function isKept(entry) {
743
+ let current = path.resolve(entry);
744
+ for (let step = 0; step < MAX_ANCESTOR_STEPS; step++) {
745
+ if (kept.has(current))
746
+ return true;
747
+ const parent = path.dirname(current);
748
+ if (parent === current || current === claudeDir)
749
+ return false;
750
+ current = parent;
751
+ }
752
+ return false;
753
+ }
754
+ function snapshot() {
755
+ return Object.freeze({
756
+ backupRoot,
757
+ runFolder,
758
+ moved: freezeList(moved),
759
+ retiredRemoved: freezeList(retiredRemoved),
760
+ failures: freezeList(failures),
761
+ capHits: freezeList(capHits),
762
+ rootRefusal,
763
+ });
764
+ }
765
+ return Object.freeze({
766
+ prepass,
767
+ removeListed,
768
+ moveWhole,
769
+ removeExact,
770
+ keptUnder,
771
+ isKept,
772
+ classify: (relPath, kind) => classify(relPath, kind, sets),
773
+ classifyAt: (entry, kind) => {
774
+ const absolute = path.resolve(entry);
775
+ return isContainedIn(claudeDir, absolute) ? classify(relFromClaude(absolute), kind, sets) : 'unknown';
776
+ },
777
+ snapshot,
778
+ });
779
+ }
780
+ //# sourceMappingURL=install-backup.js.map