@papillon6814/copi 0.1.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 +21 -0
- package/README.md +50 -0
- package/dist/src/apply.d.ts +51 -0
- package/dist/src/apply.js +443 -0
- package/dist/src/catalog.d.ts +39 -0
- package/dist/src/catalog.js +429 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +108 -0
- package/dist/src/discovery.d.ts +12 -0
- package/dist/src/discovery.js +584 -0
- package/dist/src/groups.d.ts +4 -0
- package/dist/src/groups.js +30 -0
- package/dist/src/inspect.d.ts +5 -0
- package/dist/src/inspect.js +88 -0
- package/dist/src/types.d.ts +67 -0
- package/dist/src/types.js +1 -0
- package/dist/src/wizard.d.ts +30 -0
- package/dist/src/wizard.js +464 -0
- package/package.json +34 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const preparedSnapshots = new WeakMap();
|
|
5
|
+
const kinds = new Set(["instruction", "skill", "subagent", "pi-settings", "mcp-config", "model-config", "keybindings"]);
|
|
6
|
+
const scopes = new Set(["user", "project", "shared", "package"]);
|
|
7
|
+
const excludedNames = new Set([".git", ".cache", ".npm", "auth", "node_modules", "cache", "caches", "history", "sessions"]);
|
|
8
|
+
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
|
+
const groupIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
10
|
+
export function newCatalog() {
|
|
11
|
+
return { version: 1, items: [], groups: [] };
|
|
12
|
+
}
|
|
13
|
+
function record(value) {
|
|
14
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
15
|
+
return undefined;
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function isWithin(root, target) {
|
|
19
|
+
const relative = path.relative(root, target);
|
|
20
|
+
return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
|
|
21
|
+
}
|
|
22
|
+
function safeRelativePath(value) {
|
|
23
|
+
if (typeof value !== "string" || value.length === 0 || value.includes("\\") || path.posix.isAbsolute(value))
|
|
24
|
+
return false;
|
|
25
|
+
const parts = value.split("/");
|
|
26
|
+
return parts.every((part) => part.length > 0 && part !== "." && part !== "..") && path.posix.normalize(value) === value;
|
|
27
|
+
}
|
|
28
|
+
function parseDestination(value) {
|
|
29
|
+
const destination = record(value);
|
|
30
|
+
if (!destination)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (destination.kind === "global-only")
|
|
33
|
+
return { kind: "global-only" };
|
|
34
|
+
if (destination.kind === "project" && safeRelativePath(destination.path))
|
|
35
|
+
return { kind: "project", path: destination.path };
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
function parseSnapshotFile(value) {
|
|
39
|
+
const file = record(value);
|
|
40
|
+
if (!file || !safeRelativePath(file.path) || !Number.isInteger(file.mode) || typeof file.mode !== "number" || file.mode < 0 || file.mode > 0o777 || typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(file.sha256))
|
|
41
|
+
return undefined;
|
|
42
|
+
return { path: file.path, mode: file.mode, sha256: file.sha256 };
|
|
43
|
+
}
|
|
44
|
+
function parseSavedItem(value) {
|
|
45
|
+
const item = record(value);
|
|
46
|
+
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
|
+
return undefined;
|
|
48
|
+
const destination = parseDestination(item.destination);
|
|
49
|
+
const files = item.files.map(parseSnapshotFile);
|
|
50
|
+
if (!destination || files.some((file) => file === undefined) || new Set(files.map((file) => file?.path)).size !== files.length)
|
|
51
|
+
return undefined;
|
|
52
|
+
return {
|
|
53
|
+
id: item.id,
|
|
54
|
+
kind: item.kind,
|
|
55
|
+
source: item.source,
|
|
56
|
+
scope: item.scope,
|
|
57
|
+
sourcePath: item.sourcePath,
|
|
58
|
+
destination,
|
|
59
|
+
name: item.name,
|
|
60
|
+
savedAt: item.savedAt,
|
|
61
|
+
files: files,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function parseGroup(value) {
|
|
65
|
+
const group = record(value);
|
|
66
|
+
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)))
|
|
67
|
+
return undefined;
|
|
68
|
+
if (new Set(group.itemIds).size !== group.itemIds.length)
|
|
69
|
+
return undefined;
|
|
70
|
+
return { id: group.id, name: group.name, itemIds: [...group.itemIds] };
|
|
71
|
+
}
|
|
72
|
+
function parseCatalog(value) {
|
|
73
|
+
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);
|
|
77
|
+
const groups = catalog.groups.map(parseGroup);
|
|
78
|
+
if (items.some((item) => item === undefined) || groups.some((group) => group === undefined))
|
|
79
|
+
throw new Error("Invalid copi catalog: an item or group has malformed fields.");
|
|
80
|
+
const itemIds = items.map((item) => item?.id);
|
|
81
|
+
const groupIds = groups.map((group) => group?.id);
|
|
82
|
+
if (new Set(itemIds).size !== itemIds.length || new Set(groupIds).size !== groupIds.length)
|
|
83
|
+
throw new Error("Invalid copi catalog: duplicate item or group identifiers.");
|
|
84
|
+
const knownItems = new Set(itemIds);
|
|
85
|
+
if (groups.some((group) => group?.itemIds.some((id) => !knownItems.has(id))))
|
|
86
|
+
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);
|
|
118
|
+
}
|
|
119
|
+
async function ensurePrivateSubdirectory(root, relativePath, mode, create) {
|
|
120
|
+
if (!safeRelativePath(relativePath))
|
|
121
|
+
throw new Error("Unsafe relative path inside copi storage.");
|
|
122
|
+
const canonicalRoot = await realpath(root);
|
|
123
|
+
const rootStat = await lstat(canonicalRoot);
|
|
124
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
125
|
+
throw new Error("Copi storage root is not a safe directory.");
|
|
126
|
+
let current = canonicalRoot;
|
|
127
|
+
for (const segment of relativePath.split("/")) {
|
|
128
|
+
const next = path.join(current, segment);
|
|
129
|
+
let stat = await lstat(next).catch((error) => {
|
|
130
|
+
if (error.code === "ENOENT")
|
|
131
|
+
return undefined;
|
|
132
|
+
throw error;
|
|
133
|
+
});
|
|
134
|
+
if (!stat && create) {
|
|
135
|
+
await mkdir(next, { mode: 0o700 });
|
|
136
|
+
stat = await lstat(next);
|
|
137
|
+
}
|
|
138
|
+
if (!stat || stat.isSymbolicLink() || !stat.isDirectory())
|
|
139
|
+
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
|
+
current = next;
|
|
143
|
+
}
|
|
144
|
+
return current;
|
|
145
|
+
}
|
|
146
|
+
async function existingStoreRoot(libraryDir) {
|
|
147
|
+
const requested = path.resolve(libraryDir);
|
|
148
|
+
const stat = await lstat(requested).catch((error) => {
|
|
149
|
+
if (error.code === "ENOENT")
|
|
150
|
+
return undefined;
|
|
151
|
+
throw error;
|
|
152
|
+
});
|
|
153
|
+
if (!stat)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
156
|
+
throw new Error("Refusing to read a copied catalog through a non-directory or symbolic link.");
|
|
157
|
+
return await realpath(requested);
|
|
158
|
+
}
|
|
159
|
+
function excludedRelative(relative) {
|
|
160
|
+
return relative.split("/").some((part) => {
|
|
161
|
+
const lower = part.toLowerCase();
|
|
162
|
+
return excludedNames.has(lower) || lower === "auth.json" || lower === "mcp-auth.json" || lower === "oauth.json" || lower === ".env" || lower.startsWith(".env");
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async function checkedSourceRoot(candidate) {
|
|
166
|
+
const rootInput = path.resolve(candidate.sourceRoot);
|
|
167
|
+
const sourceInput = path.resolve(candidate.sourcePath);
|
|
168
|
+
if (!path.isAbsolute(candidate.sourceRoot) || !path.isAbsolute(candidate.sourcePath) || !isWithin(rootInput, sourceInput))
|
|
169
|
+
throw new Error("Resource source path escapes its declared boundary.");
|
|
170
|
+
const rootStat = await lstat(rootInput);
|
|
171
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
172
|
+
throw new Error("Resource search root is not a safe directory.");
|
|
173
|
+
const root = await realpath(rootInput);
|
|
174
|
+
const relative = path.relative(rootInput, sourceInput);
|
|
175
|
+
let current = rootInput;
|
|
176
|
+
const components = relative === "" ? [] : relative.split(path.sep);
|
|
177
|
+
for (const component of components) {
|
|
178
|
+
current = path.join(current, component);
|
|
179
|
+
const stat = await lstat(current);
|
|
180
|
+
if (stat.isSymbolicLink())
|
|
181
|
+
throw new Error("Symbolic links are not copied as resource content.");
|
|
182
|
+
}
|
|
183
|
+
const source = await realpath(sourceInput);
|
|
184
|
+
if (!isWithin(root, source))
|
|
185
|
+
throw new Error("Resource source path resolves outside its declared boundary.");
|
|
186
|
+
const sourceStat = await lstat(sourceInput);
|
|
187
|
+
if (!sourceStat.isDirectory() && !sourceStat.isFile())
|
|
188
|
+
throw new Error("Resource source must be a regular file or directory.");
|
|
189
|
+
return { root, source };
|
|
190
|
+
}
|
|
191
|
+
async function readSourceFiles(candidate, excludedPaths) {
|
|
192
|
+
const { root, source } = await checkedSourceRoot(candidate);
|
|
193
|
+
const sourceStat = await lstat(source);
|
|
194
|
+
const directory = sourceStat.isDirectory();
|
|
195
|
+
const files = [];
|
|
196
|
+
const visit = async (target) => {
|
|
197
|
+
const relative = directory ? path.relative(source, target).split(path.sep).join("/") : path.basename(target);
|
|
198
|
+
if (relative && excludedRelative(relative)) {
|
|
199
|
+
excludedPaths.push(relative);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const stat = await lstat(target);
|
|
203
|
+
if (stat.isSymbolicLink())
|
|
204
|
+
throw new Error("Symbolic links are not copied as resource content.");
|
|
205
|
+
if (stat.isDirectory()) {
|
|
206
|
+
const entries = await readdir(target, { withFileTypes: true });
|
|
207
|
+
for (const entry of entries)
|
|
208
|
+
await visit(path.join(target, entry.name));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (!stat.isFile())
|
|
212
|
+
return;
|
|
213
|
+
const storedPath = directory ? relative : path.basename(target);
|
|
214
|
+
if (!safeRelativePath(storedPath))
|
|
215
|
+
throw new Error("Resource contains an unsafe relative file path.");
|
|
216
|
+
files.push({ path: storedPath, mode: stat.mode & 0o777, bytes: await readFile(target) });
|
|
217
|
+
};
|
|
218
|
+
await visit(source);
|
|
219
|
+
if (files.length === 0)
|
|
220
|
+
throw new Error("Resource contains no copyable files.");
|
|
221
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
222
|
+
if (!isWithin(root, source))
|
|
223
|
+
throw new Error("Resource source path escapes its declared boundary.");
|
|
224
|
+
return files;
|
|
225
|
+
}
|
|
226
|
+
function copyCandidate(candidate) {
|
|
227
|
+
return { ...candidate, destination: { ...candidate.destination } };
|
|
228
|
+
}
|
|
229
|
+
function getPreparedSnapshot(prepared) {
|
|
230
|
+
if (typeof prepared !== "object" || prepared === null)
|
|
231
|
+
throw new Error("Invalid prepared resource.");
|
|
232
|
+
const snapshot = preparedSnapshots.get(prepared);
|
|
233
|
+
if (!snapshot)
|
|
234
|
+
throw new Error("Prepared resource is unavailable.");
|
|
235
|
+
return snapshot;
|
|
236
|
+
}
|
|
237
|
+
export async function prepareResource(candidate) {
|
|
238
|
+
validateCandidate(candidate);
|
|
239
|
+
const capturedCandidate = copyCandidate(candidate);
|
|
240
|
+
const excludedPaths = [];
|
|
241
|
+
const files = await readSourceFiles(capturedCandidate, excludedPaths);
|
|
242
|
+
const prepared = Object.freeze({});
|
|
243
|
+
preparedSnapshots.set(prepared, {
|
|
244
|
+
candidate: capturedCandidate,
|
|
245
|
+
files,
|
|
246
|
+
excludedPaths: [...new Set(excludedPaths)].sort((left, right) => left.localeCompare(right)),
|
|
247
|
+
});
|
|
248
|
+
return prepared;
|
|
249
|
+
}
|
|
250
|
+
export function readPreparedResource(prepared) {
|
|
251
|
+
const snapshot = getPreparedSnapshot(prepared);
|
|
252
|
+
const files = snapshot.files.map((file) => Object.freeze({
|
|
253
|
+
path: file.path,
|
|
254
|
+
mode: file.mode,
|
|
255
|
+
bytes: Buffer.from(file.bytes),
|
|
256
|
+
}));
|
|
257
|
+
return Object.freeze({
|
|
258
|
+
candidate: copyCandidate(snapshot.candidate),
|
|
259
|
+
files: Object.freeze(files),
|
|
260
|
+
excludedPaths: Object.freeze([...snapshot.excludedPaths]),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
function validateCandidate(candidate) {
|
|
264
|
+
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
|
+
throw new Error("Invalid resource candidate.");
|
|
266
|
+
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;
|
|
291
|
+
}
|
|
292
|
+
export async function loadCatalog(libraryDir) {
|
|
293
|
+
const root = await existingStoreRoot(libraryDir);
|
|
294
|
+
if (!root)
|
|
295
|
+
return newCatalog();
|
|
296
|
+
const filePath = path.join(root, "catalog.json");
|
|
297
|
+
const stat = await lstat(filePath).catch((error) => {
|
|
298
|
+
if (error.code === "ENOENT")
|
|
299
|
+
return undefined;
|
|
300
|
+
throw error;
|
|
301
|
+
});
|
|
302
|
+
if (!stat)
|
|
303
|
+
return newCatalog();
|
|
304
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
305
|
+
throw new Error("Invalid copi catalog: catalog.json is not a regular file.");
|
|
306
|
+
let value;
|
|
307
|
+
try {
|
|
308
|
+
value = JSON.parse(await readFile(filePath, "utf8"));
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
throw new Error("Invalid copi catalog JSON.", { cause: error });
|
|
312
|
+
}
|
|
313
|
+
return parseCatalog(value);
|
|
314
|
+
}
|
|
315
|
+
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);
|
|
334
|
+
}
|
|
335
|
+
async function storePreparedResource(libraryDir, candidate, sourceFiles, requestedGroupIds) {
|
|
336
|
+
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)))
|
|
340
|
+
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);
|
|
357
|
+
catalog.items.push(item);
|
|
358
|
+
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;
|
|
367
|
+
}
|
|
368
|
+
export async function importPreparedResource(libraryDir, prepared, options) {
|
|
369
|
+
if (!options.confirmed)
|
|
370
|
+
return null;
|
|
371
|
+
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);
|
|
376
|
+
const availablePaths = new Set(snapshot.files.map((file) => file.path));
|
|
377
|
+
if (inspectedPaths.size !== options.inspection.sensitivePaths.length || approvedPaths.size !== options.approvedSensitivePaths.length ||
|
|
378
|
+
[...inspectedPaths].some((filePath) => !availablePaths.has(filePath)) || inspectedPaths.size !== approvedPaths.size ||
|
|
379
|
+
[...inspectedPaths].some((filePath) => !approvedPaths.has(filePath))) {
|
|
380
|
+
throw new Error("Prepared import requires approval for every suspected sensitive file in its inspection.");
|
|
381
|
+
}
|
|
382
|
+
return storePreparedResource(libraryDir, snapshot.candidate, snapshot.files, options.groupIds);
|
|
383
|
+
}
|
|
384
|
+
export async function importResource(libraryDir, candidate, options) {
|
|
385
|
+
if (!options.confirmed)
|
|
386
|
+
return null;
|
|
387
|
+
const prepared = await prepareResource(candidate);
|
|
388
|
+
const snapshot = getPreparedSnapshot(prepared);
|
|
389
|
+
return storePreparedResource(libraryDir, snapshot.candidate, snapshot.files, options.groupIds);
|
|
390
|
+
}
|
|
391
|
+
export async function readSnapshotFiles(libraryDir, item) {
|
|
392
|
+
if (!itemIdPattern.test(item.id))
|
|
393
|
+
throw new Error("Invalid snapshot identifier.");
|
|
394
|
+
const root = await existingStoreRoot(libraryDir);
|
|
395
|
+
if (!root)
|
|
396
|
+
throw new Error("Snapshot catalog does not exist.");
|
|
397
|
+
const catalog = await loadCatalog(root);
|
|
398
|
+
const storedItem = catalog.items.find((entry) => entry.id === item.id);
|
|
399
|
+
if (!storedItem)
|
|
400
|
+
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);
|
|
402
|
+
const results = [];
|
|
403
|
+
for (const file of storedItem.files) {
|
|
404
|
+
if (!safeRelativePath(file.path))
|
|
405
|
+
throw new Error("Catalog contains an unsafe snapshot path.");
|
|
406
|
+
const filePath = path.resolve(canonicalFilesRoot, ...file.path.split("/"));
|
|
407
|
+
if (!isWithin(canonicalFilesRoot, filePath))
|
|
408
|
+
throw new Error("Snapshot path escapes the item directory.");
|
|
409
|
+
let current = canonicalFilesRoot;
|
|
410
|
+
for (const segment of file.path.split("/")) {
|
|
411
|
+
current = path.join(current, segment);
|
|
412
|
+
const stat = await lstat(current);
|
|
413
|
+
if (stat.isSymbolicLink())
|
|
414
|
+
throw new Error("Snapshot contains an unsafe symbolic link.");
|
|
415
|
+
}
|
|
416
|
+
const canonicalFile = await realpath(filePath);
|
|
417
|
+
if (!isWithin(canonicalFilesRoot, canonicalFile))
|
|
418
|
+
throw new Error("Snapshot path resolves outside the item directory.");
|
|
419
|
+
const bytes = await readFile(canonicalFile);
|
|
420
|
+
if (createHash("sha256").update(bytes).digest("hex") !== file.sha256)
|
|
421
|
+
throw new Error(`Snapshot checksum mismatch for ${file.path}.`);
|
|
422
|
+
results.push({ ...file, bytes });
|
|
423
|
+
}
|
|
424
|
+
return results;
|
|
425
|
+
}
|
|
426
|
+
export async function listSnapshots(libraryDir) {
|
|
427
|
+
const catalog = await loadCatalog(libraryDir);
|
|
428
|
+
return [...catalog.items].sort((left, right) => right.savedAt.localeCompare(left.savedAt) || left.id.localeCompare(right.id));
|
|
429
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { listSnapshots, loadCatalog } from "./catalog.js";
|
|
6
|
+
import { printGroups, printItems, runApplyDryRun, runApplyWizard, runGroupManager, runSetup } from "./wizard.js";
|
|
7
|
+
function usage() {
|
|
8
|
+
process.stdout.write(`copi — copy and organize reusable Pi coding-agent resources
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
copi setup
|
|
12
|
+
copi groups
|
|
13
|
+
copi list
|
|
14
|
+
copi apply <group> [target]
|
|
15
|
+
copi apply <group> [target] --dry-run
|
|
16
|
+
copi help
|
|
17
|
+
|
|
18
|
+
Commands:
|
|
19
|
+
setup Search explicitly selected locations and save copied resources
|
|
20
|
+
groups Manage groups interactively; without a TTY, list groups only
|
|
21
|
+
list List saved snapshots without modifying the library
|
|
22
|
+
apply <group> Review and apply one group to the current directory or target
|
|
23
|
+
apply --dry-run Show the masked plan without changing project files
|
|
24
|
+
help Show this help
|
|
25
|
+
|
|
26
|
+
Interactive changes require a TTY. Setup searches only locations explicitly selected
|
|
27
|
+
and confirmed in the wizard. Directories named auth are excluded entirely; copied
|
|
28
|
+
authentication-specific code may be missing from copied resources. Named authentication files and .env* paths,
|
|
29
|
+
and history/cache paths are filtered; secret detection is heuristic and cannot guarantee
|
|
30
|
+
completeness.`);
|
|
31
|
+
}
|
|
32
|
+
function requireHome(environment) {
|
|
33
|
+
const home = environment.HOME;
|
|
34
|
+
if (!home || !path.isAbsolute(home))
|
|
35
|
+
throw new Error("HOME must be set to an absolute path to use the copi library.");
|
|
36
|
+
return path.resolve(home);
|
|
37
|
+
}
|
|
38
|
+
function isInteractive() {
|
|
39
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
40
|
+
}
|
|
41
|
+
export async function main(args = process.argv.slice(2), environment = process.env, cwd = process.cwd()) {
|
|
42
|
+
const [command, ...rest] = args;
|
|
43
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
44
|
+
usage();
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
48
|
+
usage();
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
const homeDir = environment.HOME && path.isAbsolute(environment.HOME) ? path.resolve(environment.HOME) : "";
|
|
52
|
+
const libraryDir = path.join(requireHome(environment), ".copi");
|
|
53
|
+
const agentDir = environment.PI_CODING_AGENT_DIR
|
|
54
|
+
? path.resolve(cwd, environment.PI_CODING_AGENT_DIR)
|
|
55
|
+
: path.join(homeDir || requireHome(environment), ".pi", "agent");
|
|
56
|
+
const projectDir = path.resolve(cwd);
|
|
57
|
+
if (command === "setup") {
|
|
58
|
+
if (rest.length > 0)
|
|
59
|
+
throw new Error("setup does not accept positional arguments.");
|
|
60
|
+
if (!isInteractive())
|
|
61
|
+
throw new Error("setup requires an interactive TTY; no search was performed.");
|
|
62
|
+
await runSetup(libraryDir, { homeDir, agentDir, projectDir });
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
if (command === "groups") {
|
|
66
|
+
if (rest.length > 0)
|
|
67
|
+
throw new Error("groups does not accept positional arguments.");
|
|
68
|
+
if (isInteractive())
|
|
69
|
+
await runGroupManager(libraryDir);
|
|
70
|
+
else
|
|
71
|
+
printGroups(await loadCatalog(libraryDir));
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
if (command === "list") {
|
|
75
|
+
if (rest.length > 0)
|
|
76
|
+
throw new Error("list does not accept positional arguments.");
|
|
77
|
+
printItems(await listSnapshots(libraryDir));
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
if (command === "apply") {
|
|
81
|
+
const dryRun = rest.includes("--dry-run");
|
|
82
|
+
const positional = rest.filter((argument) => argument !== "--dry-run");
|
|
83
|
+
const [group, target, ...extra] = positional;
|
|
84
|
+
if (!group || extra.length > 0)
|
|
85
|
+
throw new Error("Usage: copi apply <group> [target] [--dry-run].");
|
|
86
|
+
const resolvedTarget = path.resolve(cwd, target ?? ".");
|
|
87
|
+
if (dryRun) {
|
|
88
|
+
await runApplyDryRun(libraryDir, group, resolvedTarget);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
if (!isInteractive())
|
|
92
|
+
throw new Error("apply requires an interactive TTY; use --dry-run for a read-only plan.");
|
|
93
|
+
await runApplyWizard(libraryDir, group, resolvedTarget);
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`Unknown command: ${command}. Run copi help for usage.`);
|
|
97
|
+
}
|
|
98
|
+
const invokedPath = process.argv[1] ? pathToFileURL(realpathSync(path.resolve(process.argv[1]))).href : "";
|
|
99
|
+
if (import.meta.url === invokedPath) {
|
|
100
|
+
try {
|
|
101
|
+
process.exitCode = await main();
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
const message = error instanceof Error ? error.message : "Unexpected error.";
|
|
105
|
+
console.error(`copi: ${message}`);
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DiscoveryResult } from "./types.ts";
|
|
2
|
+
export interface DiscoveryScopes {
|
|
3
|
+
home: boolean;
|
|
4
|
+
project: boolean;
|
|
5
|
+
}
|
|
6
|
+
export interface DiscoveryOptions {
|
|
7
|
+
homeDir: string;
|
|
8
|
+
agentDir: string;
|
|
9
|
+
projectDir: string;
|
|
10
|
+
scopes?: DiscoveryScopes;
|
|
11
|
+
}
|
|
12
|
+
export declare function discoverResources(options: DiscoveryOptions): Promise<DiscoveryResult>;
|