@papillon6814/copi 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE.unicode +39 -0
  2. package/README.md +57 -5
  3. package/dist/native/rename-exclusive-arm64 +0 -0
  4. package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
  5. package/dist/src/apply.d.ts +9 -0
  6. package/dist/src/apply.js +110 -9
  7. package/dist/src/catalog.d.ts +21 -4
  8. package/dist/src/catalog.js +265 -133
  9. package/dist/src/cli.d.ts +2 -1
  10. package/dist/src/cli.js +73 -3
  11. package/dist/src/groups.js +8 -3
  12. package/dist/src/inspect.d.ts +3 -1
  13. package/dist/src/inspect.js +16 -6
  14. package/dist/src/instruction-reference.d.ts +6 -0
  15. package/dist/src/instruction-reference.js +21 -0
  16. package/dist/src/manage.d.ts +2 -0
  17. package/dist/src/manage.js +329 -0
  18. package/dist/src/migrate.d.ts +25 -0
  19. package/dist/src/migrate.js +171 -0
  20. package/dist/src/migration-backup.d.ts +22 -0
  21. package/dist/src/migration-backup.js +403 -0
  22. package/dist/src/migration-inventory.d.ts +9 -0
  23. package/dist/src/migration-inventory.js +237 -0
  24. package/dist/src/migration-resolution.d.ts +28 -0
  25. package/dist/src/migration-resolution.js +1618 -0
  26. package/dist/src/migration.d.ts +49 -0
  27. package/dist/src/migration.js +1004 -0
  28. package/dist/src/names.d.ts +11 -0
  29. package/dist/src/names.js +40 -0
  30. package/dist/src/native-placement.d.ts +16 -0
  31. package/dist/src/native-placement.js +557 -0
  32. package/dist/src/refresh.d.ts +20 -0
  33. package/dist/src/refresh.js +143 -0
  34. package/dist/src/resource-mutations.d.ts +45 -0
  35. package/dist/src/resource-mutations.js +225 -0
  36. package/dist/src/source-content.d.ts +4 -0
  37. package/dist/src/source-content.js +208 -0
  38. package/dist/src/storage-journal.d.ts +111 -0
  39. package/dist/src/storage-journal.js +613 -0
  40. package/dist/src/storage-root-lease.d.ts +7 -0
  41. package/dist/src/storage-root-lease.js +92 -0
  42. package/dist/src/storage-transaction.d.ts +100 -0
  43. package/dist/src/storage-transaction.js +3951 -0
  44. package/dist/src/terminal-editor.d.ts +8 -0
  45. package/dist/src/terminal-editor.js +391 -0
  46. package/dist/src/types.d.ts +18 -2
  47. package/dist/src/unicode-case-folding-data.d.ts +1 -0
  48. package/dist/src/unicode-case-folding-data.js +1596 -0
  49. package/dist/src/unicode-case-folding.d.ts +2 -0
  50. package/dist/src/unicode-case-folding.js +9 -0
  51. package/dist/src/wizard.d.ts +12 -3
  52. package/dist/src/wizard.js +287 -19
  53. package/package.json +5 -3
@@ -0,0 +1,143 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { types } from "node:util";
4
+ import { prepareResource, readPreparedResource, withSavedLibrary } from "./catalog.js";
5
+ import { discoverResources } from "./discovery.js";
6
+ import { inspectPreparedResource } from "./inspect.js";
7
+ import { commitSavedChange, prepareSavedReplacement, readSavedChange } from "./resource-mutations.js";
8
+ const refreshes = new WeakMap();
9
+ // Only an actual declared-package discovery result can authorize node_modules, not caller strings.
10
+ const discoveredOrigins = new WeakMap();
11
+ const candidateFields = ["id", "kind", "source", "scope", "sourceRoot", "sourcePath", "name", "destination"];
12
+ // Capture only plain own data, without executing caller accessors, Proxy traps,
13
+ // toJSON or coercion. The same detached capture is checked and later read.
14
+ function ownData(value) {
15
+ if (typeof value !== "object" || value === null || types.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype)
16
+ throw new Error("Refresh candidate must contain plain own data without proxies or custom prototypes.");
17
+ const descriptors = Object.getOwnPropertyDescriptors(value);
18
+ const captured = {};
19
+ for (const key of Reflect.ownKeys(descriptors)) {
20
+ const descriptor = typeof key === "string" ? descriptors[key] : undefined;
21
+ if (!descriptor || !descriptor.enumerable || !("value" in descriptor))
22
+ throw new Error("Refresh candidate must not contain accessors, symbols or non-enumerable fields.");
23
+ Object.defineProperty(captured, key, { value: descriptor.value, enumerable: true });
24
+ }
25
+ return captured;
26
+ }
27
+ function captureCandidate(source) {
28
+ const values = ownData(source);
29
+ if (Object.keys(values).length !== candidateFields.length || candidateFields.some(key => !Object.hasOwn(values, key)) || candidateFields.some(key => key !== "destination" && typeof values[key] !== "string"))
30
+ throw new Error("Invalid refresh candidate fields.");
31
+ const destination = ownData(values.destination);
32
+ let fixedDestination;
33
+ if (destination.kind === "global-only" && Object.keys(destination).length === 1)
34
+ fixedDestination = { kind: "global-only" };
35
+ else if (destination.kind === "project" && Object.keys(destination).length === 2 && typeof destination.path === "string")
36
+ fixedDestination = { kind: "project", path: destination.path };
37
+ else
38
+ throw new Error("Invalid refresh candidate destination fields.");
39
+ return {
40
+ id: values.id, kind: values.kind, source: values.source,
41
+ scope: values.scope, sourceRoot: values.sourceRoot,
42
+ sourcePath: values.sourcePath, name: values.name, destination: fixedDestination,
43
+ };
44
+ }
45
+ function matchesOrigin(fixed, origin) {
46
+ return candidateFields.every(key => key === "destination" || fixed[key] === origin[key]) &&
47
+ fixed.destination.kind === origin.destination.kind &&
48
+ (fixed.destination.kind !== "project" || (origin.destination.kind === "project" && fixed.destination.path === origin.destination.path));
49
+ }
50
+ const excluded = new Set(["auth", "auth.json", "mcp-auth.json", "oauth.json", "cache", "caches", ".cache", ".npm", ".git", "history", "sessions"]);
51
+ function within(root, target) {
52
+ const relative = path.relative(root, target);
53
+ return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
54
+ }
55
+ /** Metadata checks only. Never follows arbitrary links, including links above the selected boundary. */
56
+ export async function resolveRefreshPath(requested) {
57
+ if (!path.isAbsolute(requested) || requested.includes("\0") || requested.includes("\\"))
58
+ throw new Error("Select an absolute source path without unsafe path characters.");
59
+ let physical = path.resolve(requested);
60
+ for (const [alias, target] of [["/tmp", "/private/tmp"], ["/var", "/private/var"]]) {
61
+ if (physical === alias || physical.startsWith(`${alias}/`)) {
62
+ const stat = await lstat(alias);
63
+ if (stat.isSymbolicLink()) {
64
+ if (process.platform !== "darwin" || await realpath(alias) !== target)
65
+ throw new Error("Unsafe source ancestor symbolic link.");
66
+ physical = target + physical.slice(alias.length);
67
+ }
68
+ }
69
+ }
70
+ let current = path.parse(physical).root;
71
+ for (const component of physical.slice(current.length).split(path.sep).filter(Boolean)) {
72
+ current = path.join(current, component);
73
+ if ((await lstat(current)).isSymbolicLink())
74
+ throw new Error("Symbolic links in source or source ancestors are not followed.");
75
+ }
76
+ return physical;
77
+ }
78
+ /** Call only after explicit discovery-root/scope consent; no implicit HOME or parent search. */
79
+ export async function discoverRefreshOrigin(item, options) {
80
+ if (!options.scopes || (!options.scopes.home && !options.scopes.project))
81
+ throw new Error("Refresh origin discovery requires explicitly selected search scopes.");
82
+ const original = await resolveRefreshPath(item.sourcePath);
83
+ const discovery = await discoverResources(options);
84
+ const candidate = discovery.candidates.find(entry => entry.kind === item.kind && entry.sourcePath === original);
85
+ if (!candidate)
86
+ return undefined;
87
+ // Discovery validates declarations, authorized package roots and exclusions. Manual paths never inherit this authority.
88
+ if (candidate.scope === "package" && candidate.source.startsWith("package:")) {
89
+ const evidence = captureCandidate(candidate);
90
+ Object.freeze(evidence.destination);
91
+ discoveredOrigins.set(candidate, Object.freeze(evidence));
92
+ }
93
+ return candidate;
94
+ }
95
+ function stateOf(prepared) {
96
+ const state = refreshes.get(prepared);
97
+ if (!state)
98
+ throw new Error("Invalid or unavailable prepared refresh token.");
99
+ return state;
100
+ }
101
+ export async function prepareRefresh(libraryDir, itemId, source) {
102
+ const fixed = captureCandidate(source); // One capture, before any await.
103
+ const origin = discoveredOrigins.get(source);
104
+ if (origin && !matchesOrigin(fixed, origin))
105
+ throw new Error("Declared refresh origin changed after discovery; rediscover and review before reading.");
106
+ const registeredPackage = origin !== undefined;
107
+ const before = await withSavedLibrary(libraryDir, async (state) => ({ catalog: structuredClone(state.catalog), revision: state.revision }));
108
+ const item = before.catalog.items.find(entry => entry.id === itemId);
109
+ if (!item)
110
+ throw new Error("Unknown saved resource.");
111
+ if (fixed.kind !== item.kind)
112
+ throw new Error("Refresh source must have the same resource kind.");
113
+ fixed.sourcePath = await resolveRefreshPath(fixed.sourcePath);
114
+ fixed.sourceRoot = await resolveRefreshPath(fixed.sourceRoot);
115
+ if (!within(fixed.sourceRoot, fixed.sourcePath))
116
+ throw new Error("Refresh source escapes its physical boundary.");
117
+ for (const component of fixed.sourcePath.split(path.sep)) {
118
+ const lower = component.toLowerCase();
119
+ if (excluded.has(lower) || lower.startsWith(".env") || (lower === "node_modules" && !registeredPackage))
120
+ throw new Error("Selected source is in a private/excluded ancestor; choose a non-excluded path or explicitly rediscover its declared package origin.");
121
+ }
122
+ const stat = await lstat(fixed.sourcePath);
123
+ if (fixed.kind === "skill" ? !stat.isDirectory() : !stat.isFile())
124
+ throw new Error("Refresh source shape must match: skill directory, or regular file for other resource kinds.");
125
+ if ((fixed.kind === "model-config" || fixed.kind === "keybindings") && fixed.destination.kind !== "global-only")
126
+ throw new Error("Reference-only source kind cannot become project-applicable.");
127
+ const preparedSource = await prepareResource(fixed);
128
+ await inspectPreparedResource(preparedSource);
129
+ const change = await prepareSavedReplacement(libraryDir, itemId, preparedSource);
130
+ // Ensure the old state captured here is the state reviewed by the replacement kernel.
131
+ // Sessions never escape their callbacks or remain open across UI prompts.
132
+ await withSavedLibrary(libraryDir, async (state) => { await state.session.assertRevision(before.revision); });
133
+ const prepared = Object.freeze({ reviewId: change.reviewId });
134
+ refreshes.set(prepared, { change, originalCatalog: before.catalog, source: readPreparedResource(preparedSource).candidate });
135
+ return prepared;
136
+ }
137
+ export function readRefresh(prepared) {
138
+ const state = stateOf(prepared);
139
+ return { ...readSavedChange(state.change), originalCatalog: structuredClone(state.originalCatalog), source: structuredClone(state.source) };
140
+ }
141
+ export async function commitRefresh(prepared, approval) {
142
+ return commitSavedChange(stateOf(prepared).change, approval);
143
+ }
@@ -0,0 +1,45 @@
1
+ import { type PreparedResource } from "./catalog.ts";
2
+ import type { InspectionWarning, ResourceDestination, SavedItem, SnapshotFileContents } from "./types.ts";
3
+ declare const editBrand: unique symbol;
4
+ declare const changeBrand: unique symbol;
5
+ export interface PreparedSavedEdit {
6
+ readonly [editBrand]: true;
7
+ }
8
+ export interface PreparedSavedChange {
9
+ readonly [changeBrand]: true;
10
+ readonly reviewId: string;
11
+ }
12
+ export interface ChangeApproval {
13
+ reviewId: string;
14
+ confirmed: boolean;
15
+ approvedSensitivePaths: readonly string[];
16
+ }
17
+ export interface SavedEditView {
18
+ readonly item: SavedItem;
19
+ readonly file: SnapshotFileContents;
20
+ readonly sensitivePaths: readonly string[];
21
+ readonly warnings: readonly InspectionWarning[];
22
+ }
23
+ export interface SavedFileDiff {
24
+ readonly path: string;
25
+ readonly bytesChanged: boolean;
26
+ readonly diff: string;
27
+ readonly summary: string;
28
+ }
29
+ export interface SavedChangeView {
30
+ readonly reviewId: string;
31
+ readonly item: SavedItem;
32
+ readonly diffs: readonly SavedFileDiff[];
33
+ readonly sensitivePaths: readonly string[];
34
+ readonly excludedPaths: readonly string[];
35
+ readonly warnings: readonly InspectionWarning[];
36
+ readonly bytesChanged: boolean;
37
+ }
38
+ export declare function prepareSavedEdit(libraryDir: string, itemId: string, filePath: string): Promise<PreparedSavedEdit>;
39
+ export declare function prepareSavedChange(edit: PreparedSavedEdit, bytes: Buffer): Promise<PreparedSavedChange>;
40
+ export declare function readSavedEdit(edit: PreparedSavedEdit): SavedEditView;
41
+ export declare function readSavedChange(change: PreparedSavedChange): SavedChangeView;
42
+ export declare function commitSavedChange(change: PreparedSavedChange, approval: ChangeApproval): Promise<SavedItem>;
43
+ export declare function changeSavedDestination(libraryDir: string, itemId: string, destination: ResourceDestination): Promise<SavedItem>;
44
+ export declare function prepareSavedReplacement(libraryDir: string, itemId: string, preparedSource: PreparedResource): Promise<PreparedSavedChange>;
45
+ export {};
@@ -0,0 +1,225 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { createTwoFilesPatch } from "diff";
4
+ import { catalogBytes, readPreparedResource, withSavedLibrary } from "./catalog.js";
5
+ import { inspectFixedResource, maskSensitiveText } from "./inspect.js";
6
+ import { assertNoNameConflicts } from "./names.js";
7
+ import { privateStoragePath as privatePath, readStorageBeforeReviewFiles, safeStoragePath } from "./storage-transaction.js";
8
+ const edits = new WeakMap();
9
+ const changes = new WeakMap();
10
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
11
+ function editable(bytes) {
12
+ if (!Buffer.isBuffer(bytes))
13
+ throw new Error("Editing requires UTF-8 bytes.");
14
+ if (bytes.length > 1_000_000)
15
+ throw new Error("Saved text editing is limited to 1_000_000 bytes (1 MB).");
16
+ if (bytes.includes(0))
17
+ throw new Error("Binary/NUL content is not editable as UTF-8 text.");
18
+ try {
19
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
20
+ }
21
+ catch {
22
+ throw new Error("Unsupported encoding: saved text must be valid UTF-8.");
23
+ }
24
+ }
25
+ function candidate(item) { return { ...structuredClone(item), sourceRoot: path.dirname(item.sourcePath) }; }
26
+ function findItem(state, itemId) {
27
+ const item = state.catalog.items.find((entry) => entry.id === itemId);
28
+ if (!item)
29
+ throw new Error("Unknown saved item.");
30
+ return item;
31
+ }
32
+ function assertKnownLayout(item, files) {
33
+ const current = new Map(files.map((file) => [file.path, file]));
34
+ if (current.size !== files.length)
35
+ throw new Error("Registered sources content has duplicate paths.");
36
+ if (item.content.kind === "file" && (files.length !== 1 || files[0]?.path !== path.posix.basename(item.content.path))) {
37
+ throw new Error("Registered sources file must resolve to its single catalog path.");
38
+ }
39
+ }
40
+ function editState(edit) {
41
+ const state = edits.get(edit);
42
+ if (!state)
43
+ throw new Error("Invalid or unavailable prepared edit token.");
44
+ return state;
45
+ }
46
+ function changeState(change) {
47
+ const state = changes.get(change);
48
+ if (!state)
49
+ throw new Error("Invalid or unavailable prepared change token.");
50
+ return state;
51
+ }
52
+ function inspect(item, files, excludedPaths = []) {
53
+ return inspectFixedResource({ candidate: candidate(item), files, excludedPaths });
54
+ }
55
+ function fileDiffs(before, after) {
56
+ const previous = new Map(before.map((file) => [file.path, file]));
57
+ const next = new Map(after.map((file) => [file.path, file]));
58
+ return [...new Set([...previous.keys(), ...next.keys()])].sort().map((filePath) => {
59
+ const old = previous.get(filePath);
60
+ const current = next.get(filePath);
61
+ const bytesChanged = !old || !current || !old.bytes.equals(current.bytes);
62
+ let diff = "";
63
+ try {
64
+ diff = createTwoFilesPatch(old ? filePath : "(new)", current ? filePath : "(removed)", maskSensitiveText(old ? editable(old.bytes) : ""), maskSensitiveText(current ? editable(current.bytes) : ""));
65
+ }
66
+ catch { /* Binary/large replacement files are summarized, never decoded or exposed. */ }
67
+ return { path: filePath, bytesChanged, diff, summary: `${bytesChanged ? "Bytes changed" : "Bytes unchanged"}; ${old?.bytes.length ?? 0} → ${current?.bytes.length ?? 0} bytes; mode ${old?.mode.toString(8) ?? "absent"} → ${current?.mode.toString(8) ?? "absent"}.` };
68
+ });
69
+ }
70
+ function sameIncludedFiles(before, current) {
71
+ if (before.length !== current.length)
72
+ return false;
73
+ const byPath = new Map(current.map((file) => [file.path, file]));
74
+ return byPath.size === current.length && before.every((file) => {
75
+ const listed = byPath.get(file.path);
76
+ return listed !== undefined && file.bytes.equals(listed.bytes);
77
+ });
78
+ }
79
+ async function makeChange(state, session, nextFiles, nextItem, excludedPaths = []) {
80
+ const files = nextFiles.map((file) => ({ path: file.path, mode: file.mode, bytes: Buffer.from(file.bytes) }));
81
+ for (const file of files)
82
+ if (!safeStoragePath(file.path) || privatePath(file.path))
83
+ throw new Error("Unsafe or excluded snapshot path in saved change.");
84
+ if (files.length === 0 || new Set(files.map((file) => file.path)).size !== files.length)
85
+ throw new Error("Saved replacement must contain unique registered files.");
86
+ const item = structuredClone(nextItem);
87
+ item.files = files.map((file) => ({ path: file.path, mode: file.mode, sha256: sha256(file.bytes) }));
88
+ const catalog = structuredClone(state.catalog);
89
+ const itemIndex = catalog.items.findIndex((entry) => entry.id === item.id);
90
+ if (itemIndex < 0)
91
+ throw new Error("Saved item changed after preparation.");
92
+ catalog.items[itemIndex] = structuredClone(item);
93
+ const replacements = [{ itemId: item.id, files }];
94
+ const storageReview = await session.prepareSourcesReview(state.revision, catalogBytes(catalog), replacements);
95
+ const capturedBefore = readStorageBeforeReviewFiles(storageReview, item.id);
96
+ if (!sameIncludedFiles(capturedBefore, state.files))
97
+ throw new Error("Before-reviewed source list does not match the current included files for this revision.");
98
+ const currentByPath = new Map(state.files.map((file) => [file.path, file]));
99
+ const beforeFiles = capturedBefore.map((file) => ({
100
+ ...file,
101
+ mode: currentByPath.get(file.path).mode,
102
+ }));
103
+ const diffs = fileDiffs(beforeFiles, files);
104
+ const change = Object.freeze({ reviewId: randomUUID() });
105
+ changes.set(change, { ...state, storageReview, nextFiles: files, nextItem: item, excludedPaths: [...excludedPaths], inspection: inspect(item, files, excludedPaths), diffs, changed: diffs.some((diff) => diff.bytesChanged) || JSON.stringify(item) !== JSON.stringify(state.item), used: false });
106
+ return change;
107
+ }
108
+ export async function prepareSavedEdit(libraryDir, itemId, filePath) {
109
+ if (!safeStoragePath(filePath) || privatePath(filePath))
110
+ throw new Error("Unsafe or excluded saved edit path.");
111
+ return withSavedLibrary(libraryDir, async (state) => {
112
+ assertNoNameConflicts(state.catalog);
113
+ const item = findItem(state, itemId);
114
+ const files = state.files.get(item.id);
115
+ assertKnownLayout(item, files);
116
+ if (!item.files.some((entry) => entry.path === filePath))
117
+ throw new Error("Only an existing registered snapshot file can be edited.");
118
+ const file = files.find((entry) => entry.path === filePath);
119
+ if (!file)
120
+ throw new Error("Only an existing registered snapshot file can be edited.");
121
+ editable(file.bytes);
122
+ const edit = Object.freeze({});
123
+ edits.set(edit, { libraryDir: state.session.root, revision: state.revision, catalog: structuredClone(state.catalog), item: structuredClone(item), files, filePath });
124
+ return edit;
125
+ });
126
+ }
127
+ export async function prepareSavedChange(edit, bytes) {
128
+ const prepared = editState(edit);
129
+ const fixed = Buffer.from(bytes);
130
+ editable(fixed);
131
+ return withSavedLibrary(prepared.libraryDir, async (state) => {
132
+ await state.session.assertRevision(prepared.revision);
133
+ assertNoNameConflicts(state.catalog);
134
+ const item = findItem(state, prepared.item.id);
135
+ const files = state.files.get(item.id);
136
+ assertKnownLayout(item, files);
137
+ if (!item.files.some((entry) => entry.path === prepared.filePath))
138
+ throw new Error("Only an existing registered snapshot file can be edited.");
139
+ const sourceFile = files.find((file) => file.path === prepared.filePath);
140
+ if (!sourceFile)
141
+ throw new Error("Registered source file disappeared before edit preparation.");
142
+ const current = {
143
+ libraryDir: state.session.root,
144
+ revision: state.revision,
145
+ catalog: structuredClone(state.catalog),
146
+ item: structuredClone(item),
147
+ files,
148
+ };
149
+ return makeChange(current, state.session, files.map((file) => ({ ...file, bytes: file.path === prepared.filePath ? fixed : file.bytes })), item);
150
+ });
151
+ }
152
+ export function readSavedEdit(edit) {
153
+ const state = editState(edit);
154
+ const file = state.files.find((file) => file.path === state.filePath);
155
+ const inspection = inspect(state.item, [file]);
156
+ return { item: structuredClone(state.item), file: { ...file, bytes: Buffer.from(file.bytes) }, sensitivePaths: [...inspection.sensitivePaths], warnings: structuredClone(inspection.warnings) };
157
+ }
158
+ export function readSavedChange(change) {
159
+ const state = changeState(change);
160
+ return { reviewId: change.reviewId, item: structuredClone(state.nextItem), diffs: structuredClone(state.diffs), sensitivePaths: [...state.inspection.sensitivePaths], excludedPaths: [...state.excludedPaths], warnings: structuredClone(state.inspection.warnings), bytesChanged: state.diffs.some((diff) => diff.bytesChanged) };
161
+ }
162
+ export async function commitSavedChange(change, approval) {
163
+ const state = changeState(change);
164
+ if (state.used)
165
+ throw new Error("Prepared change was already committed; prepare and review again.");
166
+ if (approval.confirmed !== true)
167
+ throw new Error("Saved change was not confirmed; no bytes were written.");
168
+ if (approval.reviewId !== change.reviewId)
169
+ throw new Error("Approval reviewId does not match this prepared change.");
170
+ const inspected = inspect(state.nextItem, state.nextFiles, state.excludedPaths);
171
+ const approved = new Set(approval.approvedSensitivePaths);
172
+ if (approved.size !== approval.approvedSensitivePaths.length || approved.size !== inspected.sensitivePaths.length || inspected.sensitivePaths.some((filePath) => !approved.has(filePath)))
173
+ throw new Error("Individual approval is required for every suspected sensitive file in this saved change.");
174
+ return withSavedLibrary(state.libraryDir, async ({ catalog, session }) => {
175
+ assertNoNameConflicts(catalog);
176
+ await session.assertRevision(state.revision);
177
+ const item = catalog.items.find((item) => item.id === state.item.id);
178
+ if (!item)
179
+ throw new Error("Saved item changed after preparation.");
180
+ if (!state.changed) {
181
+ state.used = true;
182
+ return structuredClone(item);
183
+ }
184
+ catalog.items[catalog.items.indexOf(item)] = structuredClone(state.nextItem);
185
+ const bytes = catalogBytes(catalog);
186
+ const replacements = [{ itemId: item.id, files: state.nextFiles }];
187
+ const review = state.storageReview;
188
+ await session.commit(state.revision, bytes, replacements, { review, approval: { reviewId: review.reviewId, confirmed: true, approvedSensitivePaths: [...approved].map((filePath) => `${item.id}/${filePath}`) } });
189
+ state.used = true;
190
+ return structuredClone(state.nextItem);
191
+ });
192
+ }
193
+ export async function changeSavedDestination(libraryDir, itemId, destination) {
194
+ if (!destination || (destination.kind !== "global-only" && (destination.kind !== "project" || !safeStoragePath(destination.path) || privatePath(destination.path))))
195
+ throw new Error("Unsafe or private/excluded saved destination.");
196
+ const fixed = { ...destination };
197
+ return withSavedLibrary(libraryDir, async ({ catalog, revision, session }) => {
198
+ assertNoNameConflicts(catalog);
199
+ const item = catalog.items.find((entry) => entry.id === itemId);
200
+ if (!item)
201
+ throw new Error("Unknown saved item.");
202
+ if (item.destination.kind === "global-only" && fixed.kind !== "global-only")
203
+ throw new Error("A reference-only/global-only resource cannot become project-applicable.");
204
+ if (JSON.stringify(item.destination) === JSON.stringify(fixed))
205
+ return structuredClone(item);
206
+ item.destination = fixed;
207
+ await session.commit(revision, catalogBytes(catalog));
208
+ return structuredClone(item);
209
+ });
210
+ }
211
+ export async function prepareSavedReplacement(libraryDir, itemId, preparedSource) {
212
+ const source = readPreparedResource(preparedSource);
213
+ return withSavedLibrary(libraryDir, async (state) => {
214
+ assertNoNameConflicts(state.catalog);
215
+ const item = findItem(state, itemId);
216
+ const currentFiles = state.files.get(item.id);
217
+ assertKnownLayout(item, currentFiles);
218
+ if (source.candidate.kind !== item.kind)
219
+ throw new Error("Replacement source must have the same resource kind.");
220
+ if (source.candidate.destination.kind === "global-only" && item.destination.kind !== "global-only")
221
+ throw new Error("A reference-only/global-only replacement source cannot become project-applicable.");
222
+ const next = { ...structuredClone(item), source: source.candidate.source, scope: source.candidate.scope, sourcePath: source.candidate.sourcePath };
223
+ return makeChange({ libraryDir: state.session.root, revision: state.revision, catalog: structuredClone(state.catalog), item: structuredClone(item), files: currentFiles }, state.session, source.files, next, source.excludedPaths);
224
+ });
225
+ }
@@ -0,0 +1,4 @@
1
+ import type { SavedItem, SnapshotFileContents } from "./types.ts";
2
+ export declare function validateContentLocations(items: readonly SavedItem[]): void;
3
+ export declare function validateRuntimeContentLocations(libraryDir: string, items: readonly SavedItem[]): Promise<void>;
4
+ export declare function readSourceContent(libraryDir: string, item: SavedItem): Promise<SnapshotFileContents[]>;
@@ -0,0 +1,208 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
4
+ import path from "node:path";
5
+ const excludedNames = new Set([
6
+ ".git", ".cache", ".npm", "auth", "node_modules", "cache", "caches", "history", "sessions",
7
+ "auth.json", "mcp-auth.json", "oauth.json",
8
+ ]);
9
+ const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
10
+ function safeRelativePath(value) {
11
+ return typeof value === "string" && value.length > 0 && !value.includes("\\") && !value.includes("\0") &&
12
+ !path.posix.isAbsolute(value) && value.split("/").every((part) => part !== "" && part !== "." && part !== "..") &&
13
+ path.posix.normalize(value) === value;
14
+ }
15
+ function isWithin(root, target) {
16
+ const relative = path.relative(root, target);
17
+ return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
18
+ }
19
+ function excludedPath(value) {
20
+ return value.split("/").some((part) => {
21
+ const lower = part.toLowerCase();
22
+ return excludedNames.has(lower) || lower === ".env" || lower.startsWith(".env");
23
+ });
24
+ }
25
+ function assertContentLocation(value) {
26
+ if (typeof value !== "object" || value === null || Array.isArray(value))
27
+ throw new Error("Invalid content location in copi catalog.");
28
+ const content = value;
29
+ if ((content.kind !== "file" && content.kind !== "tree") || typeof content.path !== "string" ||
30
+ !safeRelativePath(content.path) || !content.path.startsWith("sources/") || excludedPath(content.path.slice("sources/".length))) {
31
+ throw new Error("Invalid or unsafe content path in copi catalog; content must be a safe path below sources/.");
32
+ }
33
+ }
34
+ export function validateContentLocations(items) {
35
+ const locations = [];
36
+ for (const item of items) {
37
+ assertContentLocation(item.content);
38
+ if (item.content.kind !== (item.kind === "skill" ? "tree" : "file")) {
39
+ throw new Error(`Invalid content kind for ${item.kind} item ${item.id}.`);
40
+ }
41
+ if (item.files.length === 0)
42
+ throw new Error(`Content item ${item.id} has no registered files.`);
43
+ if (item.content.kind === "file") {
44
+ const registered = item.files.length === 1 && item.files[0]?.path === path.posix.basename(item.content.path);
45
+ if (!registered)
46
+ throw new Error(`File content for ${item.id} must register exactly its content file.`);
47
+ }
48
+ locations.push({ id: item.id, location: item.content });
49
+ }
50
+ for (let index = 0; index < locations.length; index += 1) {
51
+ const current = locations[index];
52
+ for (const other of locations.slice(index + 1)) {
53
+ const left = current.location.path;
54
+ const right = other.location.path;
55
+ if (left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)) {
56
+ throw new Error(`Duplicate or overlapping content locations for items ${current.id} and ${other.id}.`);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ function sameInode(left, right) {
62
+ return left.dev === right.dev && left.ino === right.ino;
63
+ }
64
+ function sameFileState(left, right) {
65
+ return sameInode(left, right) && left.size === right.size && left.mtimeNs === right.mtimeNs &&
66
+ left.ctimeNs === right.ctimeNs && left.mode === right.mode;
67
+ }
68
+ async function safeLibraryRoot(libraryDir) {
69
+ const requested = path.resolve(libraryDir);
70
+ const rootStat = await lstat(requested);
71
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
72
+ throw new Error("Copi content root is not a safe directory.");
73
+ const root = await realpath(requested);
74
+ if (root !== requested)
75
+ throw new Error("Copi content root changed through a symbolic link.");
76
+ return root;
77
+ }
78
+ async function checkedPath(root, relative, directory) {
79
+ if (!safeRelativePath(relative))
80
+ throw new Error("Catalog contains an unsafe content path.");
81
+ const target = path.resolve(root, ...relative.split("/"));
82
+ if (!isWithin(root, target))
83
+ throw new Error("Content path escapes the copi library.");
84
+ let current = root;
85
+ for (const segment of relative.split("/")) {
86
+ current = path.join(current, segment);
87
+ const stat = await lstat(current);
88
+ if (stat.isSymbolicLink())
89
+ throw new Error("Content contains an unsafe symbolic link.");
90
+ }
91
+ const canonical = await realpath(target);
92
+ if (!isWithin(root, canonical))
93
+ throw new Error("Content path resolves outside the copi library.");
94
+ const stat = await lstat(target);
95
+ if (directory ? !stat.isDirectory() : !stat.isFile())
96
+ throw new Error(`Content path is not a regular ${directory ? "directory" : "file"}.`);
97
+ return canonical;
98
+ }
99
+ export async function validateRuntimeContentLocations(libraryDir, items) {
100
+ if (items.length === 0)
101
+ return;
102
+ validateContentLocations(items);
103
+ const libraryRoot = await safeLibraryRoot(libraryDir);
104
+ const sourcesRoot = await checkedPath(libraryRoot, "sources", true);
105
+ const locations = [];
106
+ for (const item of items) {
107
+ const canonical = await checkedPath(libraryRoot, item.content.path, item.content.kind === "tree");
108
+ if (!isWithin(sourcesRoot, canonical))
109
+ throw new Error("Content path escapes the canonical sources root.");
110
+ const stat = await lstat(canonical, { bigint: true });
111
+ for (const other of locations) {
112
+ if (sameInode(stat, other.identity) || isWithin(other.path, canonical) || isWithin(canonical, other.path)) {
113
+ throw new Error(`Duplicate or overlapping physical content locations for items ${other.id} and ${item.id}.`);
114
+ }
115
+ }
116
+ locations.push({ id: item.id, path: canonical, identity: stat });
117
+ }
118
+ }
119
+ async function readRegularFile(libraryRoot, contentRoot, target) {
120
+ const before = await lstat(target, { bigint: true });
121
+ if (!before.isFile() || before.isSymbolicLink())
122
+ throw new Error("Source content is not a regular file or is a symbolic link.");
123
+ const canonical = await realpath(target);
124
+ if (!isWithin(libraryRoot, canonical) || !isWithin(contentRoot, canonical))
125
+ throw new Error("Source content resolves outside its registered content location.");
126
+ const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
127
+ try {
128
+ const opened = await handle.stat({ bigint: true });
129
+ if (!opened.isFile() || !sameFileState(before, opened))
130
+ throw new Error("Source content changed while it was being opened.");
131
+ const bytes = await handle.readFile();
132
+ const [afterHandle, after] = await Promise.all([handle.stat({ bigint: true }), lstat(target, { bigint: true })]);
133
+ if (after.isSymbolicLink() || !sameFileState(opened, afterHandle) || !sameFileState(opened, after)) {
134
+ throw new Error("Source content changed while it was being read.");
135
+ }
136
+ return { bytes, mode: Number(opened.mode & 511n) };
137
+ }
138
+ finally {
139
+ await handle.close();
140
+ }
141
+ }
142
+ export async function readSourceContent(libraryDir, item) {
143
+ validateContentLocations([item]);
144
+ const libraryRoot = await safeLibraryRoot(libraryDir);
145
+ const sourcesRoot = await checkedPath(libraryRoot, "sources", true);
146
+ const contentRoot = await checkedPath(libraryRoot, item.content.path, item.content.kind === "tree");
147
+ if (!isWithin(sourcesRoot, contentRoot))
148
+ throw new Error("Content path escapes the canonical sources root.");
149
+ const descriptors = new Map(item.files.map((file) => [file.path, file]));
150
+ const results = [];
151
+ const readAt = async (absolutePath, relativePath) => {
152
+ if (!safeRelativePath(relativePath))
153
+ throw new Error("Source content contains an unsafe relative file path.");
154
+ if (excludedPath(relativePath))
155
+ return;
156
+ const value = await readRegularFile(libraryRoot, contentRoot, absolutePath);
157
+ const descriptor = descriptors.get(relativePath);
158
+ results.push({
159
+ path: relativePath,
160
+ mode: descriptor?.mode ?? value.mode,
161
+ sha256: digest(value.bytes),
162
+ bytes: value.bytes,
163
+ });
164
+ };
165
+ if (item.content.kind === "file") {
166
+ const filePath = path.posix.basename(item.content.path);
167
+ if (excludedPath(filePath))
168
+ throw new Error("Registered source content is in an excluded path.");
169
+ await readAt(contentRoot, filePath);
170
+ }
171
+ else {
172
+ const visit = async (directory, relative) => {
173
+ const before = await lstat(directory);
174
+ const canonicalDirectory = await realpath(directory);
175
+ if (!before.isDirectory() || before.isSymbolicLink() || !isWithin(contentRoot, canonicalDirectory))
176
+ throw new Error("Source tree contains an unsafe non-directory or symbolic link.");
177
+ const names = await readdir(directory);
178
+ for (const name of names.sort((left, right) => left.localeCompare(right))) {
179
+ const childRelative = relative ? `${relative}/${name}` : name;
180
+ if (excludedPath(childRelative))
181
+ continue;
182
+ const childPath = path.join(directory, name);
183
+ const stat = await lstat(childPath);
184
+ if (stat.isSymbolicLink())
185
+ throw new Error("Source tree contains an unsafe symbolic link.");
186
+ if (stat.isDirectory())
187
+ await visit(childPath, childRelative);
188
+ else if (stat.isFile())
189
+ await readAt(childPath, childRelative);
190
+ else
191
+ throw new Error("Source tree contains an unsupported special file.");
192
+ }
193
+ const after = await lstat(directory);
194
+ const finalNames = await readdir(directory);
195
+ if (after.isSymbolicLink() || after.dev !== before.dev || after.ino !== before.ino || JSON.stringify(finalNames.sort((left, right) => left.localeCompare(right))) !== JSON.stringify(names.sort((left, right) => left.localeCompare(right)))) {
196
+ throw new Error("Source tree directory changed while it was being read.");
197
+ }
198
+ };
199
+ await visit(contentRoot, "");
200
+ if (item.kind === "skill" && !results.some((file) => file.path === "SKILL.md")) {
201
+ throw new Error("Skill content is missing required root SKILL.md.");
202
+ }
203
+ }
204
+ if (results.length === 0)
205
+ throw new Error("Source content contains no readable files.");
206
+ results.sort((left, right) => left.path.localeCompare(right.path));
207
+ return results;
208
+ }