@papillon6814/copi 0.2.0 → 0.3.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/README.md +5 -1
- package/dist/src/discovery.js +8 -0
- package/dist/src/setup-display.d.ts +43 -0
- package/dist/src/setup-display.js +195 -0
- package/dist/src/wizard.d.ts +9 -0
- package/dist/src/wizard.js +249 -55
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -26,7 +26,11 @@ copi migrate --resolve [--dry-run]
|
|
|
26
26
|
copi help
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
`setup` requires an interactive terminal. It asks
|
|
29
|
+
`setup` requires an interactive terminal. It first asks where copi may look: your whole `HOME` (the default; this can take a minute or more), this project only, or both. Selecting the entire `HOME` is broad: it can inspect supported resources throughout that directory, including Pi profiles and the configured Pi agent directory. Selecting the current project scopes discovery to that project. Copi then asks for explicit authorization before searching; declining performs no search. Search warnings are summarized (for example, resources found outside the selected roots are counted rather than listed one by one), and missing files are skipped silently.
|
|
30
|
+
|
|
31
|
+
Candidates are listed with a source label, size and modification date, and entries whose name matches an already saved resource are marked `already saved`. After you select resources, name conflicts are resolved right away, before any preview: keep one of the same-named selections, rename the display name only (the files, body and destination are not changed), skip the resource (keeping the saved copy), or go back to the list.
|
|
32
|
+
|
|
33
|
+
A group is a named set of saved resources that you later apply together with `copi apply <group> <project>`. If no group exists yet, setup asks you to create the first one. With several resources, setup asks once how to group them: by default all of them go into the same group(s); you can instead choose groups for each resource. Each resource is then reviewed in turn with a masked preview titled with its progress (for example `1/3`) and its source directory. A suspected sensitive file needs its own approval; answering No means that resource is not saved at all. Finally, one confirmation lists everything that will be copied into the library; the original files are never changed. When it is done, setup prints the next step, for example `copi apply <group> <project> --dry-run`.
|
|
30
34
|
|
|
31
35
|
`manage` requires both stdin and stdout to be an interactive TTY. Choose saved resources and groups by name; UUID input is not required and normal screens identify resources by display name, kind, groups and destination. It provides masked viewing, terminal text editing, display-name changes, separate destination changes, source refresh, group management and apply. Running without arguments, or with `help`, `--help` or `-h`, still shows help.
|
|
32
36
|
|
package/dist/src/discovery.js
CHANGED
|
@@ -117,6 +117,10 @@ async function canonicalDirectory(directory) {
|
|
|
117
117
|
return undefined;
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
+
/** False only when the entry itself does not exist (lstat ENOENT); dangling symlinks count as existing. */
|
|
121
|
+
async function exists(target) {
|
|
122
|
+
return lstat(target).then(() => true, (error) => error.code !== "ENOENT");
|
|
123
|
+
}
|
|
120
124
|
export async function discoverResources(options) {
|
|
121
125
|
const scopes = options.scopes ?? inferSearchScopes(options);
|
|
122
126
|
if (!scopes.home && !scopes.project)
|
|
@@ -143,6 +147,8 @@ export async function discoverResources(options) {
|
|
|
143
147
|
if (project)
|
|
144
148
|
roots.push({ path: project, scope: "project", source: "project", destinationRoot: ".pi/skills", destinationKind: "project" });
|
|
145
149
|
const addCandidate = async (kind, root, sourcePath, name, scope, source, destination) => {
|
|
150
|
+
if (!(await exists(sourcePath)))
|
|
151
|
+
return;
|
|
146
152
|
const canonicalSource = await realpath(sourcePath).catch(() => undefined);
|
|
147
153
|
const canonicalRoot = await realpath(root).catch(() => undefined);
|
|
148
154
|
if (!canonicalSource || !canonicalRoot || !isWithin(canonicalRoot, canonicalSource)) {
|
|
@@ -209,6 +215,8 @@ export async function discoverResources(options) {
|
|
|
209
215
|
};
|
|
210
216
|
const canonicalStart = await realpath(startPath).catch(() => undefined);
|
|
211
217
|
if (!canonicalStart || !isWithin(canonicalRoot, canonicalStart)) {
|
|
218
|
+
if (!(await exists(startPath)))
|
|
219
|
+
return;
|
|
212
220
|
addWarning(warnings, "resource-outside-root", startPath, "A configured resource root resolves outside its allowed search root.");
|
|
213
221
|
return;
|
|
214
222
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ResourceCandidate } from "./types.ts";
|
|
2
|
+
/** Replace a leading home directory with `~` (only on a path-segment boundary). */
|
|
3
|
+
export declare function displayPath(p: string, home: string): string;
|
|
4
|
+
/** Keep head and tail joined by `…`; return `s` unchanged when `max` is missing, tiny, or not smaller than the length. */
|
|
5
|
+
export declare function truncateMiddle(s: string, max: number | undefined): string;
|
|
6
|
+
export declare function formatBytes(n: number): string;
|
|
7
|
+
/** Summarize skipped out-of-root resources into one note (undefined when none). */
|
|
8
|
+
export declare function formatOutsideRootSummary(paths: readonly string[], home: string): {
|
|
9
|
+
title: string;
|
|
10
|
+
body: string;
|
|
11
|
+
} | undefined;
|
|
12
|
+
export interface CandidateStats {
|
|
13
|
+
size?: string;
|
|
14
|
+
mtime?: Date;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `[kind] name — ~/source/dir`, plus `(already saved)`. Shared with conflict-resolution options.
|
|
18
|
+
* When `columns` is given the source directory is middle-truncated (tail kept) so the row does not wrap.
|
|
19
|
+
*/
|
|
20
|
+
export declare function candidateLabel(c: ResourceCandidate, home: string, alreadySaved: boolean, columns?: number): string;
|
|
21
|
+
/**
|
|
22
|
+
* Labels for a whole list. Rows sharing kind + nameKey must stay distinguishable: when truncation makes their
|
|
23
|
+
* source labels collide, that group falls back to whole trailing segments (`…/a/b`), widened until they differ.
|
|
24
|
+
*/
|
|
25
|
+
export declare function candidateLabels(list: readonly ResourceCandidate[], home: string, isSaved: (c: ResourceCandidate) => boolean, columns?: number): Map<string, string>;
|
|
26
|
+
/** Sort by kind, then nameKey, then source path (new array). */
|
|
27
|
+
export declare function sortCandidates(list: readonly ResourceCandidate[]): ResourceCandidate[];
|
|
28
|
+
export declare function formatLocalDate(d: Date): string;
|
|
29
|
+
/** `<size> · modified <date> · <destination>`; missing parts are omitted. Truncated to the terminal width minus label and prompt chrome. */
|
|
30
|
+
export declare function candidateHint(c: ResourceCandidate, stats: CandidateStats, home: string, columns: number | undefined, labelLength: number): string;
|
|
31
|
+
/** Cheap, never-throwing stats for the shown candidates, gathered in parallel. */
|
|
32
|
+
export declare function gatherCandidateStats(list: readonly ResourceCandidate[]): Promise<Map<string, CandidateStats>>;
|
|
33
|
+
/** Groups of 2+ selected candidates sharing a `nameKey` (any kind), each in input order; groups ordered by first member. */
|
|
34
|
+
export declare function findSelectionConflicts(list: readonly ResourceCandidate[]): ResourceCandidate[][];
|
|
35
|
+
/**
|
|
36
|
+
* Suggested rename `<name> (<segment>)`. Within a conflict group the segment is the nearest ancestor directory
|
|
37
|
+
* (walking up from the source's parent) whose name tells this member apart from the others — unique if possible,
|
|
38
|
+
* otherwise the first level where the members differ at all. Without such a level (or alone), the segment is the
|
|
39
|
+
* grandparent of the source, falling back to its parent, e.g. `jandel.md (agent-rbx)`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function defaultRenameName(c: ResourceCandidate, group?: readonly ResourceCandidate[]): string;
|
|
42
|
+
/** Validation message for a new display name, or undefined when it is non-empty and its `nameKey` is not taken. */
|
|
43
|
+
export declare function pendingNameError(name: string | undefined, taken: ReadonlySet<string>): string | undefined;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { lstat, readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { nameKey } from "./names.js";
|
|
4
|
+
/** Replace a leading home directory with `~` (only on a path-segment boundary). */
|
|
5
|
+
export function displayPath(p, home) {
|
|
6
|
+
if (!home)
|
|
7
|
+
return p;
|
|
8
|
+
const base = home.length > 1 && home.endsWith(path.sep) ? home.slice(0, -1) : home;
|
|
9
|
+
if (p === base)
|
|
10
|
+
return "~";
|
|
11
|
+
if (p.startsWith(base + path.sep))
|
|
12
|
+
return `~${p.slice(base.length)}`;
|
|
13
|
+
return p;
|
|
14
|
+
}
|
|
15
|
+
/** Keep head and tail joined by `…`; return `s` unchanged when `max` is missing, tiny, or not smaller than the length. */
|
|
16
|
+
export function truncateMiddle(s, max) {
|
|
17
|
+
if (max === undefined || !Number.isFinite(max) || max < 4 || s.length <= max)
|
|
18
|
+
return s;
|
|
19
|
+
const keep = max - 1;
|
|
20
|
+
const head = Math.ceil(keep / 2);
|
|
21
|
+
const tail = keep - head;
|
|
22
|
+
return `${s.slice(0, head)}…${tail > 0 ? s.slice(s.length - tail) : ""}`;
|
|
23
|
+
}
|
|
24
|
+
export function formatBytes(n) {
|
|
25
|
+
if (n < 1024)
|
|
26
|
+
return `${n} B`;
|
|
27
|
+
if (n < 1024 * 1024)
|
|
28
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
29
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
30
|
+
}
|
|
31
|
+
const outsideRootPreview = 5;
|
|
32
|
+
/** Summarize skipped out-of-root resources into one note (undefined when none). */
|
|
33
|
+
export function formatOutsideRootSummary(paths, home) {
|
|
34
|
+
if (paths.length === 0)
|
|
35
|
+
return undefined;
|
|
36
|
+
const lines = paths.slice(0, outsideRootPreview).map((p) => displayPath(p, home));
|
|
37
|
+
if (paths.length > outsideRootPreview)
|
|
38
|
+
lines.push(`+${paths.length - outsideRootPreview} more`);
|
|
39
|
+
return { title: `Skipped ${paths.length} resource(s) outside the selected locations`, body: lines.join("\n") };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `[kind] name — ~/source/dir`, plus `(already saved)`. Shared with conflict-resolution options.
|
|
43
|
+
* When `columns` is given the source directory is middle-truncated (tail kept) so the row does not wrap.
|
|
44
|
+
*/
|
|
45
|
+
export function candidateLabel(c, home, alreadySaved, columns) {
|
|
46
|
+
const head = `[${c.kind}] ${c.name} \u2014 `;
|
|
47
|
+
const tail = alreadySaved ? " (already saved)" : "";
|
|
48
|
+
let dir = displayPath(path.dirname(c.sourcePath), home);
|
|
49
|
+
if (columns !== undefined && Number.isFinite(columns))
|
|
50
|
+
dir = truncateMiddle(dir, Math.max(columns - head.length - tail.length - 6, 8));
|
|
51
|
+
return `${head}${dir}${tail}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Labels for a whole list. Rows sharing kind + nameKey must stay distinguishable: when truncation makes their
|
|
55
|
+
* source labels collide, that group falls back to whole trailing segments (`…/a/b`), widened until they differ.
|
|
56
|
+
*/
|
|
57
|
+
export function candidateLabels(list, home, isSaved, columns) {
|
|
58
|
+
const out = new Map();
|
|
59
|
+
const groups = new Map();
|
|
60
|
+
for (const c of list) {
|
|
61
|
+
out.set(c.id, candidateLabel(c, home, isSaved(c), columns));
|
|
62
|
+
const key = `${c.kind}\0${nameKey(c.name)}`;
|
|
63
|
+
groups.set(key, [...(groups.get(key) ?? []), c]);
|
|
64
|
+
}
|
|
65
|
+
for (const group of groups.values()) {
|
|
66
|
+
if (group.length < 2)
|
|
67
|
+
continue;
|
|
68
|
+
const labels = group.map((c) => out.get(c.id));
|
|
69
|
+
if (new Set(labels).size === labels.length)
|
|
70
|
+
continue;
|
|
71
|
+
const dirs = group.map((c) => displayPath(path.dirname(c.sourcePath), home));
|
|
72
|
+
const segs = dirs.map((d) => d.split(path.sep));
|
|
73
|
+
const max = Math.max(...segs.map((x) => x.length));
|
|
74
|
+
for (let k = 1; k <= max; k += 1) {
|
|
75
|
+
const cut = segs.map((x, i) => (x.length <= k ? dirs[i] : `\u2026${path.sep}${x.slice(-k).join(path.sep)}`));
|
|
76
|
+
const rendered = group.map((c, i) => `[${c.kind}] ${c.name} \u2014 ${cut[i]}${isSaved(c) ? " (already saved)" : ""}`);
|
|
77
|
+
if (new Set(rendered).size === rendered.length || k === max) {
|
|
78
|
+
group.forEach((c, i) => out.set(c.id, rendered[i]));
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
/** Sort by kind, then nameKey, then source path (new array). */
|
|
86
|
+
export function sortCandidates(list) {
|
|
87
|
+
const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
88
|
+
return [...list].sort((a, b) => cmp(a.kind, b.kind) || cmp(nameKey(a.name), nameKey(b.name)) || cmp(a.sourcePath, b.sourcePath));
|
|
89
|
+
}
|
|
90
|
+
export function formatLocalDate(d) {
|
|
91
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
92
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
93
|
+
}
|
|
94
|
+
/** `<size> · modified <date> · <destination>`; missing parts are omitted. Truncated to the terminal width minus label and prompt chrome. */
|
|
95
|
+
export function candidateHint(c, stats, home, columns, labelLength) {
|
|
96
|
+
const parts = [];
|
|
97
|
+
if (stats.size !== undefined)
|
|
98
|
+
parts.push(stats.size);
|
|
99
|
+
if (stats.mtime !== undefined)
|
|
100
|
+
parts.push(`modified ${formatLocalDate(stats.mtime)}`);
|
|
101
|
+
parts.push(c.destination.kind === "global-only" ? "save only" : displayPath(c.destination.path, home));
|
|
102
|
+
const hint = parts.join(" · ");
|
|
103
|
+
if (columns === undefined || !Number.isFinite(columns))
|
|
104
|
+
return hint;
|
|
105
|
+
return truncateMiddle(hint, Math.max(columns - labelLength - 10, 12));
|
|
106
|
+
}
|
|
107
|
+
const countCap = 1000;
|
|
108
|
+
async function countFiles(dir, budget) {
|
|
109
|
+
let entries;
|
|
110
|
+
try {
|
|
111
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (budget.left <= 0)
|
|
118
|
+
return;
|
|
119
|
+
if (entry.isDirectory())
|
|
120
|
+
await countFiles(path.join(dir, entry.name), budget);
|
|
121
|
+
else if (entry.isFile())
|
|
122
|
+
budget.left -= 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function hasMoreFiles(dir, cap) {
|
|
126
|
+
const budget = { left: cap + 1 };
|
|
127
|
+
await countFiles(dir, budget);
|
|
128
|
+
return budget.left <= 0;
|
|
129
|
+
}
|
|
130
|
+
async function statOne(c) {
|
|
131
|
+
try {
|
|
132
|
+
const st = await lstat(c.sourcePath);
|
|
133
|
+
if (st.isDirectory()) {
|
|
134
|
+
const budget = { left: countCap };
|
|
135
|
+
await countFiles(c.sourcePath, budget);
|
|
136
|
+
const n = countCap - budget.left;
|
|
137
|
+
return { size: budget.left <= 0 && (await hasMoreFiles(c.sourcePath, countCap)) ? `${countCap}+ files` : `${n} file${n === 1 ? "" : "s"}`, mtime: st.mtime };
|
|
138
|
+
}
|
|
139
|
+
return { size: formatBytes(st.size), mtime: st.mtime };
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Cheap, never-throwing stats for the shown candidates, gathered in parallel. */
|
|
146
|
+
export async function gatherCandidateStats(list) {
|
|
147
|
+
const entries = await Promise.all(list.map(async (c) => [c.id, await statOne(c)]));
|
|
148
|
+
return new Map(entries);
|
|
149
|
+
}
|
|
150
|
+
/** Groups of 2+ selected candidates sharing a `nameKey` (any kind), each in input order; groups ordered by first member. */
|
|
151
|
+
export function findSelectionConflicts(list) {
|
|
152
|
+
const groups = new Map();
|
|
153
|
+
for (const c of list) {
|
|
154
|
+
const key = nameKey(c.name);
|
|
155
|
+
groups.set(key, [...(groups.get(key) ?? []), c]);
|
|
156
|
+
}
|
|
157
|
+
return [...groups.values()].filter((group) => group.length > 1);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Suggested rename `<name> (<segment>)`. Within a conflict group the segment is the nearest ancestor directory
|
|
161
|
+
* (walking up from the source's parent) whose name tells this member apart from the others — unique if possible,
|
|
162
|
+
* otherwise the first level where the members differ at all. Without such a level (or alone), the segment is the
|
|
163
|
+
* grandparent of the source, falling back to its parent, e.g. `jandel.md (agent-rbx)`.
|
|
164
|
+
*/
|
|
165
|
+
export function defaultRenameName(c, group = [c]) {
|
|
166
|
+
const ancestors = (x) => {
|
|
167
|
+
const out = [];
|
|
168
|
+
for (let dir = path.dirname(x.sourcePath); path.basename(dir); dir = path.dirname(dir))
|
|
169
|
+
out.push(path.basename(dir));
|
|
170
|
+
return out;
|
|
171
|
+
};
|
|
172
|
+
const mine = ancestors(c);
|
|
173
|
+
const others = group.filter((x) => x.id !== c.id).map(ancestors);
|
|
174
|
+
let segment;
|
|
175
|
+
if (others.length > 0) {
|
|
176
|
+
const unique = mine.findIndex((name, level) => others.every((other) => other[level] !== name));
|
|
177
|
+
const differing = mine.findIndex((name, level) => others.some((other) => other[level] !== name));
|
|
178
|
+
const level = unique !== -1 ? unique : differing;
|
|
179
|
+
if (level !== -1)
|
|
180
|
+
segment = mine[level];
|
|
181
|
+
}
|
|
182
|
+
segment ??= mine[1] ?? mine[0];
|
|
183
|
+
return segment ? `${c.name} (${segment})` : c.name;
|
|
184
|
+
}
|
|
185
|
+
/** Validation message for a new display name, or undefined when it is non-empty and its `nameKey` is not taken. */
|
|
186
|
+
export function pendingNameError(name, taken) {
|
|
187
|
+
let key;
|
|
188
|
+
try {
|
|
189
|
+
key = nameKey(name ?? "");
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return "Enter a non-empty display name.";
|
|
193
|
+
}
|
|
194
|
+
return taken.has(key) ? "That name is already used by a saved or selected resource; choose another." : undefined;
|
|
195
|
+
}
|
package/dist/src/wizard.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { LegacySavedItem, ResourceCandidate, SavedItem, StoredCatalog } fro
|
|
|
6
6
|
export declare function safeDisplay(value: string): string;
|
|
7
7
|
/** The editor parses literal paste versus cancel keys; stream EOF/errors/signals stay global. */
|
|
8
8
|
export declare function withEditorInput<T>(operation: () => Promise<T>): Promise<T>;
|
|
9
|
+
export declare const multiselectKeyHint = "(Tab: select, Enter: confirm)";
|
|
9
10
|
/** One process-wide interactive lifecycle shared by manage and its existing wizard routes. */
|
|
10
11
|
export declare function withWizardLifecycle(operation: (signal: AbortSignal) => Promise<void>): Promise<void>;
|
|
11
12
|
export interface SearchRoots {
|
|
@@ -29,6 +30,14 @@ export declare function prepareCandidateForReview(candidate: ResourceCandidate):
|
|
|
29
30
|
export declare function saveReviewedCandidate(libraryDir: string, review: CandidateReview, groupIds: string[], approvedSensitivePaths: string[], confirmed: boolean): Promise<SavedItem | null>;
|
|
30
31
|
/** Resolve all staged conflicts in one name-only catalog save; cancellation writes nothing. */
|
|
31
32
|
export declare function resolveNameConflicts(libraryDir: string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Rename prompt text. The suggestion is prefilled only when it renders unchanged through `safeDisplay`
|
|
35
|
+
* (`initialValue` is echoed raw); otherwise the field stays empty and the masked suggestion is shown in the message.
|
|
36
|
+
*/
|
|
37
|
+
export declare function renamePrompt(name: string, sourceDir: string, suggestion: string): {
|
|
38
|
+
message: string;
|
|
39
|
+
initialValue?: string;
|
|
40
|
+
};
|
|
32
41
|
export declare function runSetup(libraryDir: string, roots: SearchRoots): Promise<void>;
|
|
33
42
|
export declare function runGroupManager(libraryDir: string): Promise<void>;
|
|
34
43
|
export declare function printGroups(catalog: StoredCatalog): void;
|
package/dist/src/wizard.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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";
|
|
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
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
|
+
import { candidateHint, candidateLabels, defaultRenameName, displayPath, findSelectionConflicts, formatOutsideRootSummary, gatherCandidateStats, pendingNameError, sortCandidates } from "./setup-display.js";
|
|
7
8
|
import { inspectPreparedResource, maskSensitiveText } from "./inspect.js";
|
|
8
9
|
import { assertNameAvailable, assertNoNameConflicts, findNameConflicts, nameKey } from "./names.js";
|
|
9
10
|
import { executeApply, planApply } from "./apply.js";
|
|
@@ -34,8 +35,10 @@ const select = (options) => clackSelect({
|
|
|
34
35
|
...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message),
|
|
35
36
|
options: options.options.map((option) => ({ ...option, label: safeDisplay(option.label ?? "Unavailable entry"), ...(option.hint === undefined ? {} : { hint: safeDisplay(option.hint) }) })),
|
|
36
37
|
});
|
|
38
|
+
// Clack's own key legend is dim and below the list; repeat it in the question so Tab-to-select is not missed.
|
|
39
|
+
export const multiselectKeyHint = "(Tab: select, Enter: confirm)";
|
|
37
40
|
const autocompleteMultiselect = (options) => clackMultiselect({
|
|
38
|
-
...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message)
|
|
41
|
+
...options, ...inheritedSignal(options.signal), message: `${safeDisplay(options.message)} ${multiselectKeyHint}`,
|
|
39
42
|
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
43
|
});
|
|
41
44
|
const text = (options) => clackText({ ...options, ...inheritedSignal(options.signal), message: safeDisplay(options.message) });
|
|
@@ -131,6 +134,12 @@ export async function saveReviewedCandidate(libraryDir, review, groupIds, approv
|
|
|
131
134
|
approvedSensitivePaths,
|
|
132
135
|
});
|
|
133
136
|
}
|
|
137
|
+
function showSearchWarnings(warnings, home) {
|
|
138
|
+
const summary = formatOutsideRootSummary(warnings.filter((warning) => warning.code === "resource-outside-root" && warning.path !== undefined).map((warning) => warning.path), home);
|
|
139
|
+
if (summary)
|
|
140
|
+
note(summary.body, summary.title);
|
|
141
|
+
showWarnings(warnings.filter((warning) => warning.code !== "resource-outside-root" || warning.path === undefined), "Search warnings");
|
|
142
|
+
}
|
|
134
143
|
function showWarnings(warnings, title) {
|
|
135
144
|
if (warnings.length === 0)
|
|
136
145
|
return;
|
|
@@ -265,12 +274,17 @@ async function handleNameConflicts(libraryDir) {
|
|
|
265
274
|
await resolveNameConflicts(libraryDir);
|
|
266
275
|
}
|
|
267
276
|
}
|
|
268
|
-
function groupOptions(groups) {
|
|
269
|
-
return groups.map((group) => ({
|
|
277
|
+
function groupOptions(groups, pendingCounts) {
|
|
278
|
+
return groups.map((group) => ({
|
|
279
|
+
value: group.id,
|
|
280
|
+
label: group.name,
|
|
281
|
+
hint: pendingCounts ? `${group.itemIds.length} saved \u00b7 ${pendingCounts.get(group.id) ?? 0} pending` : `${group.itemIds.length} saved resources`,
|
|
282
|
+
}));
|
|
270
283
|
}
|
|
271
|
-
async function askForGroupName(message) {
|
|
284
|
+
async function askForGroupName(message, placeholder) {
|
|
272
285
|
const value = await text({
|
|
273
286
|
message,
|
|
287
|
+
...(placeholder !== undefined ? { placeholder } : {}),
|
|
274
288
|
validate: (name) => {
|
|
275
289
|
try {
|
|
276
290
|
nameKey(name ?? "");
|
|
@@ -283,6 +297,106 @@ async function askForGroupName(message) {
|
|
|
283
297
|
});
|
|
284
298
|
return isCancel(value) ? undefined : value.trim();
|
|
285
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Rename prompt text. The suggestion is prefilled only when it renders unchanged through `safeDisplay`
|
|
302
|
+
* (`initialValue` is echoed raw); otherwise the field stays empty and the masked suggestion is shown in the message.
|
|
303
|
+
*/
|
|
304
|
+
export function renamePrompt(name, sourceDir, suggestion) {
|
|
305
|
+
const message = `New display name for ${name} \u2014 ${sourceDir} (display name only; files are not changed)`;
|
|
306
|
+
return safeDisplay(suggestion) === suggestion
|
|
307
|
+
? { message, initialValue: suggestion }
|
|
308
|
+
: { message: `${message}. Suggestion: ${suggestion}` };
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Resolve display-name conflicts right after selection (#9): within the selection first, one group at a time,
|
|
312
|
+
* then against saved library names. Returns the candidates to review (renamed ones are copies with a new
|
|
313
|
+
* display name only), "back" to re-show the list, or "cancel".
|
|
314
|
+
*/
|
|
315
|
+
async function resolveSelectedNames(selection, library, labels, homeDir) {
|
|
316
|
+
let pending = [...selection];
|
|
317
|
+
const libraryKeys = new Set(library.items.map((item) => nameKey(item.name)));
|
|
318
|
+
const askNewName = async (candidate, group = [candidate]) => {
|
|
319
|
+
// Taken: every saved name and every other pending name (assertNameAvailable semantics for the library).
|
|
320
|
+
const taken = new Set([...libraryKeys, ...pending.filter((other) => other.id !== candidate.id).map((other) => nameKey(other.name))]);
|
|
321
|
+
const value = await text({
|
|
322
|
+
...renamePrompt(candidate.name, displayPath(path.dirname(candidate.sourcePath), homeDir), defaultRenameName(candidate, group)),
|
|
323
|
+
validate: (name) => {
|
|
324
|
+
const error = pendingNameError(name, taken);
|
|
325
|
+
if (error)
|
|
326
|
+
return error;
|
|
327
|
+
try {
|
|
328
|
+
assertNameAvailable(library, "item", name ?? "");
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return "That name is already used by a saved or selected resource; choose another.";
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
return isCancel(value) ? undefined : value.trim();
|
|
337
|
+
};
|
|
338
|
+
const rename = (candidate, name) => {
|
|
339
|
+
pending = pending.map((entry) => (entry.id === candidate.id ? { ...entry, name } : entry));
|
|
340
|
+
};
|
|
341
|
+
for (const group of findSelectionConflicts(pending)) {
|
|
342
|
+
const choice = await select({
|
|
343
|
+
message: `${group.length} selected resources share the name ${group[0].name}; choose what to save`,
|
|
344
|
+
options: [
|
|
345
|
+
...group.map((candidate) => ({ value: `keep:${candidate.id}`, label: `Keep ${labels.get(candidate.id) ?? candidate.name}` })),
|
|
346
|
+
{ value: "rename", label: "Keep all and rename the others" },
|
|
347
|
+
{ value: "back", label: "Go back to the list" },
|
|
348
|
+
],
|
|
349
|
+
});
|
|
350
|
+
if (isCancel(choice))
|
|
351
|
+
return "cancel";
|
|
352
|
+
if (choice === "back")
|
|
353
|
+
return "back";
|
|
354
|
+
if (choice === "rename") {
|
|
355
|
+
for (const candidate of group.slice(1)) {
|
|
356
|
+
const name = await askNewName(pending.find((entry) => entry.id === candidate.id), group);
|
|
357
|
+
if (name === undefined)
|
|
358
|
+
return "cancel";
|
|
359
|
+
rename(candidate, name);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
const kept = choice.slice("keep:".length);
|
|
364
|
+
const dropped = new Set(group.filter((candidate) => candidate.id !== kept).map((candidate) => candidate.id));
|
|
365
|
+
pending = pending.filter((candidate) => !dropped.has(candidate.id));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
for (const candidate of [...pending]) {
|
|
369
|
+
if (!libraryKeys.has(nameKey(candidate.name)))
|
|
370
|
+
continue;
|
|
371
|
+
const choice = await select({
|
|
372
|
+
message: `${candidate.name} \u2014 ${displayPath(path.dirname(candidate.sourcePath), homeDir)}: a saved resource already uses this name. To update the saved copy, use Refresh in copi manage.`,
|
|
373
|
+
options: [
|
|
374
|
+
{ value: "skip", label: "Skip (keep the saved copy)" },
|
|
375
|
+
{ value: "rename", label: "Rename" },
|
|
376
|
+
{ value: "back", label: "Go back to the list" },
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
if (isCancel(choice))
|
|
380
|
+
return "cancel";
|
|
381
|
+
if (choice === "back")
|
|
382
|
+
return "back";
|
|
383
|
+
if (choice === "skip") {
|
|
384
|
+
pending = pending.filter((entry) => entry.id !== candidate.id);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const name = await askNewName(candidate);
|
|
388
|
+
if (name === undefined)
|
|
389
|
+
return "cancel";
|
|
390
|
+
rename(candidate, name);
|
|
391
|
+
}
|
|
392
|
+
return pending;
|
|
393
|
+
}
|
|
394
|
+
/** Quote for POSIX shells only when needed (single quotes, embedded quotes escaped as '\\''). */
|
|
395
|
+
function shellQuote(value) {
|
|
396
|
+
if (value === "<group>" || /^[A-Za-z0-9_.\/@%+=:,-]+$/.test(value))
|
|
397
|
+
return value;
|
|
398
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
399
|
+
}
|
|
286
400
|
export async function runSetup(libraryDir, roots) {
|
|
287
401
|
if (await handleNameConflicts(libraryDir))
|
|
288
402
|
return;
|
|
@@ -290,35 +404,34 @@ export async function runSetup(libraryDir, roots) {
|
|
|
290
404
|
assertModernCatalog(initial);
|
|
291
405
|
assertNoNameConflicts(initial);
|
|
292
406
|
intro("copi setup");
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
407
|
+
const homeDir = roots.homeDir;
|
|
408
|
+
const projectLabel = displayPath(roots.projectDir, homeDir);
|
|
409
|
+
const homeBase = homeDir.length > 1 && homeDir.endsWith(path.sep) ? homeDir.slice(0, -1) : homeDir;
|
|
410
|
+
const projectIsHome = Boolean(homeDir) && roots.projectDir === homeBase;
|
|
411
|
+
const projectInsideHome = Boolean(homeDir) && !projectIsHome && roots.projectDir.startsWith(homeBase + path.sep);
|
|
412
|
+
const locationOptions = [];
|
|
413
|
+
if (homeDir)
|
|
414
|
+
locationOptions.push({ value: "home", label: "HOME and Pi agent directory \u2014 ~ (can take a minute or more)" });
|
|
415
|
+
if (!projectIsHome)
|
|
416
|
+
locationOptions.push({ value: "project", label: `This project only \u2014 ${projectLabel} (fast)` });
|
|
417
|
+
if (homeDir && !projectIsHome && !projectInsideHome)
|
|
418
|
+
locationOptions.push({ value: "both", label: "Both" });
|
|
419
|
+
locationOptions.push({ value: "cancel", label: "Cancel" });
|
|
420
|
+
const choice = await select({
|
|
303
421
|
message: "Choose the locations copi may inspect",
|
|
304
|
-
options:
|
|
305
|
-
|
|
306
|
-
placeholder: "Search locations...",
|
|
422
|
+
options: locationOptions,
|
|
423
|
+
initialValue: locationOptions[0].value,
|
|
307
424
|
});
|
|
308
|
-
if (isCancel(
|
|
425
|
+
if (isCancel(choice) || choice === "cancel") {
|
|
309
426
|
cancel("Setup cancelled.");
|
|
310
427
|
return;
|
|
311
428
|
}
|
|
312
|
-
const scopes = { home:
|
|
313
|
-
const searchOptions = buildSearchOptions(roots, scopes);
|
|
314
|
-
if (!searchOptions) {
|
|
315
|
-
outro("No locations selected; nothing was searched or saved.");
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
429
|
+
const scopes = { home: choice === "home" || choice === "both", project: choice === "project" || choice === "both" };
|
|
430
|
+
const searchOptions = buildSearchOptions(roots, scopes); // every non-cancel choice enables at least one scope
|
|
318
431
|
note([
|
|
319
|
-
`HOME: ${scopes.home ? roots.homeDir : "not selected"}`,
|
|
320
|
-
`Pi agent directory: ${scopes.home ? roots.agentDir : "not selected"}`,
|
|
321
|
-
`Project: ${scopes.project ?
|
|
432
|
+
`HOME: ${scopes.home ? displayPath(roots.homeDir, homeDir) : "not selected"}`,
|
|
433
|
+
`Pi agent directory: ${scopes.home ? displayPath(roots.agentDir, homeDir) : "not selected"}`,
|
|
434
|
+
`Project: ${scopes.project ? projectLabel : "not selected"}`,
|
|
322
435
|
"Always excluded: entire directories named auth (authentication-specific code may be missing), auth.json, mcp-auth.json, oauth.json, .env* paths, history, cache/caches, .cache, .npm, node_modules, and .git.",
|
|
323
436
|
"Secret detection is heuristic and cannot guarantee completeness. Preview text is masked by default.",
|
|
324
437
|
].join("\n"), "Search scope and exclusions");
|
|
@@ -330,36 +443,64 @@ export async function runSetup(libraryDir, roots) {
|
|
|
330
443
|
outro("Search not authorized; no resources were inspected or saved.");
|
|
331
444
|
return;
|
|
332
445
|
}
|
|
333
|
-
const
|
|
334
|
-
|
|
446
|
+
const progress = clackSpinner(inheritedSignal());
|
|
447
|
+
progress.start("Searching selected locations (searching all of HOME can take a minute or more)");
|
|
448
|
+
let discovery;
|
|
449
|
+
try {
|
|
450
|
+
discovery = await discoverResources(searchOptions);
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
progress.error("Search failed");
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
progress.stop(`Search complete: ${discovery.candidates.length} supported resource(s) found`);
|
|
457
|
+
showSearchWarnings(discovery.warnings, roots.homeDir);
|
|
335
458
|
if (discovery.excludedPaths.length > 0)
|
|
336
|
-
note(discovery.excludedPaths.join("\n"), "Excluded paths");
|
|
459
|
+
note(discovery.excludedPaths.map((excluded) => displayPath(excluded, homeDir)).join("\n"), "Excluded paths");
|
|
337
460
|
if (discovery.candidates.length === 0) {
|
|
338
461
|
outro("No supported resources were found.");
|
|
339
462
|
return;
|
|
340
463
|
}
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
464
|
+
const sortedCandidates = sortCandidates(discovery.candidates);
|
|
465
|
+
const candidateStats = await gatherCandidateStats(sortedCandidates);
|
|
466
|
+
const savedNames = new Set(initial.items.map((item) => nameKey(item.name)));
|
|
467
|
+
const candidateLabelMap = candidateLabels(sortedCandidates, homeDir, (candidate) => savedNames.has(nameKey(candidate.name)), process.stdout.columns);
|
|
468
|
+
let previousSelection = [];
|
|
469
|
+
let resolvedCandidates;
|
|
470
|
+
while (true) {
|
|
471
|
+
const selectedIds = await autocompleteMultiselect({
|
|
472
|
+
message: "Select resources to review and save as copied snapshots",
|
|
473
|
+
options: sortedCandidates.map((candidate) => {
|
|
474
|
+
const label = candidateLabelMap.get(candidate.id);
|
|
475
|
+
return { value: candidate.id, label, hint: candidateHint(candidate, candidateStats.get(candidate.id) ?? {}, homeDir, process.stdout.columns, label.length) };
|
|
476
|
+
}),
|
|
477
|
+
required: false,
|
|
478
|
+
initialValues: previousSelection,
|
|
479
|
+
placeholder: "Filter resources...",
|
|
480
|
+
});
|
|
481
|
+
if (isCancel(selectedIds)) {
|
|
482
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
previousSelection = selectedIds;
|
|
486
|
+
const outcome = await resolveSelectedNames(sortedCandidates.filter((candidate) => selectedIds.includes(candidate.id)), initial, candidateLabelMap, homeDir);
|
|
487
|
+
if (outcome === "cancel") {
|
|
488
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (outcome === "back")
|
|
492
|
+
continue;
|
|
493
|
+
resolvedCandidates = outcome;
|
|
494
|
+
break;
|
|
354
495
|
}
|
|
355
|
-
if (
|
|
496
|
+
if (resolvedCandidates.length === 0) {
|
|
356
497
|
outro("No resources selected; nothing was saved.");
|
|
357
498
|
return;
|
|
358
499
|
}
|
|
359
500
|
const catalog = await loadCatalog(libraryDir);
|
|
360
501
|
assertModernCatalog(catalog);
|
|
361
502
|
if (catalog.groups.length === 0) {
|
|
362
|
-
const name = await askForGroupName("Create the first resource group");
|
|
503
|
+
const name = await askForGroupName("Create the first resource group \u2014 used later with: copi apply <group>", "e.g. game-review");
|
|
363
504
|
if (name === undefined) {
|
|
364
505
|
cancel("Setup cancelled; no resources were saved.");
|
|
365
506
|
return;
|
|
@@ -383,12 +524,53 @@ export async function runSetup(libraryDir, roots) {
|
|
|
383
524
|
createGroup(catalog, name);
|
|
384
525
|
}
|
|
385
526
|
}
|
|
386
|
-
|
|
527
|
+
// Keep discovery order for review; renamed copies replace their originals by id.
|
|
528
|
+
const resolvedById = new Map(resolvedCandidates.map((candidate) => [candidate.id, candidate]));
|
|
529
|
+
const selectedCandidates = discovery.candidates.flatMap((candidate) => resolvedById.get(candidate.id) ?? []);
|
|
387
530
|
const approvedCandidates = [];
|
|
388
|
-
|
|
531
|
+
// Decide the assignment mode once; "bulk" assigns every approved resource to bulkGroupIds.
|
|
532
|
+
let bulkGroupIds;
|
|
533
|
+
const count = selectedCandidates.length;
|
|
534
|
+
const onlyGroup = catalog.groups.length === 1 ? catalog.groups[0] : undefined;
|
|
535
|
+
let bulk = count === 1;
|
|
536
|
+
if (count >= 2) {
|
|
537
|
+
const mode = await select({
|
|
538
|
+
message: "How should these resources be grouped?",
|
|
539
|
+
options: [
|
|
540
|
+
{ value: "all", label: onlyGroup ? `Put all ${count} resources in "${onlyGroup.name}"` : `Put all ${count} resources in the same groups` },
|
|
541
|
+
{ value: "each", label: "Choose groups for each resource" },
|
|
542
|
+
],
|
|
543
|
+
initialValue: "all",
|
|
544
|
+
});
|
|
545
|
+
if (isCancel(mode)) {
|
|
546
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
bulk = mode === "all";
|
|
550
|
+
}
|
|
551
|
+
if (bulk) {
|
|
552
|
+
if (onlyGroup)
|
|
553
|
+
bulkGroupIds = [onlyGroup.id];
|
|
554
|
+
else {
|
|
555
|
+
const chosen = await autocompleteMultiselect({
|
|
556
|
+
message: count === 1 ? `Assign ${selectedCandidates[0].name} to one or more groups` : `Put all ${count} resources in these groups`,
|
|
557
|
+
options: groupOptions(catalog.groups),
|
|
558
|
+
required: true,
|
|
559
|
+
placeholder: "Filter groups...",
|
|
560
|
+
});
|
|
561
|
+
if (isCancel(chosen)) {
|
|
562
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
bulkGroupIds = chosen;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
for (const [index, candidate] of selectedCandidates.entries()) {
|
|
389
569
|
const review = await prepareCandidateForReview(candidate);
|
|
390
|
-
|
|
391
|
-
|
|
570
|
+
const previewTitle = `Masked preview (${index + 1}/${count}): ${review.candidate.name} \u2014 ${displayPath(path.dirname(candidate.sourcePath), homeDir)}`;
|
|
571
|
+
note(review.preview.length > 0 ? review.preview.join("\n\n") : "No previewable files.", previewTitle);
|
|
572
|
+
// The heuristic caveat is stated once in the scope note and the summary; per-resource it would only repeat.
|
|
573
|
+
showWarnings(review.inspection.warnings.filter((warning) => warning.code !== "secret-detection-limited"), `${review.candidate.name} inspection warnings`);
|
|
392
574
|
if (review.inspection.excludedPaths.length > 0) {
|
|
393
575
|
note(review.inspection.excludedPaths.join("\n"), `${review.candidate.name} excluded paths`);
|
|
394
576
|
}
|
|
@@ -396,7 +578,7 @@ export async function runSetup(libraryDir, roots) {
|
|
|
396
578
|
const approvedSensitivePaths = [];
|
|
397
579
|
for (const relativePath of review.inspection.sensitivePaths) {
|
|
398
580
|
const approval = await confirm({
|
|
399
|
-
message: `Save suspected sensitive file ${relativePath} from ${review.candidate.name}? Detection is heuristic; the file content remains masked in previews.`,
|
|
581
|
+
message: `Save suspected sensitive file ${relativePath} from ${review.candidate.name}? Detection is heuristic; the file content remains masked in previews. No = ${review.candidate.name} will not be saved at all.`,
|
|
400
582
|
initialValue: false,
|
|
401
583
|
});
|
|
402
584
|
if (isCancel(approval)) {
|
|
@@ -412,9 +594,17 @@ export async function runSetup(libraryDir, roots) {
|
|
|
412
594
|
log.warn(safeDisplay(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`));
|
|
413
595
|
continue;
|
|
414
596
|
}
|
|
597
|
+
if (bulkGroupIds) {
|
|
598
|
+
approvedCandidates.push({ review, groupIds: bulkGroupIds, approvedSensitivePaths });
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
const pendingCounts = new Map();
|
|
602
|
+
for (const approved of approvedCandidates)
|
|
603
|
+
for (const id of approved.groupIds)
|
|
604
|
+
pendingCounts.set(id, (pendingCounts.get(id) ?? 0) + 1);
|
|
415
605
|
const groupIds = await autocompleteMultiselect({
|
|
416
606
|
message: `Assign ${review.candidate.name} to one or more groups`,
|
|
417
|
-
options: groupOptions(catalog.groups),
|
|
607
|
+
options: groupOptions(catalog.groups, pendingCounts),
|
|
418
608
|
required: true,
|
|
419
609
|
placeholder: "Filter groups...",
|
|
420
610
|
});
|
|
@@ -432,9 +622,10 @@ export async function runSetup(libraryDir, roots) {
|
|
|
432
622
|
const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? "Unavailable group").join(", ");
|
|
433
623
|
return `${review.candidate.name} → ${names}`;
|
|
434
624
|
}).join("\n");
|
|
435
|
-
note(summary
|
|
625
|
+
note(`${summary}\n\nSecret detection is heuristic and cannot guarantee completeness.`, "Resources ready to copy");
|
|
626
|
+
const libraryLabel = displayPath(libraryDir, homeDir);
|
|
436
627
|
const save = await confirm({
|
|
437
|
-
message: `Save ${approvedCandidates.length} copied resource(s) in ${
|
|
628
|
+
message: `Save ${approvedCandidates.length} copied resource(s) in ${libraryLabel}? Original files will not be changed.`,
|
|
438
629
|
initialValue: false,
|
|
439
630
|
});
|
|
440
631
|
if (isCancel(save) || !save) {
|
|
@@ -457,7 +648,10 @@ export async function runSetup(libraryDir, roots) {
|
|
|
457
648
|
for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
|
|
458
649
|
await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
|
|
459
650
|
}
|
|
460
|
-
|
|
651
|
+
const usedGroupIds = new Set(approvedCandidates.flatMap(({ groupIds }) => groupIds));
|
|
652
|
+
const usedGroups = catalog.groups.filter((group) => usedGroupIds.has(group.id)).map((group) => group.name);
|
|
653
|
+
log.info(safeDisplay(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryLabel} (group: ${usedGroups.join(", ")}).`));
|
|
654
|
+
outro(`Next: copi apply ${shellQuote(usedGroups[0] ?? "<group>")} <project> --dry-run (edit or rename: copi manage)`);
|
|
461
655
|
}
|
|
462
656
|
export async function runGroupManager(libraryDir) {
|
|
463
657
|
if (await handleNameConflicts(libraryDir))
|