@papillon6814/copi 0.1.0 → 0.2.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.
Files changed (53) hide show
  1. package/LICENSE.unicode +39 -0
  2. package/README.md +57 -5
  3. package/dist/native/rename-exclusive-arm64 +0 -0
  4. package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
  5. package/dist/src/apply.d.ts +9 -0
  6. package/dist/src/apply.js +110 -9
  7. package/dist/src/catalog.d.ts +21 -4
  8. package/dist/src/catalog.js +265 -133
  9. package/dist/src/cli.d.ts +2 -1
  10. package/dist/src/cli.js +73 -3
  11. package/dist/src/groups.js +8 -3
  12. package/dist/src/inspect.d.ts +3 -1
  13. package/dist/src/inspect.js +16 -6
  14. package/dist/src/instruction-reference.d.ts +6 -0
  15. package/dist/src/instruction-reference.js +21 -0
  16. package/dist/src/manage.d.ts +2 -0
  17. package/dist/src/manage.js +329 -0
  18. package/dist/src/migrate.d.ts +25 -0
  19. package/dist/src/migrate.js +171 -0
  20. package/dist/src/migration-backup.d.ts +22 -0
  21. package/dist/src/migration-backup.js +403 -0
  22. package/dist/src/migration-inventory.d.ts +9 -0
  23. package/dist/src/migration-inventory.js +237 -0
  24. package/dist/src/migration-resolution.d.ts +28 -0
  25. package/dist/src/migration-resolution.js +1618 -0
  26. package/dist/src/migration.d.ts +49 -0
  27. package/dist/src/migration.js +1004 -0
  28. package/dist/src/names.d.ts +11 -0
  29. package/dist/src/names.js +40 -0
  30. package/dist/src/native-placement.d.ts +16 -0
  31. package/dist/src/native-placement.js +557 -0
  32. package/dist/src/refresh.d.ts +20 -0
  33. package/dist/src/refresh.js +143 -0
  34. package/dist/src/resource-mutations.d.ts +45 -0
  35. package/dist/src/resource-mutations.js +225 -0
  36. package/dist/src/source-content.d.ts +4 -0
  37. package/dist/src/source-content.js +208 -0
  38. package/dist/src/storage-journal.d.ts +111 -0
  39. package/dist/src/storage-journal.js +613 -0
  40. package/dist/src/storage-root-lease.d.ts +7 -0
  41. package/dist/src/storage-root-lease.js +92 -0
  42. package/dist/src/storage-transaction.d.ts +100 -0
  43. package/dist/src/storage-transaction.js +3951 -0
  44. package/dist/src/terminal-editor.d.ts +8 -0
  45. package/dist/src/terminal-editor.js +391 -0
  46. package/dist/src/types.d.ts +18 -2
  47. package/dist/src/unicode-case-folding-data.d.ts +1 -0
  48. package/dist/src/unicode-case-folding-data.js +1596 -0
  49. package/dist/src/unicode-case-folding.d.ts +2 -0
  50. package/dist/src/unicode-case-folding.js +9 -0
  51. package/dist/src/wizard.d.ts +11 -3
  52. package/dist/src/wizard.js +274 -18
  53. package/package.json +5 -3
@@ -0,0 +1,1004 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createTwoFilesPatch } from "diff";
3
+ import { constants } from "node:fs";
4
+ import { types as utilTypes } from "node:util";
5
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { inspectFixedResource, maskSensitiveText } from "./inspect.js";
8
+ import { buildMigrationBackupManifest } from "./migration-backup.js";
9
+ import { prepareLegacyMigrationReview, storageTransactions } from "./storage-transaction.js";
10
+ import { parseStoredCatalog } from "./catalog.js";
11
+ import { listManagedMigrationRootEntries, scanMigrationInventory } from "./migration-inventory.js";
12
+ const preparedStates = new WeakMap();
13
+ const excludedNames = new Set([
14
+ ".git", ".cache", ".npm", "auth", "node_modules", "cache", "caches", "history", "sessions",
15
+ "auth.json", "mcp-auth.json", "oauth.json",
16
+ ]);
17
+ const sha256Pattern = /^[a-f0-9]{64}$/;
18
+ const itemIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
19
+ const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
20
+ const compare = (left, right) => left < right ? -1 : left > right ? 1 : 0;
21
+ function isWithin(root, target) {
22
+ const relative = path.relative(root, target);
23
+ return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
24
+ }
25
+ function safeRelative(value) {
26
+ return value.length > 0 && !value.includes("\\") && !value.includes("\0") && !path.posix.isAbsolute(value) &&
27
+ value.split("/").every((part) => part !== "" && part !== "." && part !== "..") && path.posix.normalize(value) === value;
28
+ }
29
+ function excluded(relative) {
30
+ return relative.split("/").some((part) => {
31
+ const lower = part.toLowerCase();
32
+ return excludedNames.has(lower) || lower === ".env" || lower.startsWith(".env");
33
+ });
34
+ }
35
+ function identityOfDirectory(target, stat) {
36
+ return { path: target, dev: stat.dev.toString(), ino: stat.ino.toString(), mode: stat.mode.toString() };
37
+ }
38
+ function stateOfDirectory(target, stat) {
39
+ return {
40
+ ...identityOfDirectory(target, stat),
41
+ size: stat.size.toString(),
42
+ mtimeNs: stat.mtimeNs.toString(),
43
+ ctimeNs: stat.ctimeNs.toString(),
44
+ };
45
+ }
46
+ function sameDirectoryIdentity(left, right) {
47
+ return left.path === right.path && left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
48
+ }
49
+ function sameDirectory(left, right) {
50
+ return sameDirectoryIdentity(left, right) && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
51
+ }
52
+ function sameRootDirectoryState(left, right) {
53
+ return sameDirectoryIdentity(left, right);
54
+ }
55
+ function sameFile(left, right) {
56
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.size === right.size &&
57
+ left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
58
+ }
59
+ async function checkCanonicalAncestors(target) {
60
+ if (!path.isAbsolute(target) || path.resolve(target) !== target)
61
+ throw new Error("Library path must resolve to a canonical absolute path.");
62
+ const identities = [];
63
+ const volume = path.parse(target).root;
64
+ let current = volume;
65
+ const segments = target.slice(volume.length).split(path.sep).filter(Boolean);
66
+ const rootStat = await lstat(current, { bigint: true });
67
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || await realpath(current) !== current) {
68
+ throw new Error("Library path has an unsafe filesystem root.");
69
+ }
70
+ identities.push(identityOfDirectory(current, rootStat));
71
+ if (segments.length === 0)
72
+ return { exists: true, identities, rootDirectoryState: stateOfDirectory(current, rootStat) };
73
+ for (const segment of segments) {
74
+ current = path.join(current, segment);
75
+ const stat = await lstat(current, { bigint: true }).catch((error) => {
76
+ if (error.code === "ENOENT")
77
+ return undefined;
78
+ throw error;
79
+ });
80
+ if (!stat)
81
+ return { exists: false, identities };
82
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(current) !== current) {
83
+ throw new Error("Library root or an ancestor is not a canonical real directory.");
84
+ }
85
+ identities.push(identityOfDirectory(current, stat));
86
+ if (current === target)
87
+ return { exists: true, identities, rootDirectoryState: stateOfDirectory(current, stat) };
88
+ }
89
+ throw new Error("Library root could not be identified as a canonical directory.");
90
+ }
91
+ async function assertDirectoryChain(identities) {
92
+ for (const expected of identities) {
93
+ const stat = await lstat(expected.path, { bigint: true });
94
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(expected.path) !== expected.path ||
95
+ !sameDirectoryIdentity(expected, identityOfDirectory(expected.path, stat))) {
96
+ throw new Error("Library root or an ancestor changed during the read-only preview.");
97
+ }
98
+ }
99
+ }
100
+ async function assertDirectoryState(expected) {
101
+ if (!expected)
102
+ return;
103
+ const stat = await lstat(expected.path, { bigint: true });
104
+ if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(expected.path) !== expected.path ||
105
+ !sameRootDirectoryState(expected, stateOfDirectory(expected.path, stat))) {
106
+ throw new Error("Library root directory changed during the read-only preview.");
107
+ }
108
+ }
109
+ async function safeReadFile(root, relative) {
110
+ if (!safeRelative(relative))
111
+ throw new Error(`Unsafe library input path: ${relative}`);
112
+ const target = path.join(root, ...relative.split("/"));
113
+ let parent = root;
114
+ const parentStates = [];
115
+ for (const segment of relative.split("/").slice(0, -1)) {
116
+ parent = path.join(parent, segment);
117
+ const parentStat = await lstat(parent, { bigint: true });
118
+ if (!parentStat.isDirectory() || parentStat.isSymbolicLink() || await realpath(parent) !== parent) {
119
+ throw new Error(`Unsafe directory in library input: ${relative}`);
120
+ }
121
+ parentStates.push(stateOfDirectory(parent, parentStat));
122
+ }
123
+ const before = await lstat(target, { bigint: true });
124
+ if (!before.isFile() || before.isSymbolicLink() || await realpath(target) !== target || !isWithin(root, target)) {
125
+ throw new Error(`Library input is not a canonical regular file: ${relative}`);
126
+ }
127
+ const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
128
+ try {
129
+ const opened = await handle.stat({ bigint: true });
130
+ if (!opened.isFile() || !sameFile(before, opened))
131
+ throw new Error(`Library input changed while opening: ${relative}`);
132
+ const bytes = await handle.readFile();
133
+ const [afterHandle, afterPath, canonicalAfter] = await Promise.all([
134
+ handle.stat({ bigint: true }), lstat(target, { bigint: true }), realpath(target),
135
+ ]);
136
+ if (!afterPath.isFile() || afterPath.isSymbolicLink() || canonicalAfter !== target ||
137
+ !sameFile(opened, afterHandle) || !sameFile(opened, afterPath)) {
138
+ throw new Error(`Library input changed while reading: ${relative}`);
139
+ }
140
+ for (const expected of parentStates) {
141
+ const current = await lstat(expected.path, { bigint: true });
142
+ if (!current.isDirectory() || current.isSymbolicLink() || !sameDirectory(expected, stateOfDirectory(expected.path, current))) {
143
+ throw new Error(`Library input parent changed while reading: ${relative}`);
144
+ }
145
+ }
146
+ return {
147
+ path: relative,
148
+ bytes: Buffer.from(bytes),
149
+ sha256: digest(bytes),
150
+ mode: Number(opened.mode & 511n),
151
+ dev: opened.dev.toString(),
152
+ ino: opened.ino.toString(),
153
+ };
154
+ }
155
+ finally {
156
+ await handle.close();
157
+ }
158
+ }
159
+ async function scanTree(root, relativeRoot) {
160
+ const treePath = path.join(root, relativeRoot);
161
+ const rootStat = await lstat(treePath, { bigint: true }).catch((error) => {
162
+ if (error.code === "ENOENT")
163
+ return undefined;
164
+ throw error;
165
+ });
166
+ if (!rootStat)
167
+ return { present: false, directories: new Map(), entries: new Map(), files: new Map() };
168
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || await realpath(treePath) !== treePath) {
169
+ throw new Error(`${relativeRoot}/ is not a canonical real directory.`);
170
+ }
171
+ const directories = new Map();
172
+ const entries = new Map();
173
+ const files = new Map();
174
+ const visit = async (absolute, relative) => {
175
+ const before = await lstat(absolute, { bigint: true });
176
+ if (!before.isDirectory() || before.isSymbolicLink() || await realpath(absolute) !== absolute) {
177
+ throw new Error(`Unsafe directory in ${relativeRoot}/ input: ${relative}`);
178
+ }
179
+ const beforeState = stateOfDirectory(absolute, before);
180
+ directories.set(relative, beforeState);
181
+ const names = (await readdir(absolute)).sort(compare);
182
+ entries.set(relative, [...names]);
183
+ for (const name of names) {
184
+ const childRelative = `${relative}/${name}`;
185
+ const childAbsolute = path.join(absolute, name);
186
+ const stat = await lstat(childAbsolute, { bigint: true });
187
+ if (stat.isSymbolicLink())
188
+ throw new Error(`Symbolic link in ${relativeRoot}/ input: ${childRelative}`);
189
+ if (stat.isDirectory())
190
+ await visit(childAbsolute, childRelative);
191
+ else if (stat.isFile()) {
192
+ const file = await safeReadFile(root, childRelative);
193
+ files.set(childRelative, file);
194
+ }
195
+ else
196
+ throw new Error(`Unsupported special file in ${relativeRoot}/ input: ${childRelative}`);
197
+ }
198
+ const [after, finalNames, canonical] = await Promise.all([
199
+ lstat(absolute, { bigint: true }), readdir(absolute), realpath(absolute),
200
+ ]);
201
+ if (!after.isDirectory() || after.isSymbolicLink() || canonical !== absolute ||
202
+ !sameDirectory(beforeState, stateOfDirectory(absolute, after)) ||
203
+ JSON.stringify(finalNames.sort(compare)) !== JSON.stringify(names)) {
204
+ throw new Error(`Directory changed while reading ${relativeRoot}/ input: ${relative}`);
205
+ }
206
+ };
207
+ await visit(treePath, relativeRoot);
208
+ return { present: true, directories, entries, files };
209
+ }
210
+ function parseCatalogBytes(bytes) {
211
+ let value;
212
+ try {
213
+ value = JSON.parse(bytes.toString("utf8"));
214
+ }
215
+ catch {
216
+ throw new Error("catalog.json is not valid JSON.");
217
+ }
218
+ const parsed = parseStoredCatalog(value);
219
+ if (!value || typeof value !== "object" || Array.isArray(value))
220
+ throw new Error("catalog.json has an invalid top-level value.");
221
+ const record = value;
222
+ if (Object.keys(record).sort(compare).join("|") !== "groups|items|version")
223
+ throw new Error("catalog.json contains unsupported fields.");
224
+ if (parsed.version === 1) {
225
+ const rawItems = record.items;
226
+ for (const rawItem of rawItems) {
227
+ if (!rawItem || typeof rawItem !== "object" || Array.isArray(rawItem))
228
+ throw new Error("Legacy catalog contains an invalid item.");
229
+ const item = rawItem;
230
+ if (Object.keys(item).sort(compare).join("|") !== "destination|files|id|kind|name|savedAt|scope|source|sourcePath") {
231
+ throw new Error("Legacy catalog item contains unsupported fields.");
232
+ }
233
+ const destination = item.destination;
234
+ const expectedDestinationKeys = destination.kind === "project" ? "kind|path" : "kind";
235
+ if (!destination || Object.keys(destination).sort(compare).join("|") !== expectedDestinationKeys)
236
+ throw new Error("Legacy catalog destination contains unsupported fields.");
237
+ for (const rawFile of item.files) {
238
+ if (!rawFile || typeof rawFile !== "object" || Array.isArray(rawFile) ||
239
+ Object.keys(rawFile).sort(compare).join("|") !== "mode|path|sha256") {
240
+ throw new Error("Legacy catalog file descriptor contains unsupported fields.");
241
+ }
242
+ }
243
+ }
244
+ for (const group of record.groups) {
245
+ if (!group || typeof group !== "object" || Array.isArray(group) ||
246
+ Object.keys(group).sort(compare).join("|") !== "id|itemIds|name")
247
+ throw new Error("Legacy catalog group contains unsupported fields.");
248
+ }
249
+ }
250
+ return parsed;
251
+ }
252
+ function isText(bytes) {
253
+ if (bytes.includes(0))
254
+ return false;
255
+ try {
256
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
257
+ return true;
258
+ }
259
+ catch {
260
+ return false;
261
+ }
262
+ }
263
+ function blockedView(reviewId, conflict, preservedPaths = []) {
264
+ return {
265
+ reviewId,
266
+ status: "blocked",
267
+ items: [],
268
+ conflicts: [conflict],
269
+ preservedPaths: [...new Set(preservedPaths)].sort(compare),
270
+ maskedDiffs: [],
271
+ sensitivePaths: [],
272
+ warnings: [],
273
+ };
274
+ }
275
+ function cloneView(view) {
276
+ return {
277
+ reviewId: view.reviewId,
278
+ status: view.status,
279
+ items: view.items.map((item) => ({ ...item })),
280
+ conflicts: [...view.conflicts],
281
+ preservedPaths: [...view.preservedPaths],
282
+ maskedDiffs: view.maskedDiffs.map((entry) => ({ ...entry })),
283
+ sensitivePaths: [...view.sensitivePaths],
284
+ warnings: view.warnings.map((warning) => ({ ...warning })),
285
+ };
286
+ }
287
+ function makePrepared(state) {
288
+ const plan = Object.freeze({ reviewId: state.view.reviewId });
289
+ preparedStates.set(plan, { ...state, used: false });
290
+ return plan;
291
+ }
292
+ function inventoryDigest(rootIdentities, rootDirectoryState, catalog, trees) {
293
+ const rows = [
294
+ ...rootIdentities.map((identity) => `R:${JSON.stringify(identity)}`),
295
+ `L:${JSON.stringify({ path: rootDirectoryState.path, dev: rootDirectoryState.dev, ino: rootDirectoryState.ino, mode: rootDirectoryState.mode })}`,
296
+ `C:${catalog.path}:${catalog.sha256}:${catalog.mode}:${catalog.dev}:${catalog.ino}`,
297
+ ];
298
+ for (const tree of trees) {
299
+ rows.push(`T:${tree.present ? "present" : "absent"}`);
300
+ for (const state of tree.directories.values())
301
+ rows.push(`D:${JSON.stringify(state)}`);
302
+ for (const [directory, names] of tree.entries)
303
+ rows.push(`E:${directory}:${JSON.stringify(names)}`);
304
+ for (const file of tree.files.values())
305
+ rows.push(`F:${file.path}:${file.sha256}:${file.mode}:${file.dev}:${file.ino}`);
306
+ }
307
+ return digest(Buffer.from(rows.sort(compare).join("\n")));
308
+ }
309
+ function treeRelativeEntries(tree, base) {
310
+ return [...tree.files.entries()]
311
+ .filter(([relative]) => relative.startsWith(`${base}/`))
312
+ .map(([relative, file]) => ({ path: relative.slice(base.length + 1), file }))
313
+ .sort((left, right) => compare(left.path, right.path));
314
+ }
315
+ function scannedTreeCommitment(rootName, scanned) {
316
+ if (!scanned.present)
317
+ return "absent";
318
+ const entries = [];
319
+ const visit = (relative) => {
320
+ const rootRelative = relative === "." ? rootName : `${rootName}/${relative}`;
321
+ const directory = scanned.directories.get(rootRelative);
322
+ if (!directory)
323
+ throw new Error(`Migration ${rootName} snapshot is missing a directory identity.`);
324
+ entries.push({ path: relative, kind: "directory", value: "", mode: Number(BigInt(directory.mode) & 511n),
325
+ dev: Number(BigInt(directory.dev)), ino: Number(BigInt(directory.ino)) });
326
+ for (const name of scanned.entries.get(rootRelative) ?? []) {
327
+ const child = relative === "." ? name : `${relative}/${name}`;
328
+ const childRootRelative = `${rootName}/${child}`;
329
+ const file = scanned.files.get(childRootRelative);
330
+ if (file)
331
+ entries.push({ path: child, kind: "file", value: file.sha256, mode: file.mode, dev: Number(file.dev), ino: Number(file.ino) });
332
+ else
333
+ visit(child);
334
+ }
335
+ };
336
+ visit(".");
337
+ return digest(Buffer.from(JSON.stringify(entries)));
338
+ }
339
+ function currentSourcePayload(root, item, sourceTree) {
340
+ if (!path.isAbsolute(item.sourcePath) || path.resolve(item.sourcePath) !== item.sourcePath)
341
+ return undefined;
342
+ const sourcesRoot = path.join(root, "sources");
343
+ if (!isWithin(sourcesRoot, item.sourcePath) || item.sourcePath === sourcesRoot)
344
+ return undefined;
345
+ const relativeRoot = path.relative(root, item.sourcePath).split(path.sep).join("/");
346
+ const rootEntry = sourceTree.directories.get(relativeRoot);
347
+ const rootFile = sourceTree.files.get(relativeRoot);
348
+ if (!rootEntry && !rootFile)
349
+ return undefined;
350
+ const target = relativeRoot;
351
+ if (item.kind === "skill") {
352
+ if (!rootEntry)
353
+ throw new Error(`Registered source path type conflicts with skill item ${item.id}.`);
354
+ const actual = treeRelativeEntries(sourceTree, relativeRoot);
355
+ const registered = new Set(item.files.map((file) => file.path));
356
+ const exposed = actual.filter(({ path: relative }) => !excluded(relative));
357
+ if (exposed.some(({ path: relative }) => !registered.has(relative)) || item.files.some((file) => !exposed.some(({ path: relative }) => relative === file.path))) {
358
+ throw new Error(`Registered source tree has missing or unregistered files for item ${item.id}.`);
359
+ }
360
+ if (!item.files.some((file) => file.path === "SKILL.md"))
361
+ throw new Error(`Skill item ${item.id} has no registered root SKILL.md.`);
362
+ return {
363
+ target,
364
+ files: item.files.map((descriptor) => {
365
+ const found = sourceTree.files.get(`${relativeRoot}/${descriptor.path}`);
366
+ if (!found)
367
+ throw new Error(`Registered source file is missing for item ${item.id}.`);
368
+ return { path: descriptor.path, mode: descriptor.mode, bytes: Buffer.from(found.bytes), sha256: found.sha256,
369
+ inputPath: found.path, dev: found.dev, ino: found.ino };
370
+ }),
371
+ };
372
+ }
373
+ if (!rootFile || item.files.length !== 1 || item.files[0]?.path !== path.posix.basename(target)) {
374
+ throw new Error(`Registered source path does not match the legacy file descriptor for item ${item.id}.`);
375
+ }
376
+ return {
377
+ target,
378
+ files: [{ path: item.files[0].path, mode: item.files[0].mode, bytes: Buffer.from(rootFile.bytes), sha256: rootFile.sha256,
379
+ inputPath: rootFile.path, dev: rootFile.dev, ino: rootFile.ino }],
380
+ };
381
+ }
382
+ function itemsPayload(item, itemsTree) {
383
+ if (!itemIdPattern.test(item.id) || item.files.length === 0)
384
+ throw new Error(`Legacy item ${item.id} has no valid registered snapshot files.`);
385
+ const paths = new Set();
386
+ const files = [];
387
+ for (const descriptor of item.files) {
388
+ if (!safeRelative(descriptor.path) || excluded(descriptor.path) || paths.has(descriptor.path) || !sha256Pattern.test(descriptor.sha256)) {
389
+ throw new Error(`Legacy item ${item.id} has an unsafe or duplicate snapshot path.`);
390
+ }
391
+ paths.add(descriptor.path);
392
+ const target = `items/${item.id}/files/${descriptor.path}`;
393
+ const stored = itemsTree.files.get(target);
394
+ if (!stored || stored.sha256 !== descriptor.sha256)
395
+ throw new Error(`Legacy snapshot payload is missing or its declared checksum does not match for item ${item.id}.`);
396
+ files.push({ path: descriptor.path, mode: descriptor.mode, bytes: Buffer.from(stored.bytes), sha256: stored.sha256,
397
+ inputPath: stored.path, dev: stored.dev, ino: stored.ino });
398
+ }
399
+ if (item.kind === "skill" && !paths.has("SKILL.md"))
400
+ throw new Error(`Legacy skill item ${item.id} has no root SKILL.md snapshot.`);
401
+ if (item.kind !== "skill" && (files.length !== 1 || files[0]?.path !== path.posix.basename(files[0]?.path ?? ""))) {
402
+ throw new Error(`Legacy file item ${item.id} must register exactly one flat file.`);
403
+ }
404
+ return files;
405
+ }
406
+ function existingTargetPayload(target, kind, expected, sourceTree) {
407
+ const existingFile = sourceTree.files.get(target);
408
+ const existingDirectory = sourceTree.directories.has(target);
409
+ if (kind === "file") {
410
+ if (existingFile) {
411
+ if (expected.length !== 1 || existingFile.sha256 !== expected[0]?.sha256) {
412
+ throw new Error(`Existing sources target differs from the verified item payload: ${target}`);
413
+ }
414
+ return [{ ...expected[0], bytes: Buffer.from(existingFile.bytes), sha256: existingFile.sha256,
415
+ inputPath: existingFile.path, dev: existingFile.dev, ino: existingFile.ino }];
416
+ }
417
+ if (existingDirectory)
418
+ throw new Error(`Existing directory conflicts with the planned file target: ${target}`);
419
+ const parent = path.posix.dirname(target);
420
+ if (parent !== "sources" && sourceTree.directories.has(parent)) {
421
+ throw new Error(`Existing source parent conflicts with the new file target: ${parent}`);
422
+ }
423
+ return undefined;
424
+ }
425
+ if (existingFile)
426
+ throw new Error(`Existing file conflicts with the planned tree target: ${target}`);
427
+ if (!existingDirectory)
428
+ return undefined;
429
+ const expectedByPath = new Map(expected.map((file) => [file.path, file]));
430
+ const actual = treeRelativeEntries(sourceTree, target).filter(({ path: relative }) => !excluded(relative));
431
+ if (actual.length !== expectedByPath.size || actual.some(({ path: relative, file }) => {
432
+ const wanted = expectedByPath.get(relative);
433
+ return !wanted || wanted.sha256 !== file.sha256;
434
+ })) {
435
+ throw new Error(`Existing sources tree differs from the verified item payload: ${target}`);
436
+ }
437
+ return expected.map((wanted) => {
438
+ const actualFile = sourceTree.files.get(`${target}/${wanted.path}`);
439
+ return { ...wanted, bytes: Buffer.from(actualFile.bytes), sha256: actualFile.sha256,
440
+ inputPath: actualFile.path, dev: actualFile.dev, ino: actualFile.ino };
441
+ });
442
+ }
443
+ function fileMaskedSummary(itemId, file, descriptor, registeredSnapshot) {
444
+ const currentText = isText(file.bytes) ? new TextDecoder("utf-8", { fatal: true }).decode(file.bytes) : undefined;
445
+ if (currentText === undefined) {
446
+ const matches = file.sha256 === descriptor.sha256;
447
+ return {
448
+ path: `${itemId}/${file.path}`,
449
+ summary: matches
450
+ ? "binary payload matches the declared legacy snapshot; content is not displayed."
451
+ : "binary payload differs from the declared legacy snapshot; content is not displayed.",
452
+ };
453
+ }
454
+ if (!registeredSnapshot || registeredSnapshot.sha256 !== descriptor.sha256) {
455
+ return {
456
+ path: `${itemId}/${file.path}`,
457
+ summary: file.sha256 === descriptor.sha256
458
+ ? "text payload matches the declared legacy snapshot; content is not displayed."
459
+ : "text payload differs from the declared legacy snapshot, but the registered snapshot is unavailable or unverified; comparison is not displayed.",
460
+ };
461
+ }
462
+ if (!isText(registeredSnapshot.bytes)) {
463
+ return {
464
+ path: `${itemId}/${file.path}`,
465
+ summary: "selected text payload differs from a binary registered snapshot; content is not displayed.",
466
+ };
467
+ }
468
+ if (registeredSnapshot.sha256 === file.sha256) {
469
+ return { path: `${itemId}/${file.path}`, summary: "text payload matches the registered legacy snapshot; no content changes." };
470
+ }
471
+ const beforeText = new TextDecoder("utf-8", { fatal: true }).decode(registeredSnapshot.bytes);
472
+ const patch = maskSensitiveText(createTwoFilesPatch("registered legacy snapshot", "selected current source", beforeText, currentText));
473
+ return {
474
+ path: `${itemId}/${file.path}`,
475
+ summary: `text payload differs from the registered legacy snapshot; masked diff follows:\n${patch}`,
476
+ };
477
+ }
478
+ function buildNextCatalog(legacy, selections) {
479
+ const byId = new Map(selections.map((selection) => [selection.item.id, selection]));
480
+ const items = legacy.items.map((item) => {
481
+ const selection = byId.get(item.id);
482
+ if (!selection)
483
+ throw new Error(`Legacy item ${item.id} has no safe migration target.`);
484
+ return { ...item, files: item.files.map((file) => ({ ...file })), destination: { ...item.destination }, content: { kind: selection.kind, path: selection.target } };
485
+ });
486
+ const next = { version: 2, items, groups: legacy.groups.map((group) => ({ ...group, itemIds: [...group.itemIds] })) };
487
+ const validated = parseStoredCatalog(next);
488
+ if (validated.version !== 2)
489
+ throw new Error("Migration target catalog did not validate as v2.");
490
+ return validated;
491
+ }
492
+ function makeAlreadyV2(reviewId, root, revision, rootPresent, rootIdentities, rootEntries, rootDirectoryState, catalogFile) {
493
+ const state = {
494
+ root, revision, rootPresent,
495
+ rootIdentities: rootIdentities.map((identity) => ({ ...identity })),
496
+ ...(rootDirectoryState ? { rootDirectoryState: { ...rootDirectoryState } } : {}),
497
+ rootEntries: [...rootEntries],
498
+ view: { reviewId, status: "already-v2", items: [], conflicts: [], preservedPaths: [], maskedDiffs: [], sensitivePaths: [], warnings: [] },
499
+ ...(catalogFile ? { inputs: [{ ...catalogFile, bytes: Buffer.from(catalogFile.bytes) }] } : { inputs: [] }),
500
+ backupEntries: [], selections: [], additions: [],
501
+ };
502
+ return makePrepared(state);
503
+ }
504
+ let previewCheckpoint;
505
+ /** @internal A deterministic seam for tests that change the filesystem while a preview is open. */
506
+ export function setMigrationPreviewCheckpointForTests(callback) {
507
+ previewCheckpoint = callback;
508
+ }
509
+ export async function prepareMigration(libraryDir) {
510
+ if (typeof libraryDir !== "string" || libraryDir.length === 0)
511
+ throw new Error("Migration libraryDir must be a nonempty path string.");
512
+ const root = path.resolve(libraryDir);
513
+ const reviewId = randomUUID();
514
+ try {
515
+ const rootCheck = await checkCanonicalAncestors(root);
516
+ if (!rootCheck.exists) {
517
+ // A root rename can expose a brief ENOENT at the start of a preview. Confirm
518
+ // absence after yielding to pending filesystem operations before treating it as a no-op.
519
+ await new Promise((resolve) => setTimeout(resolve, 1));
520
+ const confirmedRootCheck = await checkCanonicalAncestors(root);
521
+ if (confirmedRootCheck.exists || !sameDirectoryIdentities(rootCheck.identities, confirmedRootCheck.identities)) {
522
+ throw new Error("Library root or an ancestor changed during the read-only preview.");
523
+ }
524
+ return makeAlreadyV2(reviewId, root, digest(Buffer.from(`absent:${root}`)), false, rootCheck.identities, []);
525
+ }
526
+ const rootNamesBefore = await listManagedMigrationRootEntries(root);
527
+ const blockers = [];
528
+ for (const name of [".copi-transaction", ".copi-transaction-recovery", ".copi-migration-resolution.json"]) {
529
+ const stat = await lstat(path.join(root, name)).catch((error) => {
530
+ if (error.code === "ENOENT")
531
+ return undefined;
532
+ throw error;
533
+ });
534
+ if (stat)
535
+ blockers.push(`${name} exists; preview will not recover, delete, or inspect it.`);
536
+ }
537
+ for (const name of rootNamesBefore) {
538
+ if (name.startsWith(".copi-migration-resolution.") && name !== ".copi-migration-resolution.json") {
539
+ blockers.push(`${name} exists; preview will not recover, delete, or inspect it.`);
540
+ }
541
+ }
542
+ if (blockers.length > 0) {
543
+ return makePrepared({
544
+ root, revision: digest(Buffer.from(`blocked-locks:${root}:${rootNamesBefore.join("\n")}`)),
545
+ rootPresent: true, rootIdentities: rootCheck.identities,
546
+ ...(rootCheck.rootDirectoryState ? { rootDirectoryState: rootCheck.rootDirectoryState } : {}),
547
+ rootEntries: rootNamesBefore,
548
+ view: { reviewId, status: "blocked", items: [], conflicts: blockers,
549
+ preservedPaths: blockers.map((entry) => entry.split(" ")[0]), maskedDiffs: [], sensitivePaths: [], warnings: [] },
550
+ inputs: [], backupEntries: [], selections: [], additions: [],
551
+ });
552
+ }
553
+ const catalogStat = await lstat(path.join(root, "catalog.json"), { bigint: true }).catch((error) => {
554
+ if (error.code === "ENOENT")
555
+ return undefined;
556
+ throw error;
557
+ });
558
+ if (!catalogStat)
559
+ throw new Error("catalog.json is missing; no recognized legacy library can be previewed.");
560
+ const catalogFile = await safeReadFile(root, "catalog.json");
561
+ const stored = parseCatalogBytes(catalogFile.bytes);
562
+ if (stored.version === 2) {
563
+ await assertDirectoryChain(rootCheck.identities);
564
+ await assertDirectoryState(rootCheck.rootDirectoryState);
565
+ const rootNamesAfter = await listManagedMigrationRootEntries(root);
566
+ if (JSON.stringify(rootNamesBefore) !== JSON.stringify(rootNamesAfter))
567
+ throw new Error("Library root entries changed during the read-only preview.");
568
+ return makeAlreadyV2(reviewId, root, digest(Buffer.from(`${catalogFile.sha256}:${catalogFile.dev}:${catalogFile.ino}`)), true, rootCheck.identities, rootNamesBefore, rootCheck.rootDirectoryState, catalogFile);
569
+ }
570
+ const legacy = stored;
571
+ const startInventory = await scanMigrationInventory(root);
572
+ const catalogInventoryNode = startInventory.nodes.find((node) => node.path === "catalog.json");
573
+ if (!catalogInventoryNode || catalogInventoryNode.kind !== "file" || catalogInventoryNode.bytesHash !== catalogFile.sha256 ||
574
+ catalogInventoryNode.dev !== catalogFile.dev || catalogInventoryNode.ino !== catalogFile.ino || catalogInventoryNode.mode !== catalogFile.mode) {
575
+ throw new Error("Migration start inventory no longer matches the reviewed catalog identity and bytes.");
576
+ }
577
+ const existingBackups = await lstat(path.join(root, ".migration-backups"), { bigint: true }).catch((error) => {
578
+ if (error.code === "ENOENT")
579
+ return undefined;
580
+ throw error;
581
+ });
582
+ if (existingBackups)
583
+ throw new Error("An existing migration backup namespace is preserved and is not adopted automatically.");
584
+ const itemsTree = await scanTree(root, "items");
585
+ const sourcesTree = await scanTree(root, "sources");
586
+ await previewCheckpoint?.("after-tree-scan", root);
587
+ const rootNamesAfter = await listManagedMigrationRootEntries(root);
588
+ if (JSON.stringify(rootNamesBefore) !== JSON.stringify(rootNamesAfter))
589
+ throw new Error("Library root entries changed during the read-only preview.");
590
+ await assertDirectoryChain(rootCheck.identities);
591
+ await assertDirectoryState(rootCheck.rootDirectoryState);
592
+ const preservedPaths = new Set();
593
+ const knownRootEntries = new Set(["catalog.json", "items", "sources"]);
594
+ for (const name of rootNamesBefore)
595
+ if (!knownRootEntries.has(name))
596
+ preservedPaths.add(name);
597
+ const expectedItemPaths = new Set();
598
+ for (const item of legacy.items) {
599
+ for (const file of item.files)
600
+ expectedItemPaths.add(`items/${item.id}/files/${file.path}`);
601
+ }
602
+ const expectedItemDirectories = new Set(["items"]);
603
+ for (const filePath of expectedItemPaths) {
604
+ let parent = path.posix.dirname(filePath);
605
+ while (parent !== ".") {
606
+ expectedItemDirectories.add(parent);
607
+ parent = path.posix.dirname(parent);
608
+ }
609
+ }
610
+ for (const filePath of itemsTree.files.keys())
611
+ if (!expectedItemPaths.has(filePath))
612
+ preservedPaths.add(filePath);
613
+ for (const directory of itemsTree.directories.keys())
614
+ if (!expectedItemDirectories.has(directory))
615
+ preservedPaths.add(directory);
616
+ const selected = [];
617
+ const selectedView = [];
618
+ const maskedDiffs = [];
619
+ const sensitivePaths = new Set();
620
+ const warnings = [{ code: "external-origins-not-read", message: "External origin paths are never fetched; only catalog, items, and library sources are read." }];
621
+ const additions = [];
622
+ const selections = [];
623
+ for (const item of legacy.items) {
624
+ if (!itemIdPattern.test(item.id))
625
+ throw new Error("Legacy catalog contains an invalid item UUID.");
626
+ let adoptedSource;
627
+ try {
628
+ adoptedSource = currentSourcePayload(root, item, sourcesTree);
629
+ }
630
+ catch (error) {
631
+ throw error;
632
+ }
633
+ let payload;
634
+ let target;
635
+ let kind;
636
+ let selection;
637
+ if (adoptedSource) {
638
+ payload = adoptedSource.files;
639
+ target = adoptedSource.target;
640
+ kind = item.kind === "skill" ? "tree" : "file";
641
+ selection = "sources";
642
+ for (const file of payload) {
643
+ const descriptor = item.files.find((entry) => entry.path === file.path);
644
+ maskedDiffs.push(fileMaskedSummary(item.id, file, descriptor, itemsTree.files.get(`items/${item.id}/files/${file.path}`)));
645
+ }
646
+ }
647
+ else {
648
+ payload = itemsPayload(item, itemsTree);
649
+ if (item.sourcePath.startsWith(path.join(root, "sources") + path.sep)) {
650
+ warnings.push({ code: "origin-missing", message: "The recorded in-library source path is absent; the verified legacy item snapshot is selected.", path: `items/${item.id}` });
651
+ }
652
+ else {
653
+ warnings.push({ code: "origin-not-read", message: "The recorded external origin was not read; the verified legacy item snapshot is selected.", path: `items/${item.id}` });
654
+ }
655
+ kind = item.kind === "skill" ? "tree" : "file";
656
+ target = kind === "tree" ? `sources/${item.id}` : `sources/${item.id}/${payload[0].path}`;
657
+ const existing = existingTargetPayload(target, kind, payload, sourcesTree);
658
+ if (existing) {
659
+ payload = existing;
660
+ selection = "sources";
661
+ }
662
+ else {
663
+ selection = "items";
664
+ additions.push({ itemId: item.id, target, kind, files: payload.map((file) => ({ ...file, bytes: Buffer.from(file.bytes) })) });
665
+ }
666
+ for (const file of payload) {
667
+ const descriptor = item.files.find((entry) => entry.path === file.path);
668
+ maskedDiffs.push(fileMaskedSummary(item.id, file, descriptor, itemsTree.files.get(`items/${item.id}/files/${file.path}`)));
669
+ }
670
+ }
671
+ selected.push({ item, target, kind });
672
+ selections.push({ itemId: item.id, target, kind, selection, files: payload.map((file) => ({ ...file, bytes: Buffer.from(file.bytes) })) });
673
+ selectedView.push({ id: item.id, name: item.name, target, selection });
674
+ const inspection = inspectFixedResource({
675
+ candidate: { ...item, sourceRoot: path.dirname(item.sourcePath) },
676
+ files: payload.map((file) => ({ path: file.path, mode: file.mode, bytes: Buffer.from(file.bytes) })),
677
+ excludedPaths: [],
678
+ });
679
+ for (const filePath of inspection.sensitivePaths)
680
+ sensitivePaths.add(`${item.id}/${filePath}`);
681
+ for (const warning of inspection.warnings)
682
+ warnings.push({
683
+ code: warning.code,
684
+ message: warning.message,
685
+ ...(warning.path === undefined ? {} : { path: `${item.id}/${warning.path}` }),
686
+ });
687
+ }
688
+ const expectedSourceDirectories = new Set(["sources"]);
689
+ for (const entry of selected) {
690
+ const targetDirectory = entry.kind === "tree" ? entry.target : path.posix.dirname(entry.target);
691
+ let parent = targetDirectory;
692
+ while (parent !== ".") {
693
+ expectedSourceDirectories.add(parent);
694
+ parent = path.posix.dirname(parent);
695
+ }
696
+ if (entry.kind === "tree") {
697
+ for (const directory of sourcesTree.directories.keys()) {
698
+ if (isWithin(path.join(root, entry.target), path.join(root, directory)))
699
+ expectedSourceDirectories.add(directory);
700
+ }
701
+ }
702
+ }
703
+ for (const directory of sourcesTree.directories.keys()) {
704
+ if (!expectedSourceDirectories.has(directory))
705
+ preservedPaths.add(directory);
706
+ if (excluded(directory.slice("sources/".length)))
707
+ preservedPaths.add(directory);
708
+ }
709
+ for (const relative of sourcesTree.files.keys()) {
710
+ if (!selected.some((entry) => isWithin(path.join(root, entry.target), path.join(root, relative))) &&
711
+ !selected.some((entry) => isWithin(path.join(root, relative), path.join(root, entry.target))))
712
+ preservedPaths.add(relative);
713
+ if (excluded(relative.slice("sources/".length)))
714
+ preservedPaths.add(relative);
715
+ }
716
+ const nextCatalog = buildNextCatalog(legacy, selected);
717
+ const catalogPaths = new Set();
718
+ for (const item of nextCatalog.items) {
719
+ if (catalogPaths.has(item.content.path))
720
+ throw new Error("Two legacy items resolve to the same sources target.");
721
+ catalogPaths.add(item.content.path);
722
+ }
723
+ const inputs = [catalogFile, ...itemsTree.files.values(), ...sourcesTree.files.values()].sort((left, right) => compare(left.path, right.path));
724
+ const backupEntries = inputs.map(({ path: originalPath, sha256, mode }) => ({ originalPath, sha256, mode }));
725
+ const backupManifestBytes = buildMigrationBackupManifest(backupEntries);
726
+ const rootDirectoryState = rootCheck.rootDirectoryState;
727
+ if (!rootDirectoryState)
728
+ throw new Error("Library root directory state is unavailable.");
729
+ const revision = digest(Buffer.from(`${inventoryDigest(rootCheck.identities, rootDirectoryState, catalogFile, [itemsTree, sourcesTree])}:${startInventory.hash}`));
730
+ const conflicts = [];
731
+ const view = {
732
+ reviewId,
733
+ status: conflicts.length === 0 ? "ready" : "blocked",
734
+ items: selectedView,
735
+ conflicts,
736
+ preservedPaths: [...preservedPaths].sort(compare),
737
+ maskedDiffs,
738
+ sensitivePaths: [...sensitivePaths].sort(compare),
739
+ warnings,
740
+ };
741
+ return makePrepared({
742
+ root,
743
+ revision,
744
+ startInventory: structuredClone(startInventory),
745
+ rootPresent: true,
746
+ rootIdentities: rootCheck.identities.map((identity) => ({ ...identity })),
747
+ rootDirectoryState: { ...rootDirectoryState },
748
+ rootEntries: [...rootNamesBefore],
749
+ itemsTree,
750
+ sourcesTree,
751
+ view,
752
+ oldCatalogBytes: Buffer.from(catalogFile.bytes),
753
+ oldCatalog: legacy,
754
+ nextCatalog,
755
+ inputs: inputs.map((file) => ({ ...file, bytes: Buffer.from(file.bytes) })),
756
+ backupManifestBytes: Buffer.from(backupManifestBytes),
757
+ backupEntries: backupEntries.map((entry) => ({ ...entry })),
758
+ selections: selections.map((selection) => ({ ...selection, files: selection.files.map((file) => ({ ...file, bytes: Buffer.from(file.bytes) })) })),
759
+ additions: additions.map((addition) => ({ ...addition, files: addition.files.map((file) => ({ ...file, bytes: Buffer.from(file.bytes) })) })),
760
+ });
761
+ }
762
+ catch (error) {
763
+ const message = error instanceof Error ? error.message : "The legacy library could not be safely previewed.";
764
+ return makePrepared({
765
+ root,
766
+ revision: digest(Buffer.from(`blocked:${root}:${message}`)),
767
+ view: blockedView(reviewId, message),
768
+ inputs: [], backupEntries: [], selections: [], additions: [],
769
+ });
770
+ }
771
+ }
772
+ export function readMigration(plan) {
773
+ if (typeof plan !== "object" || plan === null || !preparedStates.has(plan)) {
774
+ throw new Error("Invalid prepared migration token.");
775
+ }
776
+ const state = preparedStates.get(plan);
777
+ if (!state)
778
+ throw new Error("Prepared migration is unavailable.");
779
+ return cloneView(state.view);
780
+ }
781
+ const approvalKeys = ["reviewId", "confirmed", "approvedSensitivePaths"];
782
+ function snapshotOwnDataRecord(value, expectedKeys) {
783
+ if (typeof value !== "object" || value === null || utilTypes.isProxy(value))
784
+ return undefined;
785
+ try {
786
+ if (Array.isArray(value))
787
+ return undefined;
788
+ const prototype = Object.getPrototypeOf(value);
789
+ if (prototype !== Object.prototype && prototype !== null)
790
+ return undefined;
791
+ const ownKeys = Reflect.ownKeys(value);
792
+ if (ownKeys.length !== expectedKeys.length || ownKeys.some((key) => typeof key !== "string" || !expectedKeys.includes(key))) {
793
+ return undefined;
794
+ }
795
+ const snapshot = Object.create(null);
796
+ for (const key of expectedKeys) {
797
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
798
+ if (!descriptor || !Object.hasOwn(descriptor, "value") || descriptor.enumerable !== true)
799
+ return undefined;
800
+ snapshot[key] = descriptor.value;
801
+ }
802
+ return snapshot;
803
+ }
804
+ catch {
805
+ return undefined;
806
+ }
807
+ }
808
+ function snapshotDenseOwnDataArray(value) {
809
+ try {
810
+ if (utilTypes.isProxy(value) || !Array.isArray(value))
811
+ return undefined;
812
+ const ownKeys = Reflect.ownKeys(value);
813
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
814
+ if (!lengthDescriptor || !Object.hasOwn(lengthDescriptor, "value") || lengthDescriptor.enumerable !== false ||
815
+ typeof lengthDescriptor.value !== "number" || !Number.isSafeInteger(lengthDescriptor.value) || lengthDescriptor.value < 0 ||
816
+ ownKeys.length !== lengthDescriptor.value + 1)
817
+ return undefined;
818
+ const length = lengthDescriptor.value;
819
+ const keys = new Set();
820
+ for (const key of ownKeys) {
821
+ if (typeof key !== "string" ||
822
+ (key !== "length" && (!Number.isSafeInteger(Number(key)) || Number(key) < 0 || Number(key) >= length || String(Number(key)) !== key))) {
823
+ return undefined;
824
+ }
825
+ keys.add(key);
826
+ }
827
+ if (!keys.has("length"))
828
+ return undefined;
829
+ const snapshot = [];
830
+ for (let index = 0; index < length; index += 1) {
831
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
832
+ if (!keys.has(String(index)) || !descriptor || !Object.hasOwn(descriptor, "value") || descriptor.enumerable !== true)
833
+ return undefined;
834
+ snapshot.push(descriptor.value);
835
+ }
836
+ return snapshot;
837
+ }
838
+ catch {
839
+ return undefined;
840
+ }
841
+ }
842
+ function assertMigrationApproval(state, approval) {
843
+ const snapshot = snapshotOwnDataRecord(approval, approvalKeys);
844
+ if (!snapshot || snapshot.reviewId !== state.view.reviewId || snapshot.confirmed !== true) {
845
+ throw new Error("Migration approval must be a fixed own-data snapshot matching this review and explicit confirmation.");
846
+ }
847
+ const paths = snapshotDenseOwnDataArray(snapshot.approvedSensitivePaths);
848
+ if (!paths || paths.some((entry) => typeof entry !== "string")) {
849
+ throw new Error("Migration sensitive approvals must be a dense own-data array of exact paths.");
850
+ }
851
+ const approved = paths;
852
+ const approvedSet = new Set(approved);
853
+ const expected = state.view.sensitivePaths;
854
+ if (approvedSet.size !== approved.length || approvedSet.size !== expected.length || expected.some((entry) => !approvedSet.has(entry))) {
855
+ throw new Error("Individual approval is required for the exact set of sensitive paths in this migration review.");
856
+ }
857
+ }
858
+ function sameDirectoryIdentities(left, right) {
859
+ if (!left || !right || left.length !== right.length)
860
+ return left === right;
861
+ return left.every((entry, index) => sameDirectoryIdentity(entry, right[index]));
862
+ }
863
+ async function recheckPreparedMigration(state) {
864
+ const current = await prepareMigration(state.root);
865
+ const currentState = preparedStates.get(current);
866
+ if (!currentState || currentState.view.status !== state.view.status || currentState.revision !== state.revision ||
867
+ currentState.rootPresent !== state.rootPresent || !sameDirectoryIdentities(currentState.rootIdentities, state.rootIdentities) ||
868
+ JSON.stringify(currentState.rootEntries) !== JSON.stringify(state.rootEntries) ||
869
+ (currentState.rootDirectoryState === undefined) !== (state.rootDirectoryState === undefined) ||
870
+ (currentState.rootDirectoryState !== undefined && state.rootDirectoryState !== undefined &&
871
+ !sameRootDirectoryState(currentState.rootDirectoryState, state.rootDirectoryState)) ||
872
+ JSON.stringify(currentState.view.sensitivePaths) !== JSON.stringify(state.view.sensitivePaths)) {
873
+ throw new Error("Migration inputs, canonical root, catalog, lock state, or review snapshot changed after the read-only preview.");
874
+ }
875
+ }
876
+ /** @internal Validates the storage adapter's request without exposing fixed migration buffers. */
877
+ export async function assertPreparedMigrationRequest(plan, value) {
878
+ const state = typeof plan === "object" && plan !== null ? preparedStates.get(plan) : undefined;
879
+ if (!state || state.used || state.view.status !== "ready" || plan.reviewId !== state.view.reviewId ||
880
+ state.additions.length !== 0 || state.selections.length !== state.view.items.length ||
881
+ state.selections.some((selection) => selection.selection !== "sources")) {
882
+ throw new Error("Legacy migration storage reviews require an unused opaque ready plan selecting existing sources.");
883
+ }
884
+ const fields = snapshotOwnDataRecord(value, [
885
+ "root", "startInventory", "oldCatalogBytes", "oldCatalogIdentity", "catalogBytes", "itemsTree", "sourcesTree", "rootEntries",
886
+ "inputs", "backupDir", "backupManifestBytes", "reviewId", "sensitivePaths",
887
+ ]);
888
+ if (!fields || typeof fields.root !== "string" || typeof fields.oldCatalogIdentity !== "string" ||
889
+ typeof fields.itemsTree !== "string" || typeof fields.sourcesTree !== "string" || typeof fields.backupDir !== "string" ||
890
+ typeof fields.reviewId !== "string" || fields.reviewId !== state.view.reviewId ||
891
+ !Buffer.isBuffer(fields.oldCatalogBytes) || !Buffer.isBuffer(fields.catalogBytes) || !Buffer.isBuffer(fields.backupManifestBytes) ||
892
+ !state.startInventory || JSON.stringify(fields.startInventory) !== JSON.stringify(state.startInventory)) {
893
+ throw new Error("Legacy migration review request does not match the fixed prepared plan.");
894
+ }
895
+ const rootEntries = snapshotDenseOwnDataArray(fields.rootEntries);
896
+ const rawInputs = snapshotDenseOwnDataArray(fields.inputs);
897
+ const sensitivePaths = snapshotDenseOwnDataArray(fields.sensitivePaths);
898
+ const backupDirPattern = /^\.migration-backups\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
899
+ if (!rootEntries || rootEntries.some((entry) => typeof entry !== "string") || !rawInputs || !sensitivePaths ||
900
+ sensitivePaths.some((entry) => typeof entry !== "string") || !backupDirPattern.test(fields.backupDir) ||
901
+ fields.root !== state.root || !state.oldCatalogBytes || !state.nextCatalog || !state.backupManifestBytes ||
902
+ !state.itemsTree || !state.sourcesTree || !state.rootEntries ||
903
+ !state.oldCatalogBytes.equals(fields.oldCatalogBytes) ||
904
+ !Buffer.from(`${JSON.stringify(state.nextCatalog, null, 2)}\n`, "utf8").equals(fields.catalogBytes) ||
905
+ !state.backupManifestBytes.equals(fields.backupManifestBytes) ||
906
+ fields.itemsTree !== scannedTreeCommitment("items", state.itemsTree) ||
907
+ fields.sourcesTree !== scannedTreeCommitment("sources", state.sourcesTree) ||
908
+ JSON.stringify(rootEntries) !== JSON.stringify(state.rootEntries) ||
909
+ JSON.stringify(sensitivePaths) !== JSON.stringify(state.view.sensitivePaths) || rawInputs.length !== state.inputs.length) {
910
+ throw new Error("Legacy migration raw request differs from the fixed root, before snapshot, catalog, or selected sources plan.");
911
+ }
912
+ const catalogInput = state.inputs.find((input) => input.path === "catalog.json");
913
+ if (!catalogInput || fields.oldCatalogIdentity !== `${catalogInput.dev}:${catalogInput.ino}`) {
914
+ throw new Error("Legacy migration original catalog identity differs from the prepared plan.");
915
+ }
916
+ for (const [index, rawInput] of rawInputs.entries()) {
917
+ const input = snapshotOwnDataRecord(rawInput, ["path", "bytes", "mode", "sha256", "dev", "ino"]);
918
+ const expected = state.inputs[index];
919
+ if (!input || !expected || input.path !== expected.path || !Buffer.isBuffer(input.bytes) ||
920
+ !expected.bytes.equals(input.bytes) || input.mode !== expected.mode || input.sha256 !== expected.sha256 ||
921
+ input.dev !== expected.dev || input.ino !== expected.ino) {
922
+ throw new Error("Legacy migration request inputs differ from the opaque prepared before-data snapshot.");
923
+ }
924
+ }
925
+ const nextItems = state.nextCatalog.items;
926
+ if (nextItems.length !== state.view.items.length || nextItems.some((item, index) => {
927
+ const selected = state.view.items[index];
928
+ return !selected || selected.id !== item.id || selected.target !== item.content.path || selected.selection !== "sources";
929
+ })) {
930
+ throw new Error("Legacy migration next catalog does not preserve the opaque plan's selected source targets.");
931
+ }
932
+ await recheckPreparedMigration(state);
933
+ }
934
+ export async function commitMigration(plan, approval) {
935
+ if (typeof plan !== "object" || plan === null || !preparedStates.has(plan)) {
936
+ throw new Error("Invalid prepared migration token.");
937
+ }
938
+ const state = preparedStates.get(plan);
939
+ if (!state)
940
+ throw new Error("Prepared migration is unavailable.");
941
+ if (state.used)
942
+ throw new Error("Prepared migration was already consumed; prepare and review again.");
943
+ if (state.view.status === "blocked")
944
+ throw new Error("Prepared migration is blocked; resolve conflicts and review again.");
945
+ assertMigrationApproval(state, approval);
946
+ await recheckPreparedMigration(state);
947
+ if (state.view.status === "already-v2") {
948
+ state.used = true;
949
+ return {
950
+ catalogVersion: 2,
951
+ backupDir: null,
952
+ preservedPaths: [...state.view.preservedPaths],
953
+ };
954
+ }
955
+ if (state.additions.length > 0 || state.selections.length !== state.view.items.length ||
956
+ state.selections.some((selection) => selection.selection !== "sources")) {
957
+ throw new Error("Legacy v1 migration remains fail-closed unless every payload is adopted from existing sources; items-only and new-source additions are not implemented.");
958
+ }
959
+ await recheckPreparedMigration(state);
960
+ const oldCatalog = state.oldCatalogBytes;
961
+ const nextCatalog = state.nextCatalog;
962
+ const catalogInput = state.inputs.find((input) => input.path === "catalog.json");
963
+ const itemsTree = state.itemsTree;
964
+ const sourcesTree = state.sourcesTree;
965
+ if (!oldCatalog || !nextCatalog || !catalogInput || !itemsTree || !sourcesTree || !state.rootEntries || !state.startInventory) {
966
+ throw new Error("The fixed adopt-only migration snapshot is incomplete; no bytes were written.");
967
+ }
968
+ const backupDir = `.migration-backups/${randomUUID()}`;
969
+ const request = {
970
+ root: state.root,
971
+ startInventory: structuredClone(state.startInventory),
972
+ oldCatalogBytes: Buffer.from(oldCatalog),
973
+ oldCatalogIdentity: `${catalogInput.dev}:${catalogInput.ino}`,
974
+ catalogBytes: Buffer.from(`${JSON.stringify(nextCatalog, null, 2)}\n`, "utf8"),
975
+ itemsTree: scannedTreeCommitment("items", itemsTree),
976
+ sourcesTree: scannedTreeCommitment("sources", sourcesTree),
977
+ rootEntries: [...state.rootEntries],
978
+ inputs: state.inputs.map((input) => ({
979
+ path: input.path,
980
+ bytes: Buffer.from(input.bytes),
981
+ mode: input.mode,
982
+ sha256: input.sha256,
983
+ dev: input.dev,
984
+ ino: input.ino,
985
+ })),
986
+ backupDir,
987
+ backupManifestBytes: Buffer.from(state.backupManifestBytes ?? []),
988
+ reviewId: state.view.reviewId,
989
+ sensitivePaths: [...state.view.sensitivePaths],
990
+ };
991
+ const storageReview = await prepareLegacyMigrationReview(plan, request);
992
+ state.used = true;
993
+ await storageTransactions.run(state.root, false, async (session) => {
994
+ if (!session || session.root !== state.root)
995
+ throw new Error("Issued storage lease does not match the reviewed canonical migration root.");
996
+ await session.commitLegacyMigration(storageReview, approval);
997
+ }, { requiresNativePlacement: false });
998
+ return {
999
+ catalogVersion: 2,
1000
+ backupDir,
1001
+ preservedPaths: [...state.view.preservedPaths],
1002
+ };
1003
+ }
1004
+ export { prepareMigrationResolution, readMigrationResolution, commitMigrationResolution } from "./migration-resolution.js";