@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Papillon6814
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # copi
2
+
3
+ `copi` is a macOS CLI for copying supported Pi coding-agent resources into a private local library, grouping them by project use, reviewing a masked plan, and applying selected files to a project. It does not use AI classification.
4
+
5
+ ## Status and requirements
6
+
7
+ - Package: `@papillon6814/copi`; command: `copi`.
8
+ - macOS only; Node.js 22.19.0 or newer.
9
+ - The package is not published yet. Do not rely on the npm install command below until publication is announced.
10
+ - If published, install with `npm install --global @papillon6814/copi`.
11
+ - An older, unrelated tool may already provide a `copi` command. Check `command -v copi` before installing globally; resolve any command-name conflict deliberately.
12
+
13
+ For a local checkout, run `npm install --ignore-scripts`, `npm run build`, then `node dist/src/cli.js help`.
14
+
15
+ ## Commands
16
+
17
+ ```text
18
+ copi setup
19
+ copi groups
20
+ copi list
21
+ copi apply <group> [target]
22
+ copi apply <group> [target] --dry-run
23
+ copi help
24
+ ```
25
+
26
+ `setup` requires an interactive terminal. It asks which locations may be searched and asks for explicit authorization before searching. Selecting the entire `HOME` is broad: it can inspect supported resources throughout that directory, including Pi profiles and the configured Pi agent directory. Selecting the current project scopes discovery to that project. The wizard then lets you choose individual resources, review masked previews, assign each saved resource to one or more groups, and confirm before anything is copied into the library. Declining authorization performs no search.
27
+
28
+ `groups` lists groups in a non-interactive terminal; group changes require a TTY and are saved only after confirmation. `list` is read-only. `apply --dry-run` prints the planned paths, masked text diffs, and warnings without changing the target. A real apply requires a TTY and confirmation.
29
+
30
+ Groups contain resource IDs, not merged documents. The same saved resource may belong to multiple groups. `copi` copies files byte-for-byte; it does not split, rewrite, merge, or run resource contents. It does not install dependencies or apply global-only resources to a project. Review every proposed change.
31
+
32
+ ## Private storage and sensitive data
33
+
34
+ Snapshots and the catalog are stored under `~/.copi/` with owner-only directory/file permissions. `copi` copies the selected source into its library; it does not modify or delete the original. Snapshots remain available if the original is later removed. The copied content retains its original license and ownership; the MIT license for `copi` does not apply to imported resources.
35
+
36
+ Entire directories named `auth` are excluded; authentication-specific code may be missing, but other supported files in the resource are retained. Files named `auth.json`, `mcp-auth.json`, or `oauth.json`, `.env*` paths (including `.envrc` and `.env-production`), and repository/dependency/cache/history paths such as `.git`, `node_modules`, `.cache`, `.npm`, `cache`, `caches`, `history`, and `sessions` are excluded by the current resource filters. Do not use `copi` as a substitute for reviewing the files selected for copying.
37
+
38
+ Secret detection is heuristic and cannot guarantee completeness. Text previews and apply diffs are masked by default. Each detected potentially sensitive file requires its own explicit approval before saving and again before applying; other file contents may still contain secrets that detection misses. Review the masked preview and do not approve a file unless you have verified it yourself.
39
+
40
+ `copi` does not automatically transform resources for other AI tools, merge conflicting files, install dependencies, or apply global settings. Project placement of Pi user settings and MCP configuration preserves file contents; user-specific settings may not work in a project. `models.json` and `keybindings.json` are stored as reference-only resources and are not applied to projects.
41
+
42
+ Project subagent definitions require a compatible Pi setup; for Pi subagents, install and configure the `pi-subagents` package in the target environment yourself. `copi` does not install it.
43
+
44
+ ## Development checks
45
+
46
+ ```sh
47
+ npm run check
48
+ ```
49
+
50
+ This performs the strict TypeScript build, Node.js test suite, and npm package dry run.
@@ -0,0 +1,51 @@
1
+ import type { InspectionWarning } from "./types.ts";
2
+ export type ApplyPreview = {
3
+ kind: "text";
4
+ diff: string;
5
+ } | {
6
+ kind: "binary";
7
+ summary: string;
8
+ };
9
+ export interface ApplyFileOption {
10
+ itemId: string;
11
+ sensitive: boolean;
12
+ preview: ApplyPreview;
13
+ }
14
+ export interface ApplyFilePlan {
15
+ path: string;
16
+ itemId: string;
17
+ status: "new" | "identical" | "different" | "collision";
18
+ sensitive: boolean;
19
+ preview: ApplyPreview;
20
+ alternatives: ApplyFileOption[];
21
+ }
22
+ export interface ApplyCollision {
23
+ path: string;
24
+ itemIds: string[];
25
+ }
26
+ export interface ApplyPlan {
27
+ projectDir: string;
28
+ files: ApplyFilePlan[];
29
+ collisions: ApplyCollision[];
30
+ warnings: InspectionWarning[];
31
+ skippedItems: string[];
32
+ }
33
+ export interface ApplyDecisions {
34
+ collisions?: Record<string, string>;
35
+ existing?: Record<string, "keep" | "replace" | "abort">;
36
+ secretApprovals?: string[];
37
+ }
38
+ export interface ApplyBackup {
39
+ path: string;
40
+ backupPath: string;
41
+ mode: number;
42
+ }
43
+ export interface ApplyResult {
44
+ written: string[];
45
+ kept: string[];
46
+ skippedItems: string[];
47
+ backups: ApplyBackup[];
48
+ cancelled: boolean;
49
+ }
50
+ export declare function planApply(libraryDir: string, groupId: string, projectDir: string): Promise<ApplyPlan>;
51
+ export declare function executeApply(libraryDir: string, plan: ApplyPlan, decisions: ApplyDecisions): Promise<ApplyResult>;
@@ -0,0 +1,443 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, chmod, lstat, mkdir, open, readFile, realpath, rename, rm } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { createTwoFilesPatch } from "diff";
6
+ import { loadCatalog, readSnapshotFiles } from "./catalog.js";
7
+ import { maskSensitiveText } from "./inspect.js";
8
+ const maxInspectionBytes = 1_000_000;
9
+ const excludedNames = new Set([".git", ".cache", ".npm", "node_modules", "cache", "caches", "history", "sessions", "auth"]);
10
+ const planState = new WeakMap();
11
+ function isWithin(root, target) {
12
+ const relative = path.relative(root, target);
13
+ return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
14
+ }
15
+ function safeRelativePath(value) {
16
+ if (!value || value.includes(String.fromCharCode(92)) || path.posix.isAbsolute(value))
17
+ return false;
18
+ const parts = value.split("/");
19
+ return parts.every((part) => part.length > 0 && part !== "." && part !== "..") && path.posix.normalize(value) === value;
20
+ }
21
+ function isExcludedSnapshotPath(value) {
22
+ return value.split("/").some((part) => {
23
+ const lower = part.toLowerCase();
24
+ return excludedNames.has(lower) || lower === "auth.json" || lower === "mcp-auth.json" || lower === "oauth.json" || lower.startsWith(".env");
25
+ });
26
+ }
27
+ function hasRelativeResourceReferences(text) {
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(text);
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
36
+ return false;
37
+ const settings = parsed;
38
+ const subagentsValue = settings.subagents;
39
+ const subagents = typeof subagentsValue === "object" && subagentsValue !== null && !Array.isArray(subagentsValue)
40
+ ? subagentsValue
41
+ : undefined;
42
+ const hasRelativeSelections = (value) => Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry.trim().length > 0 && !path.isAbsolute(entry.trim()) && !entry.startsWith("$") && !entry.startsWith("~/"));
43
+ const packageSelections = Array.isArray(settings.packages) ? settings.packages : [];
44
+ const hasRelativePackage = packageSelections.some((entry) => {
45
+ const selection = typeof entry === "object" && entry !== null && !Array.isArray(entry)
46
+ ? entry
47
+ : undefined;
48
+ const source = typeof entry === "string" ? entry : selection?.source;
49
+ return typeof source === "string" && (source.startsWith("./") || source.startsWith("../") || source.startsWith("file:"));
50
+ });
51
+ return hasRelativeSelections(settings.skills) || hasRelativeSelections(settings.agentScanDirs) ||
52
+ hasRelativeSelections(subagents?.agentScanDirs) || hasRelativePackage;
53
+ }
54
+ function sha256(bytes) {
55
+ return createHash("sha256").update(bytes).digest("hex");
56
+ }
57
+ function textFrom(bytes) {
58
+ if (bytes.length > maxInspectionBytes || bytes.includes(0))
59
+ return undefined;
60
+ try {
61
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
67
+ function previewFor(pathname, candidate, existing) {
68
+ const nextText = textFrom(candidate.bytes);
69
+ const oldText = existing ? textFrom(existing.bytes) : "";
70
+ if (nextText === undefined || oldText === undefined) {
71
+ const oldSize = existing ? `${existing.bytes.length} bytes` : "no existing file";
72
+ return { kind: "binary", summary: `${oldSize}; snapshot ${candidate.bytes.length} bytes.` };
73
+ }
74
+ return {
75
+ kind: "text",
76
+ diff: createTwoFilesPatch(existing ? pathname : "(new)", pathname, maskSensitiveText(oldText), maskSensitiveText(nextText)),
77
+ };
78
+ }
79
+ function addWarning(warnings, code, message, warningPath) {
80
+ if (warnings.some((warning) => warning.code === code && warning.path === warningPath))
81
+ return;
82
+ warnings.push(warningPath === undefined ? { code, message } : { code, message, path: warningPath });
83
+ }
84
+ function inspectSnapshotText(item, file, destination, warnings) {
85
+ const text = textFrom(file.bytes);
86
+ if (text === undefined) {
87
+ if (file.bytes.length > maxInspectionBytes)
88
+ addWarning(warnings, "large-file-skipped", "Content inspection is limited to files no larger than 1 MB.", destination);
89
+ return false;
90
+ }
91
+ addWarning(warnings, "secret-detection-limited", "Secret detection is heuristic and cannot guarantee completeness.");
92
+ const compactText = text.replaceAll(" ", "").replaceAll("\t", "").replaceAll("\r", "").replaceAll("\n", "");
93
+ const namedValue = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|authorization)["']?[:=]["']?[A-Za-z0-9_./+=:-]{8,}/i;
94
+ const recognizableToken = /(?:sk_live_[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{16,})/;
95
+ const bearer = /Bearer[ ]+[A-Za-z0-9._~+/=-]{12,}/i;
96
+ const sensitive = namedValue.test(compactText) || recognizableToken.test(text) || bearer.test(text);
97
+ if (sensitive)
98
+ addWarning(warnings, "sensitive-content", "This file may contain a secret; approve this file explicitly before applying it.", destination);
99
+ if (/[$][{]?[A-Z_][A-Z0-9_]*[}]?/.test(text))
100
+ addWarning(warnings, "environment-reference", "This resource refers to environment variables that may need to be configured separately.", destination);
101
+ if (text.includes("/Users/") || text.includes("/home/") || text.includes("~/"))
102
+ addWarning(warnings, "absolute-path", "This resource contains a path that may not exist in another project.", destination);
103
+ if (path.basename(file.path) === "package.json") {
104
+ try {
105
+ const manifest = JSON.parse(text);
106
+ const hasDependencies = [manifest.dependencies, manifest.devDependencies, manifest.peerDependencies].some((value) => typeof value === "object" && value !== null && Object.keys(value).length > 0);
107
+ if (hasDependencies)
108
+ addWarning(warnings, "dependency-requirement", "This resource declares dependencies; copi does not install them automatically.", destination);
109
+ }
110
+ catch {
111
+ addWarning(warnings, "invalid-package-manifest", "This package manifest could not be parsed for dependency warnings.", destination);
112
+ }
113
+ }
114
+ if (item.kind === "pi-settings" && ["\"defaultProjectTrust\"", "\"httpProxy\"", "\"cacheWarming\""].some((key) => text.includes(key))) {
115
+ addWarning(warnings, "project-setting-restricted", "This Pi setting is user-only and may not work in a project; copi preserves its bytes without rewriting it.", destination);
116
+ }
117
+ if (item.kind === "pi-settings" && hasRelativeResourceReferences(text)) {
118
+ addWarning(warnings, "relative-resource-reference", "Pi resource references are relative to the settings file and may resolve differently when applied to a project.", destination);
119
+ }
120
+ return sensitive;
121
+ }
122
+ async function maybeLstat(target) {
123
+ try {
124
+ return await lstat(target);
125
+ }
126
+ catch (error) {
127
+ if (error.code === "ENOENT")
128
+ return undefined;
129
+ throw error;
130
+ }
131
+ }
132
+ async function safeProjectRoot(projectDir) {
133
+ const requested = path.resolve(projectDir);
134
+ const rootStat = await lstat(requested);
135
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
136
+ throw new Error("Project destination must be a real directory, not a symbolic link.");
137
+ return { requested, canonical: await realpath(requested) };
138
+ }
139
+ async function inspectDestination(root, relativePath) {
140
+ if (!safeRelativePath(relativePath))
141
+ throw new Error("Unsafe relative path in apply plan.");
142
+ let current = root;
143
+ const parts = relativePath.split("/");
144
+ for (let index = 0; index < parts.length; index += 1) {
145
+ current = path.join(current, parts[index]);
146
+ const stat = await maybeLstat(current);
147
+ if (!stat)
148
+ return undefined;
149
+ if (stat.isSymbolicLink())
150
+ throw new Error(`Destination contains a symbolic link: ${relativePath}`);
151
+ if (index < parts.length - 1 && !stat.isDirectory())
152
+ throw new Error(`Destination ancestor is not a directory: ${relativePath}`);
153
+ if (index === parts.length - 1) {
154
+ if (!stat.isFile())
155
+ throw new Error(`Destination is not a regular file: ${relativePath}`);
156
+ const bytes = await readFile(current);
157
+ return { bytes, mode: stat.mode & 0o777, sha256: sha256(bytes) };
158
+ }
159
+ }
160
+ return undefined;
161
+ }
162
+ function targetPath(item, filePath, fileCount) {
163
+ if (item.destination.kind !== "project")
164
+ throw new Error("A global-only resource cannot be applied to a project.");
165
+ if (item.kind === "skill")
166
+ return path.posix.join(item.destination.path, filePath);
167
+ if (fileCount !== 1 || filePath.includes("/"))
168
+ throw new Error(`A file resource has an invalid snapshot layout: ${item.name}`);
169
+ return item.destination.path;
170
+ }
171
+ function candidateKey(candidate) {
172
+ return `${candidate.sha256}:${candidate.mode}`;
173
+ }
174
+ export async function planApply(libraryDir, groupId, projectDir) {
175
+ const catalog = await loadCatalog(libraryDir);
176
+ const group = catalog.groups.find((item) => item.id === groupId);
177
+ if (!group)
178
+ throw new Error(`Unknown group: ${groupId}`);
179
+ const storeRoot = await realpath(libraryDir);
180
+ const project = await safeProjectRoot(projectDir);
181
+ const warnings = [];
182
+ const skippedItems = [];
183
+ const candidates = new Map();
184
+ const existing = new Map();
185
+ for (const itemId of group.itemIds) {
186
+ const item = catalog.items.find((entry) => entry.id === itemId);
187
+ if (!item)
188
+ throw new Error(`Group references an unavailable item: ${itemId}`);
189
+ if (item.destination.kind === "global-only") {
190
+ skippedItems.push(item.id);
191
+ addWarning(warnings, "global-only", "This resource is saved for reference and cannot be applied to a project.", item.name);
192
+ continue;
193
+ }
194
+ const snapshotFiles = await readSnapshotFiles(storeRoot, item);
195
+ for (const snapshot of snapshotFiles) {
196
+ if (isExcludedSnapshotPath(snapshot.path)) {
197
+ addWarning(warnings, "private-path-skipped", "Authentication, environment, history, and cache files are not applied.", snapshot.path);
198
+ continue;
199
+ }
200
+ const destination = targetPath(item, snapshot.path, snapshotFiles.length);
201
+ const relativePath = path.posix.normalize(destination);
202
+ if (!safeRelativePath(relativePath))
203
+ throw new Error(`Unsafe project destination in catalog: ${relativePath}`);
204
+ const fileCandidates = candidates.get(relativePath) ?? [];
205
+ const candidate = {
206
+ itemId: item.id,
207
+ bytes: snapshot.bytes,
208
+ mode: snapshot.mode,
209
+ sha256: snapshot.sha256,
210
+ sensitive: inspectSnapshotText(item, snapshot, relativePath, warnings),
211
+ preview: { kind: "binary", summary: "" },
212
+ };
213
+ fileCandidates.push(candidate);
214
+ candidates.set(relativePath, fileCandidates);
215
+ if (!existing.has(relativePath))
216
+ existing.set(relativePath, await inspectDestination(project.canonical, relativePath));
217
+ }
218
+ }
219
+ const files = [];
220
+ const collisions = [];
221
+ for (const [relativePath, allCandidates] of candidates) {
222
+ const uniqueCandidates = [...new Map(allCandidates.map((candidate) => [candidateKey(candidate), candidate])).values()];
223
+ const current = existing.get(relativePath);
224
+ for (const candidate of uniqueCandidates)
225
+ candidate.preview = previewFor(relativePath, candidate, current);
226
+ const collision = uniqueCandidates.length > 1;
227
+ if (collision)
228
+ collisions.push({ path: relativePath, itemIds: uniqueCandidates.map((candidate) => candidate.itemId) });
229
+ const selected = uniqueCandidates[0];
230
+ const identical = current !== undefined && current.sha256 === selected.sha256 && current.mode === selected.mode;
231
+ let status;
232
+ if (collision)
233
+ status = "collision";
234
+ else if (identical)
235
+ status = "identical";
236
+ else if (current)
237
+ status = "different";
238
+ else
239
+ status = "new";
240
+ files.push({
241
+ path: relativePath,
242
+ itemId: selected.itemId,
243
+ status,
244
+ sensitive: selected.sensitive,
245
+ preview: selected.preview,
246
+ alternatives: uniqueCandidates.map(({ itemId, sensitive, preview }) => ({ itemId, sensitive, preview })),
247
+ });
248
+ }
249
+ const plan = { projectDir: project.canonical, files, collisions, warnings, skippedItems };
250
+ planState.set(plan, {
251
+ libraryRoot: storeRoot,
252
+ projectRoot: project.canonical,
253
+ requestedProjectDir: project.requested,
254
+ candidates: new Map([...candidates].map(([key, values]) => [key, [...new Map(values.map((candidate) => [candidateKey(candidate), candidate])).values()]])),
255
+ existing,
256
+ skippedItems: [...skippedItems],
257
+ });
258
+ return plan;
259
+ }
260
+ async function assertWritableParent(root, relativePath) {
261
+ const parts = relativePath.split("/");
262
+ let current = root;
263
+ let parentForWrite = root;
264
+ for (const part of parts.slice(0, -1)) {
265
+ current = path.join(current, part);
266
+ const stat = await maybeLstat(current);
267
+ if (!stat)
268
+ break;
269
+ if (stat.isSymbolicLink() || !stat.isDirectory())
270
+ throw new Error(`Unsafe destination ancestor: ${relativePath}`);
271
+ await access(current, constants.X_OK);
272
+ parentForWrite = current;
273
+ }
274
+ const stat = await lstat(parentForWrite);
275
+ if ((stat.mode & 0o222) === 0)
276
+ throw new Error(`Destination parent is not writable: ${relativePath}`);
277
+ await access(parentForWrite, constants.W_OK);
278
+ }
279
+ async function ensureParentDirectories(root, relativePath) {
280
+ let current = root;
281
+ for (const part of relativePath.split("/").slice(0, -1)) {
282
+ current = path.join(current, part);
283
+ const stat = await maybeLstat(current);
284
+ if (!stat) {
285
+ await mkdir(current, { mode: 0o755 });
286
+ }
287
+ else if (stat.isSymbolicLink() || !stat.isDirectory()) {
288
+ throw new Error(`Unsafe destination ancestor: ${relativePath}`);
289
+ }
290
+ const verified = await lstat(current);
291
+ if (verified.isSymbolicLink() || !verified.isDirectory())
292
+ throw new Error(`Unsafe destination ancestor: ${relativePath}`);
293
+ }
294
+ const canonicalParent = await realpath(current);
295
+ if (!isWithin(root, canonicalParent))
296
+ throw new Error(`Destination escapes the project through a symbolic link: ${relativePath}`);
297
+ return current;
298
+ }
299
+ async function createBackupSet(storeRoot, operations) {
300
+ const replacing = operations.filter((operation) => operation.before !== undefined);
301
+ if (replacing.length === 0)
302
+ return [];
303
+ const backupRoot = path.join(storeRoot, "backups");
304
+ const existingRoot = await maybeLstat(backupRoot);
305
+ if (!existingRoot)
306
+ await mkdir(backupRoot, { mode: 0o700 });
307
+ const checkedRoot = await lstat(backupRoot);
308
+ if (checkedRoot.isSymbolicLink() || !checkedRoot.isDirectory())
309
+ throw new Error("Copi backup path is not a safe directory.");
310
+ await chmod(backupRoot, 0o700);
311
+ const transactionRoot = path.join(backupRoot, randomUUID());
312
+ await mkdir(transactionRoot, { mode: 0o700 });
313
+ const backups = [];
314
+ const manifest = [];
315
+ try {
316
+ for (const [index, operation] of replacing.entries()) {
317
+ const before = operation.before;
318
+ const filename = `${String(index).padStart(4, "0")}.bin`;
319
+ const backupPath = path.join(transactionRoot, filename);
320
+ const handle = await open(backupPath, "wx", 0o600);
321
+ try {
322
+ await handle.writeFile(before.bytes);
323
+ await handle.sync();
324
+ }
325
+ finally {
326
+ await handle.close();
327
+ }
328
+ await chmod(backupPath, 0o600);
329
+ backups.push({ path: operation.path, backupPath, mode: before.mode });
330
+ manifest.push({ path: operation.path, backupFile: filename, mode: before.mode });
331
+ }
332
+ const manifestHandle = await open(path.join(transactionRoot, "manifest.json"), "wx", 0o600);
333
+ try {
334
+ await manifestHandle.writeFile(`${JSON.stringify(manifest, null, 2)}${String.fromCharCode(10)}`, "utf8");
335
+ await manifestHandle.sync();
336
+ }
337
+ finally {
338
+ await manifestHandle.close();
339
+ }
340
+ return backups;
341
+ }
342
+ catch (error) {
343
+ await rm(transactionRoot, { recursive: true, force: true });
344
+ throw error;
345
+ }
346
+ }
347
+ async function writeSnapshotFile(root, relativePath, candidate) {
348
+ const target = path.resolve(root, ...relativePath.split("/"));
349
+ if (!isWithin(root, target))
350
+ throw new Error(`Destination escapes the project: ${relativePath}`);
351
+ const parent = await ensureParentDirectories(root, relativePath);
352
+ const temporary = path.join(parent, `.${path.basename(target)}.${randomUUID()}.copi-tmp`);
353
+ const handle = await open(temporary, "wx", 0o600);
354
+ try {
355
+ await handle.writeFile(candidate.bytes);
356
+ await handle.chmod(candidate.mode);
357
+ await handle.sync();
358
+ }
359
+ catch (error) {
360
+ await rm(temporary, { force: true });
361
+ throw error;
362
+ }
363
+ finally {
364
+ await handle.close();
365
+ }
366
+ try {
367
+ await rename(temporary, target);
368
+ }
369
+ catch (error) {
370
+ await rm(temporary, { force: true });
371
+ throw error;
372
+ }
373
+ }
374
+ export async function executeApply(libraryDir, plan, decisions) {
375
+ const internal = planState.get(plan);
376
+ if (!internal)
377
+ throw new Error("Apply plan is not a verified plan created by planApply.");
378
+ if (await realpath(libraryDir) !== internal.libraryRoot)
379
+ throw new Error("Apply plan belongs to a different copi library.");
380
+ if (await realpath(internal.requestedProjectDir) !== internal.projectRoot)
381
+ throw new Error("Project destination changed after planning.");
382
+ const operations = [];
383
+ const kept = [];
384
+ let cancelled = false;
385
+ for (const [relativePath, options] of internal.candidates) {
386
+ let candidate = options[0];
387
+ if (!candidate)
388
+ throw new Error(`Apply plan lost snapshot bytes for ${relativePath}.`);
389
+ if (options.length > 1) {
390
+ const itemId = decisions.collisions?.[relativePath];
391
+ if (!itemId)
392
+ throw new Error(`A collision selection is required for ${relativePath}.`);
393
+ const selected = options.find((option) => option.itemId === itemId);
394
+ if (!selected)
395
+ throw new Error(`Invalid collision selection for ${relativePath}.`);
396
+ candidate = selected;
397
+ }
398
+ const previous = internal.existing.get(relativePath);
399
+ if (previous && previous.sha256 === candidate.sha256 && previous.mode === candidate.mode)
400
+ continue;
401
+ if (previous) {
402
+ const choice = decisions.existing?.[relativePath];
403
+ if (choice !== "keep" && choice !== "replace" && choice !== "abort")
404
+ throw new Error(`Choose keep, replace, or abort for existing file ${relativePath}.`);
405
+ if (choice === "abort") {
406
+ cancelled = true;
407
+ continue;
408
+ }
409
+ if (choice === "keep") {
410
+ kept.push(relativePath);
411
+ continue;
412
+ }
413
+ }
414
+ if (candidate.sensitive && !decisions.secretApprovals?.includes(relativePath))
415
+ throw new Error(`Explicit secret approval is required for ${relativePath}.`);
416
+ if (previous)
417
+ operations.push({ path: relativePath, candidate, before: previous });
418
+ else
419
+ operations.push({ path: relativePath, candidate });
420
+ }
421
+ if (cancelled)
422
+ return { written: [], kept: [], skippedItems: [...internal.skippedItems], backups: [], cancelled: true };
423
+ for (const operation of operations) {
424
+ const current = await inspectDestination(internal.projectRoot, operation.path);
425
+ const expected = internal.existing.get(operation.path);
426
+ if ((current?.sha256 ?? undefined) !== (expected?.sha256 ?? undefined) || (current?.mode ?? undefined) !== (expected?.mode ?? undefined)) {
427
+ throw new Error(`Destination changed after planning: ${operation.path}`);
428
+ }
429
+ await assertWritableParent(internal.projectRoot, operation.path);
430
+ }
431
+ const backups = await createBackupSet(internal.libraryRoot, operations);
432
+ const written = [];
433
+ for (const operation of operations) {
434
+ const current = await inspectDestination(internal.projectRoot, operation.path);
435
+ const expected = internal.existing.get(operation.path);
436
+ if ((current?.sha256 ?? undefined) !== (expected?.sha256 ?? undefined) || (current?.mode ?? undefined) !== (expected?.mode ?? undefined)) {
437
+ throw new Error(`Destination changed during apply: ${operation.path}`);
438
+ }
439
+ await writeSnapshotFile(internal.projectRoot, operation.path, operation.candidate);
440
+ written.push(operation.path);
441
+ }
442
+ return { written, kept, skippedItems: [...internal.skippedItems], backups, cancelled: false };
443
+ }
@@ -0,0 +1,39 @@
1
+ import type { Catalog, InspectionResult, ResourceCandidate, SavedItem, SnapshotFileContents } from "./types.ts";
2
+ export interface ImportOptions {
3
+ confirmed: boolean;
4
+ groupIds: string[];
5
+ }
6
+ declare const preparedResourceBrand: unique symbol;
7
+ export interface PreparedResource {
8
+ readonly [preparedResourceBrand]: true;
9
+ }
10
+ export interface PreparedFile {
11
+ readonly path: string;
12
+ readonly mode: number;
13
+ readonly bytes: Buffer;
14
+ }
15
+ export interface PreparedResourceContents {
16
+ readonly candidate: ResourceCandidate;
17
+ readonly files: readonly PreparedFile[];
18
+ readonly excludedPaths: readonly string[];
19
+ }
20
+ export interface PreparedInspection {
21
+ readonly warnings: Readonly<InspectionResult["warnings"]>;
22
+ readonly sensitivePaths: readonly string[];
23
+ readonly excludedPaths: readonly string[];
24
+ readonly preparedResource: PreparedResource;
25
+ }
26
+ export interface PreparedImportOptions extends ImportOptions {
27
+ inspection: PreparedInspection;
28
+ approvedSensitivePaths: readonly string[];
29
+ }
30
+ export declare function newCatalog(): Catalog;
31
+ export declare function prepareResource(candidate: ResourceCandidate): Promise<PreparedResource>;
32
+ export declare function readPreparedResource(prepared: PreparedResource): PreparedResourceContents;
33
+ export declare function loadCatalog(libraryDir: string): Promise<Catalog>;
34
+ export declare function saveCatalog(libraryDir: string, catalog: Catalog): Promise<void>;
35
+ export declare function importPreparedResource(libraryDir: string, prepared: PreparedResource, options: PreparedImportOptions): Promise<SavedItem | null>;
36
+ export declare function importResource(libraryDir: string, candidate: ResourceCandidate, options: ImportOptions): Promise<SavedItem | null>;
37
+ export declare function readSnapshotFiles(libraryDir: string, item: SavedItem): Promise<SnapshotFileContents[]>;
38
+ export declare function listSnapshots(libraryDir: string): Promise<SavedItem[]>;
39
+ export {};