@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.
- package/LICENSE.unicode +39 -0
- package/README.md +57 -5
- package/dist/native/rename-exclusive-arm64 +0 -0
- package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
- package/dist/src/apply.d.ts +9 -0
- package/dist/src/apply.js +110 -9
- package/dist/src/catalog.d.ts +21 -4
- package/dist/src/catalog.js +265 -133
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.js +73 -3
- package/dist/src/groups.js +8 -3
- package/dist/src/inspect.d.ts +3 -1
- package/dist/src/inspect.js +16 -6
- package/dist/src/instruction-reference.d.ts +6 -0
- package/dist/src/instruction-reference.js +21 -0
- package/dist/src/manage.d.ts +2 -0
- package/dist/src/manage.js +329 -0
- package/dist/src/migrate.d.ts +25 -0
- package/dist/src/migrate.js +171 -0
- package/dist/src/migration-backup.d.ts +22 -0
- package/dist/src/migration-backup.js +403 -0
- package/dist/src/migration-inventory.d.ts +9 -0
- package/dist/src/migration-inventory.js +237 -0
- package/dist/src/migration-resolution.d.ts +28 -0
- package/dist/src/migration-resolution.js +1618 -0
- package/dist/src/migration.d.ts +49 -0
- package/dist/src/migration.js +1004 -0
- package/dist/src/names.d.ts +11 -0
- package/dist/src/names.js +40 -0
- package/dist/src/native-placement.d.ts +16 -0
- package/dist/src/native-placement.js +557 -0
- package/dist/src/refresh.d.ts +20 -0
- package/dist/src/refresh.js +143 -0
- package/dist/src/resource-mutations.d.ts +45 -0
- package/dist/src/resource-mutations.js +225 -0
- package/dist/src/source-content.d.ts +4 -0
- package/dist/src/source-content.js +208 -0
- package/dist/src/storage-journal.d.ts +111 -0
- package/dist/src/storage-journal.js +613 -0
- package/dist/src/storage-root-lease.d.ts +7 -0
- package/dist/src/storage-root-lease.js +92 -0
- package/dist/src/storage-transaction.d.ts +100 -0
- package/dist/src/storage-transaction.js +3951 -0
- package/dist/src/terminal-editor.d.ts +8 -0
- package/dist/src/terminal-editor.js +391 -0
- package/dist/src/types.d.ts +18 -2
- package/dist/src/unicode-case-folding-data.d.ts +1 -0
- package/dist/src/unicode-case-folding-data.js +1596 -0
- package/dist/src/unicode-case-folding.d.ts +2 -0
- package/dist/src/unicode-case-folding.js +9 -0
- package/dist/src/wizard.d.ts +11 -3
- package/dist/src/wizard.js +274 -18
- package/package.json +5 -3
package/dist/src/catalog.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
await
|
|
364
|
-
|
|
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 (
|
|
410
|
+
if (options.confirmed !== true)
|
|
370
411
|
return null;
|
|
371
412
|
const snapshot = getPreparedSnapshot(prepared);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
|
|
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 !==
|
|
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,
|
|
426
|
+
return storePreparedResource(libraryDir, snapshot.candidate, snapshot.files, groupIds, [...approvedPaths]);
|
|
383
427
|
}
|
|
384
428
|
export async function importResource(libraryDir, candidate, options) {
|
|
385
|
-
if (
|
|
429
|
+
if (options.confirmed !== true)
|
|
386
430
|
return null;
|
|
387
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|