@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.
- package/LICENSE.unicode +39 -0
- package/README.md +57 -5
- package/dist/native/rename-exclusive-arm64 +0 -0
- package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
- package/dist/src/apply.d.ts +9 -0
- package/dist/src/apply.js +110 -9
- package/dist/src/catalog.d.ts +21 -4
- package/dist/src/catalog.js +265 -133
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.js +73 -3
- package/dist/src/groups.js +8 -3
- package/dist/src/inspect.d.ts +3 -1
- package/dist/src/inspect.js +16 -6
- package/dist/src/instruction-reference.d.ts +6 -0
- package/dist/src/instruction-reference.js +21 -0
- package/dist/src/manage.d.ts +2 -0
- package/dist/src/manage.js +329 -0
- package/dist/src/migrate.d.ts +25 -0
- package/dist/src/migrate.js +171 -0
- package/dist/src/migration-backup.d.ts +22 -0
- package/dist/src/migration-backup.js +403 -0
- package/dist/src/migration-inventory.d.ts +9 -0
- package/dist/src/migration-inventory.js +237 -0
- package/dist/src/migration-resolution.d.ts +28 -0
- package/dist/src/migration-resolution.js +1618 -0
- package/dist/src/migration.d.ts +49 -0
- package/dist/src/migration.js +1004 -0
- package/dist/src/names.d.ts +11 -0
- package/dist/src/names.js +40 -0
- package/dist/src/native-placement.d.ts +16 -0
- package/dist/src/native-placement.js +557 -0
- package/dist/src/refresh.d.ts +20 -0
- package/dist/src/refresh.js +143 -0
- package/dist/src/resource-mutations.d.ts +45 -0
- package/dist/src/resource-mutations.js +225 -0
- package/dist/src/source-content.d.ts +4 -0
- package/dist/src/source-content.js +208 -0
- package/dist/src/storage-journal.d.ts +111 -0
- package/dist/src/storage-journal.js +613 -0
- package/dist/src/storage-root-lease.d.ts +7 -0
- package/dist/src/storage-root-lease.js +92 -0
- package/dist/src/storage-transaction.d.ts +100 -0
- package/dist/src/storage-transaction.js +3951 -0
- package/dist/src/terminal-editor.d.ts +8 -0
- package/dist/src/terminal-editor.js +391 -0
- package/dist/src/types.d.ts +18 -2
- package/dist/src/unicode-case-folding-data.d.ts +1 -0
- package/dist/src/unicode-case-folding-data.js +1596 -0
- package/dist/src/unicode-case-folding.d.ts +2 -0
- package/dist/src/unicode-case-folding.js +9 -0
- package/dist/src/wizard.d.ts +12 -3
- package/dist/src/wizard.js +287 -19
- package/package.json +5 -3
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { unicode17CaseFolding } from "./unicode-case-folding-data.js";
|
|
2
|
+
/** Unicode 17.0.0 DEFAULT FULL case folding, independent of locale. */
|
|
3
|
+
export function foldCaseUnicode17(value) {
|
|
4
|
+
let result = "";
|
|
5
|
+
for (const character of value) {
|
|
6
|
+
result += unicode17CaseFolding.get(character.codePointAt(0)) ?? character;
|
|
7
|
+
}
|
|
8
|
+
return result;
|
|
9
|
+
}
|
package/dist/src/wizard.d.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { type PreparedInspection, type PreparedResource } from "./catalog.ts";
|
|
2
2
|
import { type DiscoveryOptions } from "./discovery.ts";
|
|
3
3
|
import type { ApplyFileOption } from "./apply.ts";
|
|
4
|
-
import type {
|
|
4
|
+
import type { LegacySavedItem, ResourceCandidate, SavedItem, StoredCatalog } from "./types.ts";
|
|
5
|
+
/** Mask display metadata too, and render controls literally rather than executing VT sequences. */
|
|
6
|
+
export declare function safeDisplay(value: string): string;
|
|
7
|
+
/** The editor parses literal paste versus cancel keys; stream EOF/errors/signals stay global. */
|
|
8
|
+
export declare function withEditorInput<T>(operation: () => Promise<T>): Promise<T>;
|
|
9
|
+
export declare const multiselectKeyHint = "(Tab: select, Enter: confirm)";
|
|
10
|
+
/** One process-wide interactive lifecycle shared by manage and its existing wizard routes. */
|
|
11
|
+
export declare function withWizardLifecycle(operation: (signal: AbortSignal) => Promise<void>): Promise<void>;
|
|
5
12
|
export interface SearchRoots {
|
|
6
13
|
homeDir: string;
|
|
7
14
|
agentDir: string;
|
|
@@ -21,10 +28,12 @@ export interface CandidateReview {
|
|
|
21
28
|
}
|
|
22
29
|
export declare function prepareCandidateForReview(candidate: ResourceCandidate): Promise<CandidateReview>;
|
|
23
30
|
export declare function saveReviewedCandidate(libraryDir: string, review: CandidateReview, groupIds: string[], approvedSensitivePaths: string[], confirmed: boolean): Promise<SavedItem | null>;
|
|
31
|
+
/** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
|
|
32
|
+
export declare function resolveNameConflicts(libraryDir: string): Promise<void>;
|
|
24
33
|
export declare function runSetup(libraryDir: string, roots: SearchRoots): Promise<void>;
|
|
25
34
|
export declare function runGroupManager(libraryDir: string): Promise<void>;
|
|
26
|
-
export declare function printGroups(catalog:
|
|
27
|
-
export declare function printItems(items: SavedItem
|
|
35
|
+
export declare function printGroups(catalog: StoredCatalog): void;
|
|
36
|
+
export declare function printItems(items: Array<LegacySavedItem | SavedItem>): void;
|
|
28
37
|
export declare function formatApplyOptionPreview(filePath: string, resourceName: string, option: ApplyFileOption): string;
|
|
29
38
|
export declare function runApplyDryRun(libraryDir: string, groupValue: string, projectDir: string): Promise<void>;
|
|
30
39
|
export declare function runApplyWizard(libraryDir: string, groupValue: string, projectDir: string): Promise<void>;
|
package/dist/src/wizard.js
CHANGED
|
@@ -1,11 +1,90 @@
|
|
|
1
|
-
import { autocompleteMultiselect, cancel, confirm, intro, isCancel, log, note, outro, select, text } from "@clack/prompts";
|
|
1
|
+
import { autocompleteMultiselect as clackMultiselect, cancel as clackCancel, confirm as clackConfirm, intro as clackIntro, isCancel, log, note as clackNote, outro as clackOutro, select as clackSelect, spinner as clackSpinner, text as clackText } from "@clack/prompts";
|
|
2
2
|
import { lstat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { importPreparedResource, loadCatalog, prepareResource, readPreparedResource, saveCatalog } from "./catalog.js";
|
|
4
|
+
import { assertModernCatalog, importPreparedResource, loadCatalog, prepareResource, readPreparedResource, readSnapshotFiles, saveCatalog, saveNameChanges } from "./catalog.js";
|
|
5
5
|
import { discoverResources } from "./discovery.js";
|
|
6
6
|
import { createGroup, renameGroup, setGroupItems } from "./groups.js";
|
|
7
7
|
import { inspectPreparedResource, maskSensitiveText } from "./inspect.js";
|
|
8
|
+
import { assertNameAvailable, assertNoNameConflicts, findNameConflicts, nameKey } from "./names.js";
|
|
8
9
|
import { executeApply, planApply } from "./apply.js";
|
|
10
|
+
/** Mask display metadata too, and render controls literally rather than executing VT sequences. */
|
|
11
|
+
export function safeDisplay(value) {
|
|
12
|
+
return maskSensitiveText(value)
|
|
13
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "[internal reference]")
|
|
14
|
+
.replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
15
|
+
}
|
|
16
|
+
let activeSignal;
|
|
17
|
+
let editorOwnsControlInput = false;
|
|
18
|
+
/** The editor parses literal paste versus cancel keys; stream EOF/errors/signals stay global. */
|
|
19
|
+
export async function withEditorInput(operation) {
|
|
20
|
+
const previous = editorOwnsControlInput;
|
|
21
|
+
editorOwnsControlInput = true;
|
|
22
|
+
try {
|
|
23
|
+
return await operation();
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
editorOwnsControlInput = previous;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function inheritedSignal(signal) {
|
|
30
|
+
const current = signal ?? activeSignal;
|
|
31
|
+
return current ? { signal: current } : {};
|
|
32
|
+
}
|
|
33
|
+
const select = (options) => clackSelect({
|
|
34
|
+
...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message),
|
|
35
|
+
options: options.options.map((option) => ({ ...option, label: safeDisplay(option.label ?? "Unavailable entry"), ...(option.hint === undefined ? {} : { hint: safeDisplay(option.hint) }) })),
|
|
36
|
+
});
|
|
37
|
+
// Clack's own key legend is dim and below the list; repeat it in the question so Tab-to-select is not missed.
|
|
38
|
+
export const multiselectKeyHint = "(Tab: select, Enter: confirm)";
|
|
39
|
+
const autocompleteMultiselect = (options) => clackMultiselect({
|
|
40
|
+
...options, ...inheritedSignal(options.signal), message: `${safeDisplay(options.message)} ${multiselectKeyHint}`,
|
|
41
|
+
options: typeof options.options === "function" ? options.options : options.options.map((option) => ({ ...option, label: safeDisplay(option.label ?? "Unavailable entry"), ...(option.hint === undefined ? {} : { hint: safeDisplay(option.hint) }) })),
|
|
42
|
+
});
|
|
43
|
+
const text = (options) => clackText({ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message) });
|
|
44
|
+
const confirm = (options) => clackConfirm({ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message) });
|
|
45
|
+
const note = (message, title, options) => clackNote(safeDisplay(message ?? ""), safeDisplay(title ?? ""), options);
|
|
46
|
+
const intro = (message, options) => clackIntro(safeDisplay(message ?? ""), options);
|
|
47
|
+
const outro = (message, options) => clackOutro(safeDisplay(message ?? ""), options);
|
|
48
|
+
const cancel = (message, options) => clackCancel(safeDisplay(message ?? ""), options);
|
|
49
|
+
/** One process-wide interactive lifecycle shared by manage and its existing wizard routes. */
|
|
50
|
+
export async function withWizardLifecycle(operation) {
|
|
51
|
+
if (activeSignal)
|
|
52
|
+
throw new Error("An interactive wizard is already active.");
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const wasRaw = Boolean(process.stdin.isRaw), wasFlowing = process.stdin.readableFlowing === true;
|
|
55
|
+
const abort = () => controller.abort();
|
|
56
|
+
const eofKey = (chunk) => {
|
|
57
|
+
if (!editorOwnsControlInput && (typeof chunk === "string" ? chunk.includes("\u0004") : chunk.includes(4)))
|
|
58
|
+
abort();
|
|
59
|
+
};
|
|
60
|
+
activeSignal = controller.signal;
|
|
61
|
+
process.stdin.on("end", abort);
|
|
62
|
+
process.stdin.on("close", abort);
|
|
63
|
+
process.stdin.on("error", abort);
|
|
64
|
+
process.stdin.on("data", eofKey);
|
|
65
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
66
|
+
process.on(signal, abort);
|
|
67
|
+
try {
|
|
68
|
+
await operation(controller.signal);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
activeSignal = undefined;
|
|
72
|
+
process.stdin.off("end", abort);
|
|
73
|
+
process.stdin.off("close", abort);
|
|
74
|
+
process.stdin.off("error", abort);
|
|
75
|
+
process.stdin.off("data", eofKey);
|
|
76
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
77
|
+
process.off(signal, abort);
|
|
78
|
+
if (process.stdin.isTTY)
|
|
79
|
+
process.stdin.setRawMode(wasRaw);
|
|
80
|
+
// Adding our data listener starts an inactive reader too; restore its original ownership.
|
|
81
|
+
if (wasFlowing)
|
|
82
|
+
process.stdin.resume();
|
|
83
|
+
else
|
|
84
|
+
process.stdin.pause();
|
|
85
|
+
process.stdout.write("\u001b[?25h");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
9
88
|
const previewMaxBytes = 1_000_000;
|
|
10
89
|
const previewMaxFiles = 12;
|
|
11
90
|
const previewMaxLines = 24;
|
|
@@ -22,7 +101,7 @@ export function formatMaskedPreview(relativePath, content) {
|
|
|
22
101
|
const lines = masked.split(/\r?\n/);
|
|
23
102
|
const snippet = lines.slice(0, previewMaxLines).join("\n").slice(0, 2_000);
|
|
24
103
|
const omitted = lines.length > previewMaxLines ? `\n… ${lines.length - previewMaxLines} more lines` : "";
|
|
25
|
-
return `${relativePath}:\n${snippet}${omitted}
|
|
104
|
+
return safeDisplay(`${relativePath}:\n${snippet}${omitted}`);
|
|
26
105
|
}
|
|
27
106
|
function formatPreparedFiles(files) {
|
|
28
107
|
return files.slice(0, previewMaxFiles).map((file) => {
|
|
@@ -59,17 +138,159 @@ function showWarnings(warnings, title) {
|
|
|
59
138
|
return;
|
|
60
139
|
note(warnings.map((warning) => `${warning.path ? `${warning.path}: ` : ""}${warning.message}`).join("\n"), title);
|
|
61
140
|
}
|
|
141
|
+
function savedItemHint(catalog, item) {
|
|
142
|
+
const groups = catalog.groups.filter((group) => group.itemIds.includes(item.id)).map((group) => group.name).join(", ") || "no groups";
|
|
143
|
+
return `${groups} · ${item.source} · ${item.destination.kind === "project" ? item.destination.path : "saved only"}`;
|
|
144
|
+
}
|
|
145
|
+
async function viewSavedEntry(libraryDir) {
|
|
146
|
+
const catalog = await loadCatalog(libraryDir);
|
|
147
|
+
const entry = await select({
|
|
148
|
+
message: "Select saved resource or group",
|
|
149
|
+
options: [
|
|
150
|
+
...catalog.items.map((item) => ({ value: `item:${item.id}`, label: `[${item.kind}] ${item.name}`, hint: savedItemHint(catalog, item) })),
|
|
151
|
+
...catalog.groups.map((group) => ({ value: `group:${group.id}`, label: `[group] ${group.name}`, hint: `${group.itemIds.length} resources` })),
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
if (isCancel(entry))
|
|
155
|
+
return;
|
|
156
|
+
const item = catalog.items.find((item) => `item:${item.id}` === entry);
|
|
157
|
+
if (item) {
|
|
158
|
+
const files = await readSnapshotFiles(libraryDir, item);
|
|
159
|
+
note(formatPreparedFiles(files).join("\n\n") || "No previewable files.", `Masked saved preview: ${item.name}`);
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
const group = catalog.groups.find((group) => `group:${group.id}` === entry);
|
|
163
|
+
if (!group)
|
|
164
|
+
throw new Error("Selected group is no longer available.");
|
|
165
|
+
note(group.itemIds.map((id) => {
|
|
166
|
+
const member = catalog.items.find((item) => item.id === id);
|
|
167
|
+
return member ? `[${member.kind}] ${member.name} · ${savedItemHint(catalog, member)}` : "Unavailable resource";
|
|
168
|
+
}).join("\n") || "No saved resources.", group.name);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
|
|
172
|
+
export async function resolveNameConflicts(libraryDir) {
|
|
173
|
+
const before = await loadCatalog(libraryDir);
|
|
174
|
+
assertModernCatalog(before);
|
|
175
|
+
const catalog = structuredClone(before);
|
|
176
|
+
if (findNameConflicts(catalog).length === 0) {
|
|
177
|
+
outro("No name conflicts.");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
while (true) {
|
|
181
|
+
const conflicts = findNameConflicts(catalog);
|
|
182
|
+
if (conflicts.length === 0)
|
|
183
|
+
break;
|
|
184
|
+
const options = conflicts.flatMap((conflict) => conflict.ids.map((id) => {
|
|
185
|
+
if (conflict.namespace === "item") {
|
|
186
|
+
const item = catalog.items.find((entry) => entry.id === id);
|
|
187
|
+
return { value: `item:${id}`, label: `[${item.kind}] ${item.name}`, hint: savedItemHint(catalog, item) };
|
|
188
|
+
}
|
|
189
|
+
const group = catalog.groups.find((entry) => entry.id === id);
|
|
190
|
+
return { value: `group:${id}`, label: `[group] ${group.name}`, hint: `${group.itemIds.length} resources` };
|
|
191
|
+
}));
|
|
192
|
+
const selected = await select({ message: "Select a conflicting name to rename", options });
|
|
193
|
+
if (isCancel(selected)) {
|
|
194
|
+
cancel("Name changes were not saved.");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const namespace = selected.startsWith("item:") ? "item" : "group";
|
|
198
|
+
const id = selected.slice(namespace.length + 1);
|
|
199
|
+
const entries = namespace === "item" ? catalog.items : catalog.groups;
|
|
200
|
+
const entry = entries.find((entry) => entry.id === id);
|
|
201
|
+
if (!entry)
|
|
202
|
+
throw new Error("Selected name is no longer available.");
|
|
203
|
+
const name = await text({
|
|
204
|
+
message: `New name for ${entry.name}`,
|
|
205
|
+
validate: (value) => {
|
|
206
|
+
try {
|
|
207
|
+
assertNameAvailable(catalog, namespace, value ?? "", id);
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
return error instanceof Error ? error.message : "Choose a unique non-empty name.";
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
if (isCancel(name)) {
|
|
216
|
+
cancel("Name changes were not saved.");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
entry.name = name.trim();
|
|
220
|
+
}
|
|
221
|
+
const changes = [];
|
|
222
|
+
for (const namespace of ["item", "group"]) {
|
|
223
|
+
const oldEntries = namespace === "item" ? before.items : before.groups;
|
|
224
|
+
const nextEntries = namespace === "item" ? catalog.items : catalog.groups;
|
|
225
|
+
for (const entry of nextEntries) {
|
|
226
|
+
const previous = oldEntries.find((previous) => previous.id === entry.id);
|
|
227
|
+
if (previous.name !== entry.name)
|
|
228
|
+
changes.push(`[${namespace}] ${previous.name} → ${entry.name}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
note(changes.join("\n"), "Explicit name changes; saved contents and destinations stay unchanged");
|
|
232
|
+
const save = await confirm({ message: "Save these name changes?", initialValue: false });
|
|
233
|
+
if (isCancel(save) || !save) {
|
|
234
|
+
outro("Name changes were not saved.");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
assertNoNameConflicts(catalog);
|
|
238
|
+
await saveNameChanges(libraryDir, before, catalog);
|
|
239
|
+
outro("Name conflicts resolved. Run setup or groups again to make other changes.");
|
|
240
|
+
}
|
|
241
|
+
async function handleNameConflicts(libraryDir) {
|
|
242
|
+
if (findNameConflicts(await loadCatalog(libraryDir)).length === 0)
|
|
243
|
+
return false;
|
|
244
|
+
while (true) {
|
|
245
|
+
const catalog = await loadCatalog(libraryDir);
|
|
246
|
+
const conflicts = findNameConflicts(catalog);
|
|
247
|
+
if (conflicts.length === 0)
|
|
248
|
+
return true;
|
|
249
|
+
const modern = catalog.version === 2;
|
|
250
|
+
note(conflicts.map((conflict) => {
|
|
251
|
+
const entries = conflict.namespace === "item" ? catalog.items : catalog.groups;
|
|
252
|
+
return `[${conflict.namespace}] ${conflict.ids.map((id) => entries.find((entry) => entry.id === id).name).join(" / ")}`;
|
|
253
|
+
}).join("\n"), modern ? "Duplicate names: only listing, masked viewing, and resolution are allowed" : "Legacy catalog v1 is read-only; duplicate names are not changed here");
|
|
254
|
+
const action = await select({
|
|
255
|
+
message: "Unresolved names",
|
|
256
|
+
options: [
|
|
257
|
+
{ value: "view", label: "View saved resources or groups (masked)" },
|
|
258
|
+
...(modern ? [{ value: "resolve", label: "Resolve name conflicts" }] : []),
|
|
259
|
+
{ value: "done", label: "Finish without changes" },
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
if (isCancel(action) || action === "done")
|
|
263
|
+
return true;
|
|
264
|
+
if (action === "view")
|
|
265
|
+
await viewSavedEntry(libraryDir);
|
|
266
|
+
else if (modern)
|
|
267
|
+
await resolveNameConflicts(libraryDir);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
62
270
|
function groupOptions(groups) {
|
|
63
271
|
return groups.map((group) => ({ value: group.id, label: group.name, hint: `${group.itemIds.length} saved resources` }));
|
|
64
272
|
}
|
|
65
273
|
async function askForGroupName(message) {
|
|
66
274
|
const value = await text({
|
|
67
275
|
message,
|
|
68
|
-
validate: (name) =>
|
|
276
|
+
validate: (name) => {
|
|
277
|
+
try {
|
|
278
|
+
nameKey(name ?? "");
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return "Enter a non-empty group name.";
|
|
283
|
+
}
|
|
284
|
+
},
|
|
69
285
|
});
|
|
70
286
|
return isCancel(value) ? undefined : value.trim();
|
|
71
287
|
}
|
|
72
288
|
export async function runSetup(libraryDir, roots) {
|
|
289
|
+
if (await handleNameConflicts(libraryDir))
|
|
290
|
+
return;
|
|
291
|
+
const initial = await loadCatalog(libraryDir);
|
|
292
|
+
assertModernCatalog(initial);
|
|
293
|
+
assertNoNameConflicts(initial);
|
|
73
294
|
intro("copi setup");
|
|
74
295
|
const scopeOptions = [];
|
|
75
296
|
if (roots.homeDir) {
|
|
@@ -111,7 +332,17 @@ export async function runSetup(libraryDir, roots) {
|
|
|
111
332
|
outro("Search not authorized; no resources were inspected or saved.");
|
|
112
333
|
return;
|
|
113
334
|
}
|
|
114
|
-
const
|
|
335
|
+
const progress = clackSpinner(inheritedSignal());
|
|
336
|
+
progress.start("Searching selected locations (searching all of HOME can take a minute or more)");
|
|
337
|
+
let discovery;
|
|
338
|
+
try {
|
|
339
|
+
discovery = await discoverResources(searchOptions);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
progress.error("Search failed");
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
progress.stop(`Search complete: ${discovery.candidates.length} supported resource(s) found`);
|
|
115
346
|
showWarnings(discovery.warnings, "Search warnings");
|
|
116
347
|
if (discovery.excludedPaths.length > 0)
|
|
117
348
|
note(discovery.excludedPaths.join("\n"), "Excluded paths");
|
|
@@ -138,6 +369,7 @@ export async function runSetup(libraryDir, roots) {
|
|
|
138
369
|
return;
|
|
139
370
|
}
|
|
140
371
|
const catalog = await loadCatalog(libraryDir);
|
|
372
|
+
assertModernCatalog(catalog);
|
|
141
373
|
if (catalog.groups.length === 0) {
|
|
142
374
|
const name = await askForGroupName("Create the first resource group");
|
|
143
375
|
if (name === undefined) {
|
|
@@ -189,7 +421,7 @@ export async function runSetup(libraryDir, roots) {
|
|
|
189
421
|
allSuspectedFilesApproved = false;
|
|
190
422
|
}
|
|
191
423
|
if (!allSuspectedFilesApproved) {
|
|
192
|
-
log.warn(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`);
|
|
424
|
+
log.warn(safeDisplay(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`));
|
|
193
425
|
continue;
|
|
194
426
|
}
|
|
195
427
|
const groupIds = await autocompleteMultiselect({
|
|
@@ -209,7 +441,7 @@ export async function runSetup(libraryDir, roots) {
|
|
|
209
441
|
return;
|
|
210
442
|
}
|
|
211
443
|
const summary = approvedCandidates.map(({ review, groupIds }) => {
|
|
212
|
-
const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ??
|
|
444
|
+
const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? "Unavailable group").join(", ");
|
|
213
445
|
return `${review.candidate.name} → ${names}`;
|
|
214
446
|
}).join("\n");
|
|
215
447
|
note(summary, "Resources ready to copy");
|
|
@@ -221,6 +453,18 @@ export async function runSetup(libraryDir, roots) {
|
|
|
221
453
|
outro("Nothing was saved.");
|
|
222
454
|
return;
|
|
223
455
|
}
|
|
456
|
+
// Validate the whole batch before saving groups or any snapshot.
|
|
457
|
+
const pending = structuredClone(catalog);
|
|
458
|
+
assertNoNameConflicts(pending);
|
|
459
|
+
const pendingItemNames = new Set(pending.items.map((item) => nameKey(item.name)));
|
|
460
|
+
for (const { review } of approvedCandidates) {
|
|
461
|
+
const candidate = readPreparedResource(review.prepared).candidate;
|
|
462
|
+
assertNameAvailable(pending, "item", candidate.name);
|
|
463
|
+
const candidateName = nameKey(candidate.name);
|
|
464
|
+
if (pendingItemNames.has(candidateName))
|
|
465
|
+
throw new Error("Duplicate item name; choose a unique display name.");
|
|
466
|
+
pendingItemNames.add(candidateName);
|
|
467
|
+
}
|
|
224
468
|
await saveCatalog(libraryDir, catalog);
|
|
225
469
|
for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
|
|
226
470
|
await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
|
|
@@ -228,7 +472,10 @@ export async function runSetup(libraryDir, roots) {
|
|
|
228
472
|
outro(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryDir}.`);
|
|
229
473
|
}
|
|
230
474
|
export async function runGroupManager(libraryDir) {
|
|
475
|
+
if (await handleNameConflicts(libraryDir))
|
|
476
|
+
return;
|
|
231
477
|
const catalog = await loadCatalog(libraryDir);
|
|
478
|
+
assertModernCatalog(catalog);
|
|
232
479
|
if (catalog.groups.length === 0) {
|
|
233
480
|
note("No groups are saved yet.", "Groups");
|
|
234
481
|
}
|
|
@@ -254,6 +501,7 @@ export async function runGroupManager(libraryDir) {
|
|
|
254
501
|
}
|
|
255
502
|
if (action === "done")
|
|
256
503
|
break;
|
|
504
|
+
assertModernCatalog(catalog);
|
|
257
505
|
if (action === "create") {
|
|
258
506
|
const name = await askForGroupName("New group name");
|
|
259
507
|
if (name === undefined) {
|
|
@@ -307,6 +555,7 @@ export async function runGroupManager(libraryDir) {
|
|
|
307
555
|
outro("Group changes were not saved.");
|
|
308
556
|
return;
|
|
309
557
|
}
|
|
558
|
+
assertModernCatalog(catalog);
|
|
310
559
|
await saveCatalog(libraryDir, catalog);
|
|
311
560
|
outro("Group changes saved.");
|
|
312
561
|
}
|
|
@@ -316,7 +565,7 @@ export function printGroups(catalog) {
|
|
|
316
565
|
return;
|
|
317
566
|
}
|
|
318
567
|
for (const group of catalog.groups)
|
|
319
|
-
process.stdout.write(`${group.name}
|
|
568
|
+
process.stdout.write(`${safeDisplay(group.name)} — ${group.itemIds.length} resource(s)\n`);
|
|
320
569
|
}
|
|
321
570
|
export function printItems(items) {
|
|
322
571
|
if (items.length === 0) {
|
|
@@ -324,18 +573,18 @@ export function printItems(items) {
|
|
|
324
573
|
return;
|
|
325
574
|
}
|
|
326
575
|
for (const item of items) {
|
|
327
|
-
process.stdout.write(`${item.name} [${item.kind}]
|
|
576
|
+
process.stdout.write(`${safeDisplay(item.name)} [${item.kind}] — ${item.files.length} file(s), ${safeDisplay(item.destination.kind === "global-only" ? "saved only" : item.destination.path)}\n`);
|
|
328
577
|
}
|
|
329
578
|
}
|
|
330
579
|
function resolveGroup(catalog, value) {
|
|
331
580
|
const matching = catalog.groups.filter((group) => group.id === value || group.name === value);
|
|
332
581
|
if (matching.length === 0)
|
|
333
|
-
throw new Error(
|
|
582
|
+
throw new Error("Unknown group; select a saved group with copi manage.");
|
|
334
583
|
if (matching.length > 1)
|
|
335
|
-
throw new Error(
|
|
584
|
+
throw new Error("Group name is ambiguous; resolve name conflicts with copi manage.");
|
|
336
585
|
const group = matching[0];
|
|
337
586
|
if (!group)
|
|
338
|
-
throw new Error(
|
|
587
|
+
throw new Error("Selected group is unavailable.");
|
|
339
588
|
return group;
|
|
340
589
|
}
|
|
341
590
|
function formatPlan(plan, catalog) {
|
|
@@ -344,26 +593,32 @@ function formatPlan(plan, catalog) {
|
|
|
344
593
|
const mark = file.alternatives.some((option) => option.sensitive) ? " [suspected secret; approval required]" : "";
|
|
345
594
|
const preview = file.preview.kind === "text" ? `\n${file.preview.diff}` : `\n${file.preview.summary}`;
|
|
346
595
|
const alternatives = file.alternatives.length > 1
|
|
347
|
-
? `\n candidates: ${file.alternatives.map((option) => itemNames.get(option.itemId) ??
|
|
596
|
+
? `\n candidates: ${file.alternatives.map((option) => itemNames.get(option.itemId) ?? "Unavailable resource").join(", ")}`
|
|
348
597
|
: "";
|
|
349
598
|
return `${file.status}: ${file.path}${mark}${alternatives}${preview}`;
|
|
350
599
|
});
|
|
600
|
+
const proposal = plan.referenceProposal;
|
|
601
|
+
const reference = proposal
|
|
602
|
+
? [`reference (optional, approval required during apply): ${proposal.status}: ${proposal.path}${proposal.preview.kind === "text" ? `\n${proposal.preview.diff}` : `\n${proposal.preview.summary}`}`]
|
|
603
|
+
: [];
|
|
351
604
|
const warnings = plan.warnings.map((warning) => `warning${warning.path ? ` ${warning.path}` : ""}: ${warning.message}`);
|
|
352
|
-
const skipped = plan.skippedItems.map((id) => `save-only resource skipped from apply: ${itemNames.get(id) ??
|
|
353
|
-
return [...lines, ...warnings, ...skipped].join("\n\n") || "No project files need changes.";
|
|
605
|
+
const skipped = plan.skippedItems.map((id) => `save-only resource skipped from apply: ${itemNames.get(id) ?? "Unavailable resource"}`);
|
|
606
|
+
return [...lines, ...reference, ...warnings, ...skipped].join("\n\n") || "No project files need changes.";
|
|
354
607
|
}
|
|
355
608
|
export function formatApplyOptionPreview(filePath, resourceName, option) {
|
|
356
609
|
const preview = option.preview.kind === "text" ? option.preview.diff : option.preview.summary;
|
|
357
|
-
return `${resourceName} → ${filePath}\n${preview}
|
|
610
|
+
return safeDisplay(`${resourceName} → ${filePath}\n${preview}`);
|
|
358
611
|
}
|
|
359
612
|
export async function runApplyDryRun(libraryDir, groupValue, projectDir) {
|
|
360
613
|
const catalog = await loadCatalog(libraryDir);
|
|
614
|
+
assertModernCatalog(catalog);
|
|
361
615
|
const group = resolveGroup(catalog, groupValue);
|
|
362
616
|
const plan = await planApply(libraryDir, group.id, projectDir);
|
|
363
617
|
note(formatPlan(plan, catalog), `Dry run: ${group.name} → ${projectDir}`);
|
|
364
618
|
}
|
|
365
619
|
export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
366
620
|
const catalog = await loadCatalog(libraryDir);
|
|
621
|
+
assertModernCatalog(catalog);
|
|
367
622
|
const group = resolveGroup(catalog, groupValue);
|
|
368
623
|
const plan = await planApply(libraryDir, group.id, projectDir);
|
|
369
624
|
if (plan.files.length === 0) {
|
|
@@ -383,7 +638,7 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
|
383
638
|
message: `Choose one resource for colliding path ${file.path}`,
|
|
384
639
|
options: file.alternatives.map((option) => ({
|
|
385
640
|
value: option.itemId,
|
|
386
|
-
label: catalog.items.find((item) => item.id === option.itemId)?.name ??
|
|
641
|
+
label: catalog.items.find((item) => item.id === option.itemId)?.name ?? "Unavailable resource",
|
|
387
642
|
hint: option.preview.kind === "text" ? "masked text diff available" : option.preview.summary,
|
|
388
643
|
})),
|
|
389
644
|
});
|
|
@@ -396,7 +651,7 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
|
396
651
|
throw new Error(`Selected resource is unavailable for ${file.path}.`);
|
|
397
652
|
collisionDecisions[file.path] = selectedItem;
|
|
398
653
|
sensitive = selectedOption.sensitive;
|
|
399
|
-
const selectedName = catalog.items.find((item) => item.id === selectedItem)?.name ??
|
|
654
|
+
const selectedName = catalog.items.find((item) => item.id === selectedItem)?.name ?? "Unavailable resource";
|
|
400
655
|
note(formatApplyOptionPreview(file.path, selectedName, selectedOption), "Selected colliding resource preview");
|
|
401
656
|
}
|
|
402
657
|
let hasExistingFile = file.status === "different";
|
|
@@ -450,6 +705,18 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
|
450
705
|
}
|
|
451
706
|
}
|
|
452
707
|
decisions.secretApprovals = [...selectedSecrets];
|
|
708
|
+
if (plan.referenceProposal) {
|
|
709
|
+
const proposal = plan.referenceProposal;
|
|
710
|
+
const addReference = await confirm({
|
|
711
|
+
message: `${proposal.status === "new" ? "Create" : "Append to"} ${proposal.path} with a reference line for ${proposal.instructionPaths.join(", ")}? Pi does not load these files automatically.`,
|
|
712
|
+
initialValue: false,
|
|
713
|
+
});
|
|
714
|
+
if (isCancel(addReference)) {
|
|
715
|
+
cancel("Apply cancelled; project files were not changed.");
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
decisions.addReference = addReference;
|
|
719
|
+
}
|
|
453
720
|
const apply = await confirm({ message: `Apply the selected snapshots to ${projectDir}?`, initialValue: false });
|
|
454
721
|
if (isCancel(apply) || !apply) {
|
|
455
722
|
outro("Project files were not changed.");
|
|
@@ -460,5 +727,6 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
|
460
727
|
outro("Apply aborted; project files were not changed.");
|
|
461
728
|
return;
|
|
462
729
|
}
|
|
463
|
-
|
|
730
|
+
const referenceNote = result.referenceAdded ? ` Added a reference line to ${result.referenceAdded}.` : "";
|
|
731
|
+
outro(`Applied ${result.written.length} file(s); kept ${result.kept.length}; saved ${result.backups.length} private backup(s).${referenceNote}`);
|
|
464
732
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@papillon6814/copi",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Collect and organize reusable Pi coding-agent resources",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist/src",
|
|
12
|
+
"dist/native",
|
|
12
13
|
"README.md",
|
|
13
|
-
"LICENSE"
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"LICENSE.unicode"
|
|
14
16
|
],
|
|
15
17
|
"engines": {
|
|
16
18
|
"node": ">=22.19.0"
|
|
@@ -19,7 +21,7 @@
|
|
|
19
21
|
"darwin"
|
|
20
22
|
],
|
|
21
23
|
"scripts": {
|
|
22
|
-
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"build": "tsc -p tsconfig.json && node scripts/build-native.mjs",
|
|
23
25
|
"test": "npm run build && node --test dist/test/*.test.js",
|
|
24
26
|
"check": "npm run build && npm test && npm pack --dry-run"
|
|
25
27
|
},
|