@papillon6814/copi 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.unicode +39 -0
- package/README.md +57 -5
- package/dist/native/rename-exclusive-arm64 +0 -0
- package/dist/native/rename-exclusive-arm64.manifest.json +12 -0
- package/dist/src/apply.d.ts +9 -0
- package/dist/src/apply.js +110 -9
- package/dist/src/catalog.d.ts +21 -4
- package/dist/src/catalog.js +265 -133
- package/dist/src/cli.d.ts +2 -1
- package/dist/src/cli.js +73 -3
- package/dist/src/groups.js +8 -3
- package/dist/src/inspect.d.ts +3 -1
- package/dist/src/inspect.js +16 -6
- package/dist/src/instruction-reference.d.ts +6 -0
- package/dist/src/instruction-reference.js +21 -0
- package/dist/src/manage.d.ts +2 -0
- package/dist/src/manage.js +329 -0
- package/dist/src/migrate.d.ts +25 -0
- package/dist/src/migrate.js +171 -0
- package/dist/src/migration-backup.d.ts +22 -0
- package/dist/src/migration-backup.js +403 -0
- package/dist/src/migration-inventory.d.ts +9 -0
- package/dist/src/migration-inventory.js +237 -0
- package/dist/src/migration-resolution.d.ts +28 -0
- package/dist/src/migration-resolution.js +1618 -0
- package/dist/src/migration.d.ts +49 -0
- package/dist/src/migration.js +1004 -0
- package/dist/src/names.d.ts +11 -0
- package/dist/src/names.js +40 -0
- package/dist/src/native-placement.d.ts +16 -0
- package/dist/src/native-placement.js +557 -0
- package/dist/src/refresh.d.ts +20 -0
- package/dist/src/refresh.js +143 -0
- package/dist/src/resource-mutations.d.ts +45 -0
- package/dist/src/resource-mutations.js +225 -0
- package/dist/src/source-content.d.ts +4 -0
- package/dist/src/source-content.js +208 -0
- package/dist/src/storage-journal.d.ts +111 -0
- package/dist/src/storage-journal.js +613 -0
- package/dist/src/storage-root-lease.d.ts +7 -0
- package/dist/src/storage-root-lease.js +92 -0
- package/dist/src/storage-transaction.d.ts +100 -0
- package/dist/src/storage-transaction.js +3951 -0
- package/dist/src/terminal-editor.d.ts +8 -0
- package/dist/src/terminal-editor.js +391 -0
- package/dist/src/types.d.ts +18 -2
- package/dist/src/unicode-case-folding-data.d.ts +1 -0
- package/dist/src/unicode-case-folding-data.js +1596 -0
- package/dist/src/unicode-case-folding.d.ts +2 -0
- package/dist/src/unicode-case-folding.js +9 -0
- package/dist/src/wizard.d.ts +11 -3
- package/dist/src/wizard.js +274 -18
- package/package.json +5 -3
|
@@ -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,13 @@
|
|
|
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
|
+
/** One process-wide interactive lifecycle shared by manage and its existing wizard routes. */
|
|
10
|
+
export declare function withWizardLifecycle(operation: (signal: AbortSignal) => Promise<void>): Promise<void>;
|
|
5
11
|
export interface SearchRoots {
|
|
6
12
|
homeDir: string;
|
|
7
13
|
agentDir: string;
|
|
@@ -21,10 +27,12 @@ export interface CandidateReview {
|
|
|
21
27
|
}
|
|
22
28
|
export declare function prepareCandidateForReview(candidate: ResourceCandidate): Promise<CandidateReview>;
|
|
23
29
|
export declare function saveReviewedCandidate(libraryDir: string, review: CandidateReview, groupIds: string[], approvedSensitivePaths: string[], confirmed: boolean): Promise<SavedItem | null>;
|
|
30
|
+
/** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
|
|
31
|
+
export declare function resolveNameConflicts(libraryDir: string): Promise<void>;
|
|
24
32
|
export declare function runSetup(libraryDir: string, roots: SearchRoots): Promise<void>;
|
|
25
33
|
export declare function runGroupManager(libraryDir: string): Promise<void>;
|
|
26
|
-
export declare function printGroups(catalog:
|
|
27
|
-
export declare function printItems(items: SavedItem
|
|
34
|
+
export declare function printGroups(catalog: StoredCatalog): void;
|
|
35
|
+
export declare function printItems(items: Array<LegacySavedItem | SavedItem>): void;
|
|
28
36
|
export declare function formatApplyOptionPreview(filePath: string, resourceName: string, option: ApplyFileOption): string;
|
|
29
37
|
export declare function runApplyDryRun(libraryDir: string, groupValue: string, projectDir: string): Promise<void>;
|
|
30
38
|
export declare function runApplyWizard(libraryDir: string, groupValue: string, projectDir: string): Promise<void>;
|
package/dist/src/wizard.js
CHANGED
|
@@ -1,11 +1,88 @@
|
|
|
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, 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
|
+
const autocompleteMultiselect = (options) => clackMultiselect({
|
|
38
|
+
...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message),
|
|
39
|
+
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) }) })),
|
|
40
|
+
});
|
|
41
|
+
const text = (options) => clackText({ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message) });
|
|
42
|
+
const confirm = (options) => clackConfirm({ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message) });
|
|
43
|
+
const note = (message, title, options) => clackNote(safeDisplay(message ?? ""), safeDisplay(title ?? ""), options);
|
|
44
|
+
const intro = (message, options) => clackIntro(safeDisplay(message ?? ""), options);
|
|
45
|
+
const outro = (message, options) => clackOutro(safeDisplay(message ?? ""), options);
|
|
46
|
+
const cancel = (message, options) => clackCancel(safeDisplay(message ?? ""), options);
|
|
47
|
+
/** One process-wide interactive lifecycle shared by manage and its existing wizard routes. */
|
|
48
|
+
export async function withWizardLifecycle(operation) {
|
|
49
|
+
if (activeSignal)
|
|
50
|
+
throw new Error("An interactive wizard is already active.");
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
const wasRaw = Boolean(process.stdin.isRaw), wasFlowing = process.stdin.readableFlowing === true;
|
|
53
|
+
const abort = () => controller.abort();
|
|
54
|
+
const eofKey = (chunk) => {
|
|
55
|
+
if (!editorOwnsControlInput && (typeof chunk === "string" ? chunk.includes("\u0004") : chunk.includes(4)))
|
|
56
|
+
abort();
|
|
57
|
+
};
|
|
58
|
+
activeSignal = controller.signal;
|
|
59
|
+
process.stdin.on("end", abort);
|
|
60
|
+
process.stdin.on("close", abort);
|
|
61
|
+
process.stdin.on("error", abort);
|
|
62
|
+
process.stdin.on("data", eofKey);
|
|
63
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
64
|
+
process.on(signal, abort);
|
|
65
|
+
try {
|
|
66
|
+
await operation(controller.signal);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
activeSignal = undefined;
|
|
70
|
+
process.stdin.off("end", abort);
|
|
71
|
+
process.stdin.off("close", abort);
|
|
72
|
+
process.stdin.off("error", abort);
|
|
73
|
+
process.stdin.off("data", eofKey);
|
|
74
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
75
|
+
process.off(signal, abort);
|
|
76
|
+
if (process.stdin.isTTY)
|
|
77
|
+
process.stdin.setRawMode(wasRaw);
|
|
78
|
+
// Adding our data listener starts an inactive reader too; restore its original ownership.
|
|
79
|
+
if (wasFlowing)
|
|
80
|
+
process.stdin.resume();
|
|
81
|
+
else
|
|
82
|
+
process.stdin.pause();
|
|
83
|
+
process.stdout.write("\u001b[?25h");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
9
86
|
const previewMaxBytes = 1_000_000;
|
|
10
87
|
const previewMaxFiles = 12;
|
|
11
88
|
const previewMaxLines = 24;
|
|
@@ -22,7 +99,7 @@ export function formatMaskedPreview(relativePath, content) {
|
|
|
22
99
|
const lines = masked.split(/\r?\n/);
|
|
23
100
|
const snippet = lines.slice(0, previewMaxLines).join("\n").slice(0, 2_000);
|
|
24
101
|
const omitted = lines.length > previewMaxLines ? `\n… ${lines.length - previewMaxLines} more lines` : "";
|
|
25
|
-
return `${relativePath}:\n${snippet}${omitted}
|
|
102
|
+
return safeDisplay(`${relativePath}:\n${snippet}${omitted}`);
|
|
26
103
|
}
|
|
27
104
|
function formatPreparedFiles(files) {
|
|
28
105
|
return files.slice(0, previewMaxFiles).map((file) => {
|
|
@@ -59,17 +136,159 @@ function showWarnings(warnings, title) {
|
|
|
59
136
|
return;
|
|
60
137
|
note(warnings.map((warning) => `${warning.path ? `${warning.path}: ` : ""}${warning.message}`).join("\n"), title);
|
|
61
138
|
}
|
|
139
|
+
function savedItemHint(catalog, item) {
|
|
140
|
+
const groups = catalog.groups.filter((group) => group.itemIds.includes(item.id)).map((group) => group.name).join(", ") || "no groups";
|
|
141
|
+
return `${groups} · ${item.source} · ${item.destination.kind === "project" ? item.destination.path : "saved only"}`;
|
|
142
|
+
}
|
|
143
|
+
async function viewSavedEntry(libraryDir) {
|
|
144
|
+
const catalog = await loadCatalog(libraryDir);
|
|
145
|
+
const entry = await select({
|
|
146
|
+
message: "Select saved resource or group",
|
|
147
|
+
options: [
|
|
148
|
+
...catalog.items.map((item) => ({ value: `item:${item.id}`, label: `[${item.kind}] ${item.name}`, hint: savedItemHint(catalog, item) })),
|
|
149
|
+
...catalog.groups.map((group) => ({ value: `group:${group.id}`, label: `[group] ${group.name}`, hint: `${group.itemIds.length} resources` })),
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
if (isCancel(entry))
|
|
153
|
+
return;
|
|
154
|
+
const item = catalog.items.find((item) => `item:${item.id}` === entry);
|
|
155
|
+
if (item) {
|
|
156
|
+
const files = await readSnapshotFiles(libraryDir, item);
|
|
157
|
+
note(formatPreparedFiles(files).join("\n\n") || "No previewable files.", `Masked saved preview: ${item.name}`);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
const group = catalog.groups.find((group) => `group:${group.id}` === entry);
|
|
161
|
+
if (!group)
|
|
162
|
+
throw new Error("Selected group is no longer available.");
|
|
163
|
+
note(group.itemIds.map((id) => {
|
|
164
|
+
const member = catalog.items.find((item) => item.id === id);
|
|
165
|
+
return member ? `[${member.kind}] ${member.name} · ${savedItemHint(catalog, member)}` : "Unavailable resource";
|
|
166
|
+
}).join("\n") || "No saved resources.", group.name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
|
|
170
|
+
export async function resolveNameConflicts(libraryDir) {
|
|
171
|
+
const before = await loadCatalog(libraryDir);
|
|
172
|
+
assertModernCatalog(before);
|
|
173
|
+
const catalog = structuredClone(before);
|
|
174
|
+
if (findNameConflicts(catalog).length === 0) {
|
|
175
|
+
outro("No name conflicts.");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
while (true) {
|
|
179
|
+
const conflicts = findNameConflicts(catalog);
|
|
180
|
+
if (conflicts.length === 0)
|
|
181
|
+
break;
|
|
182
|
+
const options = conflicts.flatMap((conflict) => conflict.ids.map((id) => {
|
|
183
|
+
if (conflict.namespace === "item") {
|
|
184
|
+
const item = catalog.items.find((entry) => entry.id === id);
|
|
185
|
+
return { value: `item:${id}`, label: `[${item.kind}] ${item.name}`, hint: savedItemHint(catalog, item) };
|
|
186
|
+
}
|
|
187
|
+
const group = catalog.groups.find((entry) => entry.id === id);
|
|
188
|
+
return { value: `group:${id}`, label: `[group] ${group.name}`, hint: `${group.itemIds.length} resources` };
|
|
189
|
+
}));
|
|
190
|
+
const selected = await select({ message: "Select a conflicting name to rename", options });
|
|
191
|
+
if (isCancel(selected)) {
|
|
192
|
+
cancel("Name changes were not saved.");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const namespace = selected.startsWith("item:") ? "item" : "group";
|
|
196
|
+
const id = selected.slice(namespace.length + 1);
|
|
197
|
+
const entries = namespace === "item" ? catalog.items : catalog.groups;
|
|
198
|
+
const entry = entries.find((entry) => entry.id === id);
|
|
199
|
+
if (!entry)
|
|
200
|
+
throw new Error("Selected name is no longer available.");
|
|
201
|
+
const name = await text({
|
|
202
|
+
message: `New name for ${entry.name}`,
|
|
203
|
+
validate: (value) => {
|
|
204
|
+
try {
|
|
205
|
+
assertNameAvailable(catalog, namespace, value ?? "", id);
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
return error instanceof Error ? error.message : "Choose a unique non-empty name.";
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
if (isCancel(name)) {
|
|
214
|
+
cancel("Name changes were not saved.");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
entry.name = name.trim();
|
|
218
|
+
}
|
|
219
|
+
const changes = [];
|
|
220
|
+
for (const namespace of ["item", "group"]) {
|
|
221
|
+
const oldEntries = namespace === "item" ? before.items : before.groups;
|
|
222
|
+
const nextEntries = namespace === "item" ? catalog.items : catalog.groups;
|
|
223
|
+
for (const entry of nextEntries) {
|
|
224
|
+
const previous = oldEntries.find((previous) => previous.id === entry.id);
|
|
225
|
+
if (previous.name !== entry.name)
|
|
226
|
+
changes.push(`[${namespace}] ${previous.name} → ${entry.name}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
note(changes.join("\n"), "Explicit name changes; saved contents and destinations stay unchanged");
|
|
230
|
+
const save = await confirm({ message: "Save these name changes?", initialValue: false });
|
|
231
|
+
if (isCancel(save) || !save) {
|
|
232
|
+
outro("Name changes were not saved.");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
assertNoNameConflicts(catalog);
|
|
236
|
+
await saveNameChanges(libraryDir, before, catalog);
|
|
237
|
+
outro("Name conflicts resolved. Run setup or groups again to make other changes.");
|
|
238
|
+
}
|
|
239
|
+
async function handleNameConflicts(libraryDir) {
|
|
240
|
+
if (findNameConflicts(await loadCatalog(libraryDir)).length === 0)
|
|
241
|
+
return false;
|
|
242
|
+
while (true) {
|
|
243
|
+
const catalog = await loadCatalog(libraryDir);
|
|
244
|
+
const conflicts = findNameConflicts(catalog);
|
|
245
|
+
if (conflicts.length === 0)
|
|
246
|
+
return true;
|
|
247
|
+
const modern = catalog.version === 2;
|
|
248
|
+
note(conflicts.map((conflict) => {
|
|
249
|
+
const entries = conflict.namespace === "item" ? catalog.items : catalog.groups;
|
|
250
|
+
return `[${conflict.namespace}] ${conflict.ids.map((id) => entries.find((entry) => entry.id === id).name).join(" / ")}`;
|
|
251
|
+
}).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");
|
|
252
|
+
const action = await select({
|
|
253
|
+
message: "Unresolved names",
|
|
254
|
+
options: [
|
|
255
|
+
{ value: "view", label: "View saved resources or groups (masked)" },
|
|
256
|
+
...(modern ? [{ value: "resolve", label: "Resolve name conflicts" }] : []),
|
|
257
|
+
{ value: "done", label: "Finish without changes" },
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
if (isCancel(action) || action === "done")
|
|
261
|
+
return true;
|
|
262
|
+
if (action === "view")
|
|
263
|
+
await viewSavedEntry(libraryDir);
|
|
264
|
+
else if (modern)
|
|
265
|
+
await resolveNameConflicts(libraryDir);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
62
268
|
function groupOptions(groups) {
|
|
63
269
|
return groups.map((group) => ({ value: group.id, label: group.name, hint: `${group.itemIds.length} saved resources` }));
|
|
64
270
|
}
|
|
65
271
|
async function askForGroupName(message) {
|
|
66
272
|
const value = await text({
|
|
67
273
|
message,
|
|
68
|
-
validate: (name) =>
|
|
274
|
+
validate: (name) => {
|
|
275
|
+
try {
|
|
276
|
+
nameKey(name ?? "");
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return "Enter a non-empty group name.";
|
|
281
|
+
}
|
|
282
|
+
},
|
|
69
283
|
});
|
|
70
284
|
return isCancel(value) ? undefined : value.trim();
|
|
71
285
|
}
|
|
72
286
|
export async function runSetup(libraryDir, roots) {
|
|
287
|
+
if (await handleNameConflicts(libraryDir))
|
|
288
|
+
return;
|
|
289
|
+
const initial = await loadCatalog(libraryDir);
|
|
290
|
+
assertModernCatalog(initial);
|
|
291
|
+
assertNoNameConflicts(initial);
|
|
73
292
|
intro("copi setup");
|
|
74
293
|
const scopeOptions = [];
|
|
75
294
|
if (roots.homeDir) {
|
|
@@ -138,6 +357,7 @@ export async function runSetup(libraryDir, roots) {
|
|
|
138
357
|
return;
|
|
139
358
|
}
|
|
140
359
|
const catalog = await loadCatalog(libraryDir);
|
|
360
|
+
assertModernCatalog(catalog);
|
|
141
361
|
if (catalog.groups.length === 0) {
|
|
142
362
|
const name = await askForGroupName("Create the first resource group");
|
|
143
363
|
if (name === undefined) {
|
|
@@ -189,7 +409,7 @@ export async function runSetup(libraryDir, roots) {
|
|
|
189
409
|
allSuspectedFilesApproved = false;
|
|
190
410
|
}
|
|
191
411
|
if (!allSuspectedFilesApproved) {
|
|
192
|
-
log.warn(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`);
|
|
412
|
+
log.warn(safeDisplay(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`));
|
|
193
413
|
continue;
|
|
194
414
|
}
|
|
195
415
|
const groupIds = await autocompleteMultiselect({
|
|
@@ -209,7 +429,7 @@ export async function runSetup(libraryDir, roots) {
|
|
|
209
429
|
return;
|
|
210
430
|
}
|
|
211
431
|
const summary = approvedCandidates.map(({ review, groupIds }) => {
|
|
212
|
-
const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ??
|
|
432
|
+
const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? "Unavailable group").join(", ");
|
|
213
433
|
return `${review.candidate.name} → ${names}`;
|
|
214
434
|
}).join("\n");
|
|
215
435
|
note(summary, "Resources ready to copy");
|
|
@@ -221,6 +441,18 @@ export async function runSetup(libraryDir, roots) {
|
|
|
221
441
|
outro("Nothing was saved.");
|
|
222
442
|
return;
|
|
223
443
|
}
|
|
444
|
+
// Validate the whole batch before saving groups or any snapshot.
|
|
445
|
+
const pending = structuredClone(catalog);
|
|
446
|
+
assertNoNameConflicts(pending);
|
|
447
|
+
const pendingItemNames = new Set(pending.items.map((item) => nameKey(item.name)));
|
|
448
|
+
for (const { review } of approvedCandidates) {
|
|
449
|
+
const candidate = readPreparedResource(review.prepared).candidate;
|
|
450
|
+
assertNameAvailable(pending, "item", candidate.name);
|
|
451
|
+
const candidateName = nameKey(candidate.name);
|
|
452
|
+
if (pendingItemNames.has(candidateName))
|
|
453
|
+
throw new Error("Duplicate item name; choose a unique display name.");
|
|
454
|
+
pendingItemNames.add(candidateName);
|
|
455
|
+
}
|
|
224
456
|
await saveCatalog(libraryDir, catalog);
|
|
225
457
|
for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
|
|
226
458
|
await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
|
|
@@ -228,7 +460,10 @@ export async function runSetup(libraryDir, roots) {
|
|
|
228
460
|
outro(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryDir}.`);
|
|
229
461
|
}
|
|
230
462
|
export async function runGroupManager(libraryDir) {
|
|
463
|
+
if (await handleNameConflicts(libraryDir))
|
|
464
|
+
return;
|
|
231
465
|
const catalog = await loadCatalog(libraryDir);
|
|
466
|
+
assertModernCatalog(catalog);
|
|
232
467
|
if (catalog.groups.length === 0) {
|
|
233
468
|
note("No groups are saved yet.", "Groups");
|
|
234
469
|
}
|
|
@@ -254,6 +489,7 @@ export async function runGroupManager(libraryDir) {
|
|
|
254
489
|
}
|
|
255
490
|
if (action === "done")
|
|
256
491
|
break;
|
|
492
|
+
assertModernCatalog(catalog);
|
|
257
493
|
if (action === "create") {
|
|
258
494
|
const name = await askForGroupName("New group name");
|
|
259
495
|
if (name === undefined) {
|
|
@@ -307,6 +543,7 @@ export async function runGroupManager(libraryDir) {
|
|
|
307
543
|
outro("Group changes were not saved.");
|
|
308
544
|
return;
|
|
309
545
|
}
|
|
546
|
+
assertModernCatalog(catalog);
|
|
310
547
|
await saveCatalog(libraryDir, catalog);
|
|
311
548
|
outro("Group changes saved.");
|
|
312
549
|
}
|
|
@@ -316,7 +553,7 @@ export function printGroups(catalog) {
|
|
|
316
553
|
return;
|
|
317
554
|
}
|
|
318
555
|
for (const group of catalog.groups)
|
|
319
|
-
process.stdout.write(`${group.name}
|
|
556
|
+
process.stdout.write(`${safeDisplay(group.name)} — ${group.itemIds.length} resource(s)\n`);
|
|
320
557
|
}
|
|
321
558
|
export function printItems(items) {
|
|
322
559
|
if (items.length === 0) {
|
|
@@ -324,18 +561,18 @@ export function printItems(items) {
|
|
|
324
561
|
return;
|
|
325
562
|
}
|
|
326
563
|
for (const item of items) {
|
|
327
|
-
process.stdout.write(`${item.name} [${item.kind}]
|
|
564
|
+
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
565
|
}
|
|
329
566
|
}
|
|
330
567
|
function resolveGroup(catalog, value) {
|
|
331
568
|
const matching = catalog.groups.filter((group) => group.id === value || group.name === value);
|
|
332
569
|
if (matching.length === 0)
|
|
333
|
-
throw new Error(
|
|
570
|
+
throw new Error("Unknown group; select a saved group with copi manage.");
|
|
334
571
|
if (matching.length > 1)
|
|
335
|
-
throw new Error(
|
|
572
|
+
throw new Error("Group name is ambiguous; resolve name conflicts with copi manage.");
|
|
336
573
|
const group = matching[0];
|
|
337
574
|
if (!group)
|
|
338
|
-
throw new Error(
|
|
575
|
+
throw new Error("Selected group is unavailable.");
|
|
339
576
|
return group;
|
|
340
577
|
}
|
|
341
578
|
function formatPlan(plan, catalog) {
|
|
@@ -344,26 +581,32 @@ function formatPlan(plan, catalog) {
|
|
|
344
581
|
const mark = file.alternatives.some((option) => option.sensitive) ? " [suspected secret; approval required]" : "";
|
|
345
582
|
const preview = file.preview.kind === "text" ? `\n${file.preview.diff}` : `\n${file.preview.summary}`;
|
|
346
583
|
const alternatives = file.alternatives.length > 1
|
|
347
|
-
? `\n candidates: ${file.alternatives.map((option) => itemNames.get(option.itemId) ??
|
|
584
|
+
? `\n candidates: ${file.alternatives.map((option) => itemNames.get(option.itemId) ?? "Unavailable resource").join(", ")}`
|
|
348
585
|
: "";
|
|
349
586
|
return `${file.status}: ${file.path}${mark}${alternatives}${preview}`;
|
|
350
587
|
});
|
|
588
|
+
const proposal = plan.referenceProposal;
|
|
589
|
+
const reference = proposal
|
|
590
|
+
? [`reference (optional, approval required during apply): ${proposal.status}: ${proposal.path}${proposal.preview.kind === "text" ? `\n${proposal.preview.diff}` : `\n${proposal.preview.summary}`}`]
|
|
591
|
+
: [];
|
|
351
592
|
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.";
|
|
593
|
+
const skipped = plan.skippedItems.map((id) => `save-only resource skipped from apply: ${itemNames.get(id) ?? "Unavailable resource"}`);
|
|
594
|
+
return [...lines, ...reference, ...warnings, ...skipped].join("\n\n") || "No project files need changes.";
|
|
354
595
|
}
|
|
355
596
|
export function formatApplyOptionPreview(filePath, resourceName, option) {
|
|
356
597
|
const preview = option.preview.kind === "text" ? option.preview.diff : option.preview.summary;
|
|
357
|
-
return `${resourceName} → ${filePath}\n${preview}
|
|
598
|
+
return safeDisplay(`${resourceName} → ${filePath}\n${preview}`);
|
|
358
599
|
}
|
|
359
600
|
export async function runApplyDryRun(libraryDir, groupValue, projectDir) {
|
|
360
601
|
const catalog = await loadCatalog(libraryDir);
|
|
602
|
+
assertModernCatalog(catalog);
|
|
361
603
|
const group = resolveGroup(catalog, groupValue);
|
|
362
604
|
const plan = await planApply(libraryDir, group.id, projectDir);
|
|
363
605
|
note(formatPlan(plan, catalog), `Dry run: ${group.name} → ${projectDir}`);
|
|
364
606
|
}
|
|
365
607
|
export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
366
608
|
const catalog = await loadCatalog(libraryDir);
|
|
609
|
+
assertModernCatalog(catalog);
|
|
367
610
|
const group = resolveGroup(catalog, groupValue);
|
|
368
611
|
const plan = await planApply(libraryDir, group.id, projectDir);
|
|
369
612
|
if (plan.files.length === 0) {
|
|
@@ -383,7 +626,7 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
|
383
626
|
message: `Choose one resource for colliding path ${file.path}`,
|
|
384
627
|
options: file.alternatives.map((option) => ({
|
|
385
628
|
value: option.itemId,
|
|
386
|
-
label: catalog.items.find((item) => item.id === option.itemId)?.name ??
|
|
629
|
+
label: catalog.items.find((item) => item.id === option.itemId)?.name ?? "Unavailable resource",
|
|
387
630
|
hint: option.preview.kind === "text" ? "masked text diff available" : option.preview.summary,
|
|
388
631
|
})),
|
|
389
632
|
});
|
|
@@ -396,7 +639,7 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
|
396
639
|
throw new Error(`Selected resource is unavailable for ${file.path}.`);
|
|
397
640
|
collisionDecisions[file.path] = selectedItem;
|
|
398
641
|
sensitive = selectedOption.sensitive;
|
|
399
|
-
const selectedName = catalog.items.find((item) => item.id === selectedItem)?.name ??
|
|
642
|
+
const selectedName = catalog.items.find((item) => item.id === selectedItem)?.name ?? "Unavailable resource";
|
|
400
643
|
note(formatApplyOptionPreview(file.path, selectedName, selectedOption), "Selected colliding resource preview");
|
|
401
644
|
}
|
|
402
645
|
let hasExistingFile = file.status === "different";
|
|
@@ -450,6 +693,18 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
|
450
693
|
}
|
|
451
694
|
}
|
|
452
695
|
decisions.secretApprovals = [...selectedSecrets];
|
|
696
|
+
if (plan.referenceProposal) {
|
|
697
|
+
const proposal = plan.referenceProposal;
|
|
698
|
+
const addReference = await confirm({
|
|
699
|
+
message: `${proposal.status === "new" ? "Create" : "Append to"} ${proposal.path} with a reference line for ${proposal.instructionPaths.join(", ")}? Pi does not load these files automatically.`,
|
|
700
|
+
initialValue: false,
|
|
701
|
+
});
|
|
702
|
+
if (isCancel(addReference)) {
|
|
703
|
+
cancel("Apply cancelled; project files were not changed.");
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
decisions.addReference = addReference;
|
|
707
|
+
}
|
|
453
708
|
const apply = await confirm({ message: `Apply the selected snapshots to ${projectDir}?`, initialValue: false });
|
|
454
709
|
if (isCancel(apply) || !apply) {
|
|
455
710
|
outro("Project files were not changed.");
|
|
@@ -460,5 +715,6 @@ export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
|
460
715
|
outro("Apply aborted; project files were not changed.");
|
|
461
716
|
return;
|
|
462
717
|
}
|
|
463
|
-
|
|
718
|
+
const referenceNote = result.referenceAdded ? ` Added a reference line to ${result.referenceAdded}.` : "";
|
|
719
|
+
outro(`Applied ${result.written.length} file(s); kept ${result.kept.length}; saved ${result.backups.length} private backup(s).${referenceNote}`);
|
|
464
720
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@papillon6814/copi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
},
|