@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
@@ -1,6 +1,11 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm } from "node:fs/promises";
2
+ import { lstat, readFile, readdir, realpath } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { assertNameAvailable, assertNoNameConflicts, findNameConflicts, nameKey } from "./names.js";
5
+ import { assertPreparedInspection, inspectFixedResource, inspectPreparedResource } from "./inspect.js";
6
+ import { absentStorageRevision, prepareStorageReview, storageTransactions } from "./storage-transaction.js";
7
+ import { readSourceContent, validateContentLocations, validateRuntimeContentLocations } from "./source-content.js";
8
+ const catalogRevisions = new WeakMap();
4
9
  const preparedSnapshots = new WeakMap();
5
10
  const kinds = new Set(["instruction", "skill", "subagent", "pi-settings", "mcp-config", "model-config", "keybindings"]);
6
11
  const scopes = new Set(["user", "project", "shared", "package"]);
@@ -8,7 +13,7 @@ const excludedNames = new Set([".git", ".cache", ".npm", "auth", "node_modules",
8
13
  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;
9
14
  const groupIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
15
  export function newCatalog() {
11
- return { version: 1, items: [], groups: [] };
16
+ return { version: 2, items: [], groups: [] };
12
17
  }
13
18
  function record(value) {
14
19
  if (typeof value !== "object" || value === null || Array.isArray(value))
@@ -20,7 +25,7 @@ function isWithin(root, target) {
20
25
  return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
21
26
  }
22
27
  function safeRelativePath(value) {
23
- if (typeof value !== "string" || value.length === 0 || value.includes("\\") || path.posix.isAbsolute(value))
28
+ if (typeof value !== "string" || value.length === 0 || value.includes("\\") || value.includes("\0") || path.posix.isAbsolute(value))
24
29
  return false;
25
30
  const parts = value.split("/");
26
31
  return parts.every((part) => part.length > 0 && part !== "." && part !== "..") && path.posix.normalize(value) === value;
@@ -41,7 +46,7 @@ function parseSnapshotFile(value) {
41
46
  return undefined;
42
47
  return { path: file.path, mode: file.mode, sha256: file.sha256 };
43
48
  }
44
- function parseSavedItem(value) {
49
+ function parseLegacySavedItem(value) {
45
50
  const item = record(value);
46
51
  if (!item || typeof item.id !== "string" || !itemIdPattern.test(item.id) || typeof item.kind !== "string" || !kinds.has(item.kind) || typeof item.source !== "string" || typeof item.scope !== "string" || !scopes.has(item.scope) || typeof item.sourcePath !== "string" || !path.isAbsolute(item.sourcePath) || typeof item.name !== "string" || item.name.length === 0 || typeof item.savedAt !== "string" || !Number.isFinite(Date.parse(item.savedAt)) || !Array.isArray(item.files))
47
52
  return undefined;
@@ -61,6 +66,14 @@ function parseSavedItem(value) {
61
66
  files: files,
62
67
  };
63
68
  }
69
+ function parseSavedItem(value) {
70
+ const item = record(value);
71
+ const legacy = parseLegacySavedItem(value);
72
+ const content = record(item?.content);
73
+ if (!legacy || !content || (content.kind !== "file" && content.kind !== "tree") || typeof content.path !== "string")
74
+ return undefined;
75
+ return { ...legacy, content: { kind: content.kind, path: content.path } };
76
+ }
64
77
  function parseGroup(value) {
65
78
  const group = record(value);
66
79
  if (!group || typeof group.id !== "string" || !groupIdPattern.test(group.id) || typeof group.name !== "string" || group.name.trim().length === 0 || !Array.isArray(group.itemIds) || group.itemIds.some((id) => typeof id !== "string" || !itemIdPattern.test(id)))
@@ -69,11 +82,12 @@ function parseGroup(value) {
69
82
  return undefined;
70
83
  return { id: group.id, name: group.name, itemIds: [...group.itemIds] };
71
84
  }
72
- function parseCatalog(value) {
85
+ export function parseStoredCatalog(value) {
73
86
  const catalog = record(value);
74
- if (!catalog || catalog.version !== 1 || !Array.isArray(catalog.items) || !Array.isArray(catalog.groups))
75
- throw new Error("Invalid copi catalog: expected version 1 with item and group arrays.");
76
- const items = catalog.items.map(parseSavedItem);
87
+ if (!catalog || (catalog.version !== 1 && catalog.version !== 2) || !Array.isArray(catalog.items) || !Array.isArray(catalog.groups)) {
88
+ throw new Error("Invalid copi catalog: expected version 1 or 2 with item and group arrays.");
89
+ }
90
+ const items = catalog.items.map(catalog.version === 1 ? parseLegacySavedItem : parseSavedItem);
77
91
  const groups = catalog.groups.map(parseGroup);
78
92
  if (items.some((item) => item === undefined) || groups.some((group) => group === undefined))
79
93
  throw new Error("Invalid copi catalog: an item or group has malformed fields.");
@@ -84,39 +98,17 @@ function parseCatalog(value) {
84
98
  const knownItems = new Set(itemIds);
85
99
  if (groups.some((group) => group?.itemIds.some((id) => !knownItems.has(id))))
86
100
  throw new Error("Invalid copi catalog: a group references an unknown item.");
87
- return { version: 1, items: items, groups: groups };
88
- }
89
- async function ensurePrivateDirectory(requestedPath, mode) {
90
- const absolute = path.resolve(requestedPath);
91
- const parsed = path.parse(absolute);
92
- const segments = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
93
- if (segments.length === 0)
94
- throw new Error("Refusing to use the filesystem root as a copi directory.");
95
- let current = parsed.root;
96
- for (let index = 0; index < segments.length; index += 1) {
97
- const segment = segments[index];
98
- if (!segment)
99
- continue;
100
- let next = path.join(current, segment);
101
- let stat = await lstat(next).catch(() => undefined);
102
- if (!stat) {
103
- await mkdir(next, { mode: 0o700 });
104
- stat = await lstat(next);
105
- }
106
- if (stat.isSymbolicLink()) {
107
- if (index === segments.length - 1)
108
- throw new Error("Refusing to follow a symbolic link for a copi storage directory.");
109
- next = await realpath(next);
110
- stat = await lstat(next);
111
- }
112
- if (!stat.isDirectory())
113
- throw new Error("A copi storage path component is not a directory.");
114
- current = next;
115
- }
116
- await chmod(current, mode);
117
- return await realpath(current);
101
+ if (catalog.version === 1)
102
+ return { version: 1, items: items, groups: groups };
103
+ const modern = { version: 2, items: items, groups: groups };
104
+ validateContentLocations(modern.items);
105
+ return modern;
106
+ }
107
+ export function assertModernCatalog(catalog) {
108
+ if (catalog.version !== 2)
109
+ throw new Error("This operation requires catalog v2; explicitly migrate the v1 library first.");
118
110
  }
119
- async function ensurePrivateSubdirectory(root, relativePath, mode, create) {
111
+ async function existingPrivateSubdirectory(root, relativePath) {
120
112
  if (!safeRelativePath(relativePath))
121
113
  throw new Error("Unsafe relative path inside copi storage.");
122
114
  const canonicalRoot = await realpath(root);
@@ -126,19 +118,13 @@ async function ensurePrivateSubdirectory(root, relativePath, mode, create) {
126
118
  let current = canonicalRoot;
127
119
  for (const segment of relativePath.split("/")) {
128
120
  const next = path.join(current, segment);
129
- let stat = await lstat(next).catch((error) => {
121
+ const stat = await lstat(next).catch((error) => {
130
122
  if (error.code === "ENOENT")
131
123
  return undefined;
132
124
  throw error;
133
125
  });
134
- if (!stat && create) {
135
- await mkdir(next, { mode: 0o700 });
136
- stat = await lstat(next);
137
- }
138
126
  if (!stat || stat.isSymbolicLink() || !stat.isDirectory())
139
127
  throw new Error("Copi storage contains a missing, non-directory, or symbolic-link path component.");
140
- if (mode !== undefined)
141
- await chmod(next, mode);
142
128
  current = next;
143
129
  }
144
130
  return current;
@@ -264,32 +250,10 @@ function validateCandidate(candidate) {
264
250
  if (!candidate || typeof candidate !== "object" || !kinds.has(candidate.kind) || !scopes.has(candidate.scope) || typeof candidate.source !== "string" || !candidate.source || typeof candidate.name !== "string" || !candidate.name || !path.isAbsolute(candidate.sourceRoot) || !path.isAbsolute(candidate.sourcePath))
265
251
  throw new Error("Invalid resource candidate.");
266
252
  const destination = candidate.destination;
267
- if (!destination || (destination.kind !== "global-only" && !(destination.kind === "project" && safeRelativePath(destination.path))))
268
- throw new Error("Invalid resource destination.");
269
- }
270
- async function writeSnapshotFiles(root, itemId, files) {
271
- const itemRoot = await ensurePrivateSubdirectory(root, path.posix.join("items", itemId, "files"), 0o700, true);
272
- const snapshots = [];
273
- for (const file of files) {
274
- const outputPath = path.resolve(itemRoot, ...file.path.split("/"));
275
- if (!isWithin(itemRoot, outputPath))
276
- throw new Error("Snapshot file path escapes the item directory.");
277
- const parentRelative = path.posix.dirname(file.path);
278
- const parent = parentRelative === "." ? itemRoot : await ensurePrivateSubdirectory(itemRoot, parentRelative, 0o700, true);
279
- const target = path.join(parent, path.basename(outputPath));
280
- const handle = await open(target, "wx", 0o600);
281
- try {
282
- await handle.writeFile(file.bytes);
283
- await handle.sync();
284
- }
285
- finally {
286
- await handle.close();
287
- }
288
- snapshots.push({ path: file.path, mode: file.mode, sha256: createHash("sha256").update(file.bytes).digest("hex") });
289
- }
290
- return snapshots;
253
+ if (!destination || (destination.kind !== "global-only" && !(destination.kind === "project" && safeRelativePath(destination.path) && !excludedRelative(destination.path))))
254
+ throw new Error("Invalid or private/excluded resource destination.");
291
255
  }
292
- export async function loadCatalog(libraryDir) {
256
+ async function loadCatalogUnlocked(libraryDir) {
293
257
  const root = await existingStoreRoot(libraryDir);
294
258
  if (!root)
295
259
  return newCatalog();
@@ -310,95 +274,196 @@ export async function loadCatalog(libraryDir) {
310
274
  catch (error) {
311
275
  throw new Error("Invalid copi catalog JSON.", { cause: error });
312
276
  }
313
- return parseCatalog(value);
277
+ const catalog = parseStoredCatalog(value);
278
+ if (catalog.version === 2)
279
+ await validateRuntimeContentLocations(root, catalog.items);
280
+ return catalog;
281
+ }
282
+ export function catalogBytes(catalog) {
283
+ const validated = parseStoredCatalog(catalog);
284
+ assertModernCatalog(validated);
285
+ return Buffer.from(`${JSON.stringify(validated, null, 2)}\n`, "utf8");
286
+ }
287
+ export async function loadCatalog(libraryDir) {
288
+ return storageTransactions.run(libraryDir, false, async (session) => {
289
+ if (!session) {
290
+ const catalog = newCatalog();
291
+ catalogRevisions.set(catalog, await absentStorageRevision(libraryDir));
292
+ return catalog;
293
+ }
294
+ const revision = await session.capture();
295
+ const catalog = await loadCatalogUnlocked(session.root);
296
+ await session.assertRevision(revision);
297
+ catalogRevisions.set(catalog, revision);
298
+ return catalog;
299
+ }, { requiresNativePlacement: false });
300
+ }
301
+ /** Internal seam: a single lock, checked catalog and all snapshots; no recursive reader locking. */
302
+ export async function withSavedLibrary(libraryDir, operation) {
303
+ return storageTransactions.run(libraryDir, false, async (session) => {
304
+ if (!session)
305
+ throw new Error("Snapshot catalog does not exist.");
306
+ const revision = await session.capture();
307
+ const loaded = await loadCatalogUnlocked(session.root);
308
+ assertModernCatalog(loaded);
309
+ const catalog = loaded;
310
+ const files = new Map();
311
+ for (const item of catalog.items)
312
+ files.set(item.id, await readSnapshotFilesUnlocked(session.root, catalog, item));
313
+ await session.assertRevision(revision);
314
+ return operation({ catalog, revision, files, session });
315
+ }, { requiresNativePlacement: false });
314
316
  }
315
317
  export async function saveCatalog(libraryDir, catalog) {
316
- const validated = parseCatalog(catalog);
317
- const root = await ensurePrivateDirectory(libraryDir, 0o700);
318
- const destination = path.join(root, "catalog.json");
319
- const temporary = path.join(root, `catalog.${randomUUID()}.tmp`);
320
- const handle = await open(temporary, "wx", 0o600);
321
- try {
322
- await handle.writeFile(`${JSON.stringify(validated, null, 2)}\n`, "utf8");
323
- await handle.sync();
324
- }
325
- catch (error) {
326
- await rm(temporary, { force: true });
327
- throw error;
328
- }
329
- finally {
330
- await handle.close();
331
- }
332
- await rename(temporary, destination);
333
- await chmod(destination, 0o600);
318
+ const parsed = parseStoredCatalog(catalog);
319
+ assertModernCatalog(parsed);
320
+ const validated = parsed;
321
+ assertNoNameConflicts(validated);
322
+ const binding = catalogRevisions.get(catalog);
323
+ // Invalid new names are rejected without creating a library.
324
+ const preflight = await loadCatalog(libraryDir);
325
+ assertNoNameConflicts(preflight);
326
+ await validateRuntimeContentLocations(libraryDir, validated.items);
327
+ await storageTransactions.run(libraryDir, true, async (session) => {
328
+ if (!session)
329
+ throw new Error("Missing storage session.");
330
+ const revision = await session.capture();
331
+ const current = await loadCatalogUnlocked(session.root);
332
+ assertNoNameConflicts(current);
333
+ for (const item of current.items)
334
+ await readSnapshotFilesUnlocked(session.root, current, item);
335
+ if (binding)
336
+ await session.assertRevision(binding);
337
+ else {
338
+ if (current.items.length > 0 || current.groups.length > 0)
339
+ throw new Error("Saving an existing catalog requires the original object returned by loadCatalog; detached catalogs have no verified starting revision.");
340
+ if (JSON.stringify(current) !== JSON.stringify(preflight))
341
+ throw new Error("Catalog changed before saving.");
342
+ }
343
+ for (const item of validated.items) {
344
+ const old = current.items.find((entry) => entry.id === item.id);
345
+ if (old?.destination.kind === "global-only" && item.destination.kind !== "global-only")
346
+ throw new Error("A reference-only/global-only resource cannot become project-applicable.");
347
+ if (JSON.stringify(old?.destination) !== JSON.stringify(item.destination) && item.destination.kind === "project" && excludedRelative(item.destination.path))
348
+ throw new Error("Invalid or private/excluded resource destination.");
349
+ const files = await readSnapshotFilesUnlocked(session.root, validated, item);
350
+ if (!old || JSON.stringify(old.files) !== JSON.stringify(item.files)) {
351
+ const inspection = inspectFixedResource({ candidate: { ...item, sourceRoot: path.dirname(item.sourcePath) }, files, excludedPaths: [] });
352
+ if (inspection.sensitivePaths.length > 0)
353
+ throw new Error("Content changes require a prepared inspection and individual approval; use the saved-change API.");
354
+ }
355
+ }
356
+ await session.commit(revision, catalogBytes(validated));
357
+ catalogRevisions.set(catalog, await session.capture());
358
+ }, { requiresNativePlacement: false });
334
359
  }
335
- async function storePreparedResource(libraryDir, candidate, sourceFiles, requestedGroupIds) {
360
+ async function storePreparedResource(libraryDir, candidate, sourceFiles, requestedGroupIds, approvedSensitivePaths) {
361
+ const catalogPreflight = await loadCatalogUnlocked(libraryDir);
362
+ assertModernCatalog(catalogPreflight);
363
+ assertNoNameConflicts(catalogPreflight);
364
+ assertNameAvailable(catalogPreflight, "item", candidate.name);
336
365
  const groupIds = [...new Set(requestedGroupIds)];
337
- const catalog = await loadCatalog(libraryDir);
338
- const groups = new Map(catalog.groups.map((group) => [group.id, group]));
339
- if (groupIds.some((id) => !groups.has(id)))
366
+ if (groupIds.some((id) => !catalogPreflight.groups.some((group) => group.id === id)))
340
367
  throw new Error("Cannot import a resource into an unknown group.");
341
- const root = await ensurePrivateDirectory(libraryDir, 0o700);
342
- const id = randomUUID();
343
- const item = {
344
- id,
345
- kind: candidate.kind,
346
- source: candidate.source,
347
- scope: candidate.scope,
348
- sourcePath: candidate.sourcePath,
349
- destination: candidate.destination,
350
- name: candidate.name,
351
- savedAt: new Date().toISOString(),
352
- files: [],
353
- };
354
- const itemDirectory = path.join(root, "items", id);
355
- try {
356
- item.files = await writeSnapshotFiles(root, id, sourceFiles);
368
+ return storageTransactions.run(libraryDir, true, async (session) => {
369
+ if (!session)
370
+ throw new Error("Missing storage session.");
371
+ const revision = await session.capture();
372
+ const catalog = await loadCatalogUnlocked(session.root);
373
+ assertModernCatalog(catalog);
374
+ assertNoNameConflicts(catalog);
375
+ assertNameAvailable(catalog, "item", candidate.name);
376
+ for (const saved of catalog.items)
377
+ await readSnapshotFilesUnlocked(session.root, catalog, saved);
378
+ const groups = new Map(catalog.groups.map((group) => [group.id, group]));
379
+ if (groupIds.some((id) => !groups.has(id)))
380
+ throw new Error("Cannot import a resource into an unknown group.");
381
+ const id = randomUUID();
382
+ let content;
383
+ if (candidate.kind === "skill") {
384
+ content = { kind: "tree", path: `sources/${id}` };
385
+ }
386
+ else {
387
+ const sourceFile = sourceFiles[0];
388
+ if (sourceFiles.length !== 1 || !sourceFile || sourceFile.path !== path.posix.basename(sourceFile.path)) {
389
+ throw new Error("Non-skill resources must be imported from one regular source file.");
390
+ }
391
+ content = { kind: "file", path: `sources/${id}/${sourceFile.path}` };
392
+ }
393
+ const item = {
394
+ id, kind: candidate.kind, source: candidate.source, scope: candidate.scope,
395
+ sourcePath: candidate.sourcePath, destination: { ...candidate.destination }, name: candidate.name.trim(), savedAt: new Date().toISOString(),
396
+ files: sourceFiles.map((file) => ({ path: file.path, mode: file.mode, sha256: createHash("sha256").update(file.bytes).digest("hex") })),
397
+ content,
398
+ };
357
399
  catalog.items.push(item);
358
400
  for (const groupId of groupIds)
359
- groups.get(groupId)?.itemIds.push(id);
360
- await saveCatalog(root, catalog);
361
- }
362
- catch (error) {
363
- await rm(itemDirectory, { recursive: true, force: true });
364
- throw error;
365
- }
366
- return item;
401
+ groups.get(groupId)?.itemIds.push(item.id);
402
+ const bytes = catalogBytes(catalog);
403
+ const replacements = [{ itemId: item.id, files: sourceFiles }];
404
+ const review = prepareStorageReview(bytes, replacements);
405
+ await session.commit(revision, bytes, replacements, { review, approval: { reviewId: review.reviewId, confirmed: true, approvedSensitivePaths: approvedSensitivePaths.map((filePath) => `${item.id}/${filePath}`) } });
406
+ return structuredClone(item);
407
+ }, { requiresNativePlacement: true });
367
408
  }
368
409
  export async function importPreparedResource(libraryDir, prepared, options) {
369
- if (!options.confirmed)
410
+ if (options.confirmed !== true)
370
411
  return null;
371
412
  const snapshot = getPreparedSnapshot(prepared);
372
- if (options.inspection.preparedResource !== prepared)
373
- throw new Error("Inspection does not match the prepared resource.");
374
- const inspectedPaths = new Set(options.inspection.sensitivePaths);
375
- const approvedPaths = new Set(options.approvedSensitivePaths);
413
+ const inspection = options.inspection;
414
+ const approvedSensitivePaths = [...options.approvedSensitivePaths];
415
+ const groupIds = [...options.groupIds];
416
+ assertPreparedInspection(inspection, prepared);
417
+ const internalInspection = await inspectPreparedResource(prepared);
418
+ const inspectedPaths = new Set(internalInspection.sensitivePaths);
419
+ const approvedPaths = new Set(approvedSensitivePaths);
376
420
  const availablePaths = new Set(snapshot.files.map((file) => file.path));
377
- if (inspectedPaths.size !== options.inspection.sensitivePaths.length || approvedPaths.size !== options.approvedSensitivePaths.length ||
421
+ if (inspectedPaths.size !== inspection.sensitivePaths.length || approvedPaths.size !== approvedSensitivePaths.length ||
378
422
  [...inspectedPaths].some((filePath) => !availablePaths.has(filePath)) || inspectedPaths.size !== approvedPaths.size ||
379
423
  [...inspectedPaths].some((filePath) => !approvedPaths.has(filePath))) {
380
424
  throw new Error("Prepared import requires approval for every suspected sensitive file in its inspection.");
381
425
  }
382
- return storePreparedResource(libraryDir, snapshot.candidate, snapshot.files, options.groupIds);
426
+ return storePreparedResource(libraryDir, snapshot.candidate, snapshot.files, groupIds, [...approvedPaths]);
383
427
  }
384
428
  export async function importResource(libraryDir, candidate, options) {
385
- if (!options.confirmed)
429
+ if (options.confirmed !== true)
386
430
  return null;
387
- const prepared = await prepareResource(candidate);
431
+ const capturedCandidate = copyCandidate(candidate);
432
+ const suppliedInspection = options.inspection;
433
+ const approvedSensitivePaths = [...(options.approvedSensitivePaths ?? [])];
434
+ const groupIds = [...options.groupIds];
435
+ const prepared = suppliedInspection?.preparedResource ?? await prepareResource(capturedCandidate);
388
436
  const snapshot = getPreparedSnapshot(prepared);
389
- return storePreparedResource(libraryDir, snapshot.candidate, snapshot.files, options.groupIds);
437
+ if (suppliedInspection) {
438
+ assertPreparedInspection(suppliedInspection, prepared);
439
+ const fixed = snapshot.candidate;
440
+ const sameDestination = capturedCandidate.destination?.kind === fixed.destination.kind &&
441
+ (fixed.destination.kind !== "project" || (capturedCandidate.destination.kind === "project" && capturedCandidate.destination.path === fixed.destination.path));
442
+ if (["id", "kind", "source", "scope", "sourceRoot", "sourcePath", "name"].some((key) => capturedCandidate[key] !== fixed[key]) || !sameDestination) {
443
+ throw new Error("Inspection candidate does not match the prepared resource metadata.");
444
+ }
445
+ }
446
+ const inspection = await inspectPreparedResource(prepared);
447
+ if (!suppliedInspection && inspection.sensitivePaths.length > 0)
448
+ throw new Error("A prepared inspection and individual approval are required for sensitive content.");
449
+ return importPreparedResource(libraryDir, prepared, { confirmed: true, groupIds, inspection: suppliedInspection ?? inspection, approvedSensitivePaths });
390
450
  }
391
- export async function readSnapshotFiles(libraryDir, item) {
451
+ async function readSnapshotFilesUnlocked(libraryDir, catalog, item) {
392
452
  if (!itemIdPattern.test(item.id))
393
453
  throw new Error("Invalid snapshot identifier.");
394
454
  const root = await existingStoreRoot(libraryDir);
395
455
  if (!root)
396
456
  throw new Error("Snapshot catalog does not exist.");
397
- const catalog = await loadCatalog(root);
398
457
  const storedItem = catalog.items.find((entry) => entry.id === item.id);
399
458
  if (!storedItem)
400
459
  throw new Error("Snapshot is not present in the catalog.");
401
- const canonicalFilesRoot = await ensurePrivateSubdirectory(root, path.posix.join("items", storedItem.id, "files"), undefined, false);
460
+ if (catalog.version === 2) {
461
+ const modernItem = catalog.items.find((entry) => entry.id === item.id);
462
+ if (!modernItem)
463
+ throw new Error("Snapshot is not present in the v2 catalog.");
464
+ return readSourceContent(root, modernItem);
465
+ }
466
+ const canonicalFilesRoot = await existingPrivateSubdirectory(root, path.posix.join("items", storedItem.id, "files"));
402
467
  const results = [];
403
468
  for (const file of storedItem.files) {
404
469
  if (!safeRelativePath(file.path))
@@ -418,11 +483,78 @@ export async function readSnapshotFiles(libraryDir, item) {
418
483
  throw new Error("Snapshot path resolves outside the item directory.");
419
484
  const bytes = await readFile(canonicalFile);
420
485
  if (createHash("sha256").update(bytes).digest("hex") !== file.sha256)
421
- throw new Error(`Snapshot checksum mismatch for ${file.path}.`);
486
+ throw new Error(`Snapshot checksum mismatch for ${file.path}: saved bytes were manually changed or damaged. Changes are stopped; inspect the saved copy and its source before re-importing. Checksums are not automatically repaired.`);
422
487
  results.push({ ...file, bytes });
423
488
  }
424
489
  return results;
425
490
  }
491
+ export async function readSnapshotFiles(libraryDir, item) {
492
+ return storageTransactions.run(libraryDir, false, async (session) => {
493
+ if (!session)
494
+ throw new Error("Snapshot catalog does not exist.");
495
+ const revision = await session.capture();
496
+ const catalog = await loadCatalogUnlocked(session.root);
497
+ const result = await readSnapshotFilesUnlocked(session.root, catalog, item);
498
+ await session.assertRevision(revision);
499
+ return result;
500
+ }, { requiresNativePlacement: false });
501
+ }
502
+ /** Persist staged, explicit name-only changes together, so cancellation never partially resolves names. */
503
+ export async function saveNameChanges(libraryDir, before, after) {
504
+ return withSavedLibrary(libraryDir, async ({ catalog: current, revision, session }) => {
505
+ await saveNameChangesLocked(before, after, current, revision, session);
506
+ });
507
+ }
508
+ async function saveNameChangesLocked(before, after, current, revision, session) {
509
+ const parsed = parseStoredCatalog(after);
510
+ assertModernCatalog(parsed);
511
+ const validated = parsed;
512
+ if (JSON.stringify(current) !== JSON.stringify(before))
513
+ throw new Error("Catalog changed while renaming; reload before trying again.");
514
+ const restored = structuredClone(validated);
515
+ const conflicts = findNameConflicts(before);
516
+ for (const namespace of ["item", "group"]) {
517
+ const previousEntries = namespace === "item" ? before.items : before.groups;
518
+ const nextEntries = namespace === "item" ? restored.items : restored.groups;
519
+ if (previousEntries.length !== nextEntries.length)
520
+ throw new Error("Renaming must preserve catalog entries.");
521
+ for (const [index, entry] of nextEntries.entries()) {
522
+ const previous = previousEntries[index];
523
+ if (!previous || previous.id !== entry.id)
524
+ throw new Error("Renaming must preserve catalog identifiers and order.");
525
+ if (entry.name !== previous.name) {
526
+ if (conflicts.length > 0 && !conflicts.some((conflict) => conflict.namespace === namespace && conflict.ids.includes(entry.id))) {
527
+ throw new Error("Only conflicting names may change until duplicate names are resolved.");
528
+ }
529
+ nameKey(entry.name);
530
+ const finalEntries = namespace === "item" ? validated.items : validated.groups;
531
+ if (finalEntries.some((other) => other.id !== entry.id && nameKey(other.name) === nameKey(entry.name))) {
532
+ throw new Error("Duplicate name; choose a unique display name.");
533
+ }
534
+ }
535
+ entry.name = previous.name;
536
+ }
537
+ }
538
+ if (JSON.stringify(restored) !== JSON.stringify(before))
539
+ throw new Error("Renaming must preserve metadata, files, destinations, and group references.");
540
+ const binding = catalogRevisions.get(before);
541
+ if (!binding)
542
+ throw new Error("Renaming requires the original before-catalog returned by loadCatalog; reload to obtain a verified revision.");
543
+ await session.assertRevision(binding);
544
+ await session.commit(revision, catalogBytes(validated));
545
+ }
546
+ export async function renameSavedItem(libraryDir, itemId, name) {
547
+ const before = await loadCatalog(libraryDir);
548
+ assertModernCatalog(before);
549
+ const after = structuredClone(before);
550
+ const item = after.items.find((entry) => entry.id === itemId);
551
+ if (!item)
552
+ throw new Error("Unknown saved item.");
553
+ assertNameAvailable(before, "item", name, itemId);
554
+ item.name = name.trim();
555
+ await saveNameChanges(libraryDir, before, after);
556
+ return item;
557
+ }
426
558
  export async function listSnapshots(libraryDir) {
427
559
  const catalog = await loadCatalog(libraryDir);
428
560
  return [...catalog.items].sort((left, right) => right.savedAt.localeCompare(left.savedAt) || left.id.localeCompare(right.id));
package/dist/src/cli.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export declare function main(args?: string[], environment?: NodeJS.ProcessEnv, cwd?: string): Promise<number>;
2
+ import { type MigrationCommandRuntime } from "./migrate.ts";
3
+ export declare function main(args?: string[], environment?: NodeJS.ProcessEnv, cwd?: string, migrationRuntime?: MigrationCommandRuntime): Promise<number>;
package/dist/src/cli.js CHANGED
@@ -3,26 +3,79 @@ import { realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { listSnapshots, loadCatalog } from "./catalog.js";
6
- import { printGroups, printItems, runApplyDryRun, runApplyWizard, runGroupManager, runSetup } from "./wizard.js";
6
+ import { printGroups, printItems, runApplyDryRun, runApplyWizard, runGroupManager, runSetup, safeDisplay } from "./wizard.js";
7
+ import { runManage } from "./manage.js";
8
+ import { runMigrateCommand } from "./migrate.js";
7
9
  function usage() {
8
10
  process.stdout.write(`copi — copy and organize reusable Pi coding-agent resources
9
11
 
10
12
  Usage:
11
13
  copi setup
14
+ copi manage
12
15
  copi groups
13
16
  copi list
14
17
  copi apply <group> [target]
15
18
  copi apply <group> [target] --dry-run
19
+ copi migrate [--dry-run]
20
+ copi migrate --resolve [--dry-run]
16
21
  copi help
17
22
 
18
23
  Commands:
19
24
  setup Search explicitly selected locations and save copied resources
25
+ manage Select saved copies by name; masked view, edit, rename, refresh, groups, apply
20
26
  groups Manage groups interactively; without a TTY, list groups only
21
27
  list List saved snapshots without modifying the library
22
28
  apply <group> Review and apply one group to the current directory or target
23
29
  apply --dry-run Show the masked plan without changing project files
30
+ migrate Review and migrate an eligible legacy library (TTY required)
31
+ migrate --dry-run Show a read-only masked legacy migration review
32
+ migrate --resolve Confirm resolution of eligible post-switch migration leftovers
24
33
  help Show this help
25
34
 
35
+ Manage requires an interactive stdin/stdout TTY; UUID input is not required.
36
+ Resource names and group names are unique in separate library-wide namespaces:
37
+ trim, NFKC, Unicode 17 default full case folding, then NFKC. Legacy duplicate
38
+ names require manual resolution; only listing, masked viewing and resolution
39
+ are allowed until resolved. Display name changes leave contents not rewritten
40
+ and are separate from destination changes.
41
+
42
+ Edit only existing UTF-8 saved files, at most 1_000_000 bytes; binary and unsupported
43
+ encodings are rejected. Ctrl-S submits for diff review, not immediate saving;
44
+ Esc / Ctrl-C cancels. Enter inserts a newline, arrows move, PgUp/PgDn scroll;
45
+ bracketed paste supports multiple lines. Suspected sensitive raw text can expose
46
+ secrets on the screen, scrollback or recordings: individual approval to expose
47
+ is separate from approval to save. Fixed bytes are inspected before saving;
48
+ each suspected sensitive file requires fresh individual approval.
49
+
50
+ Pi loads only root context files (AGENTS.override.md, AGENTS.md, AGENTS.MD,
51
+ CLAUDE.md, CLAUDE.MD) and .pi/SYSTEM.md or .pi/APPEND_SYSTEM.md automatically.
52
+ apply warns when another instruction is not mentioned by a root context file and
53
+ can add one reference line after a separate default-no approval; --dry-run shows
54
+ the same warning and diff without writing.
55
+
56
+ Refresh can reselect a same-kind source with explicit read permission; the whole
57
+ saved copy is replaced after masked diff and individual file approvals. Copi-only edits
58
+ will be lost. Originals are not written back. No history, restore or automatic merge
59
+ is provided. Private saved files are 0600; apply reproduces the original source mode.
60
+ File addition, deletion and internal rename through text editing, and applying
61
+ global settings, are outside scope.
62
+
63
+ Migration dry-runs are read-only and may run without a TTY. Mutating migration and
64
+ resolution require both stdin and stdout to be a TTY; every confirmation defaults to no.
65
+ Resolution first shows the complete masked diff, then asks about each sensitive path,
66
+ then asks for a final confirmation. --resolve never falls back to ordinary migration.
67
+ There is no --yes option. Cancellation, blocked
68
+ reviews, invalid flags and non-TTY mutation exit with status 1. If an ordinary migration
69
+ stops on an internal lock, use copi migrate --resolve --dry-run; never remove a lock by hand.
70
+
71
+ Resolution only confirms eligible adopted-source edits and cleans up owned migration
72
+ artifacts. A v4 journal has no start inventory, so it stays on the existing recovery/
73
+ hold-stop path. It does not rewrite catalog, sources or items, distribute resources, or delete
74
+ items. Do not use an older copi binary while resolution artifacts remain. v4 journals lack
75
+ the start inventory required for this procedure and remain on the existing hold-stop path.
76
+ A migration backup is not general history or a restore feature. Migration, items deletion,
77
+ main integration and publishing require separate approval.
78
+
26
79
  Interactive changes require a TTY. Setup searches only locations explicitly selected
27
80
  and confirmed in the wizard. Directories named auth are excluded entirely; copied
28
81
  authentication-specific code may be missing from copied resources. Named authentication files and .env* paths,
@@ -38,12 +91,19 @@ function requireHome(environment) {
38
91
  function isInteractive() {
39
92
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
40
93
  }
41
- export async function main(args = process.argv.slice(2), environment = process.env, cwd = process.cwd()) {
94
+ export async function main(args = process.argv.slice(2), environment = process.env, cwd = process.cwd(), migrationRuntime = {}) {
42
95
  const [command, ...rest] = args;
43
96
  if (!command || command === "help" || command === "--help" || command === "-h") {
44
97
  usage();
45
98
  return 0;
46
99
  }
100
+ if (command === "migrate" && (rest.includes("--help") || rest.includes("-h"))) {
101
+ if (rest.length === 1) {
102
+ usage();
103
+ return 0;
104
+ }
105
+ throw new Error("Usage: copi migrate [--dry-run] | copi migrate --resolve [--dry-run].");
106
+ }
47
107
  if (rest.includes("--help") || rest.includes("-h")) {
48
108
  usage();
49
109
  return 0;
@@ -54,6 +114,16 @@ export async function main(args = process.argv.slice(2), environment = process.e
54
114
  ? path.resolve(cwd, environment.PI_CODING_AGENT_DIR)
55
115
  : path.join(homeDir || requireHome(environment), ".pi", "agent");
56
116
  const projectDir = path.resolve(cwd);
117
+ if (command === "migrate")
118
+ return runMigrateCommand(rest, libraryDir, migrationRuntime);
119
+ if (command === "manage") {
120
+ if (rest.length > 0)
121
+ throw new Error("manage does not accept positional arguments.");
122
+ if (!isInteractive())
123
+ throw new Error("manage requires an interactive stdin/stdout TTY; no library was created or searched.");
124
+ await runManage(libraryDir, { homeDir, agentDir, projectDir });
125
+ return 0;
126
+ }
57
127
  if (command === "setup") {
58
128
  if (rest.length > 0)
59
129
  throw new Error("setup does not accept positional arguments.");
@@ -102,7 +172,7 @@ if (import.meta.url === invokedPath) {
102
172
  }
103
173
  catch (error) {
104
174
  const message = error instanceof Error ? error.message : "Unexpected error.";
105
- console.error(`copi: ${message}`);
175
+ console.error(`copi: ${safeDisplay(message)}`);
106
176
  process.exitCode = 1;
107
177
  }
108
178
  }