@papillon6814/copi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/src/apply.d.ts +51 -0
- package/dist/src/apply.js +443 -0
- package/dist/src/catalog.d.ts +39 -0
- package/dist/src/catalog.js +429 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +108 -0
- package/dist/src/discovery.d.ts +12 -0
- package/dist/src/discovery.js +584 -0
- package/dist/src/groups.d.ts +4 -0
- package/dist/src/groups.js +30 -0
- package/dist/src/inspect.d.ts +5 -0
- package/dist/src/inspect.js +88 -0
- package/dist/src/types.d.ts +67 -0
- package/dist/src/types.js +1 -0
- package/dist/src/wizard.d.ts +30 -0
- package/dist/src/wizard.js +464 -0
- package/package.json +34 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { prepareResource, readPreparedResource } from "./catalog.js";
|
|
3
|
+
const maxInspectionBytes = 1_000_000;
|
|
4
|
+
const relativePathReference = /(?:^|[\s"'`])(?:\.{1,2}\/)?[A-Za-z0-9_.@-]+(?:\/[A-Za-z0-9_.@-]+)+(?:$|[\s"'`),;!?])/m;
|
|
5
|
+
function addWarning(warnings, code, message, relativePath) {
|
|
6
|
+
const warning = relativePath === undefined ? { code, message } : { code, message, path: relativePath };
|
|
7
|
+
if (!warnings.some((item) => item.code === code && item.path === relativePath))
|
|
8
|
+
warnings.push(warning);
|
|
9
|
+
}
|
|
10
|
+
function containsInlineSecret(text) {
|
|
11
|
+
const namedValue = /(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|authorization)\s*["']?\s*[:=]\s*["']?(?!\s*(?:\$|!|<|placeholder|example|changeme|your[_-]))[A-Za-z0-9_./+=:-]{8,}/i;
|
|
12
|
+
const recognizableToken = /\b(?:sk_live_[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{16,})\b/;
|
|
13
|
+
const bearer = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/i;
|
|
14
|
+
return namedValue.test(text) || recognizableToken.test(text) || bearer.test(text);
|
|
15
|
+
}
|
|
16
|
+
export async function inspectPreparedResource(prepared) {
|
|
17
|
+
const { candidate, files, excludedPaths } = readPreparedResource(prepared);
|
|
18
|
+
const warnings = [];
|
|
19
|
+
const sensitivePaths = [];
|
|
20
|
+
addWarning(warnings, "secret-detection-limited", "Secret detection is heuristic and cannot guarantee completeness.");
|
|
21
|
+
for (const relativePath of excludedPaths) {
|
|
22
|
+
if (relativePath.split("/").some((part) => part.toLowerCase() === "auth")) {
|
|
23
|
+
addWarning(warnings, "auth-directory-excluded", "An auth path was excluded; copied resources may be missing authentication-specific code.", relativePath);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
if (file.bytes.length > maxInspectionBytes) {
|
|
28
|
+
addWarning(warnings, "large-file-skipped", "Content inspection is limited to files no larger than 1 MB.", file.path);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (file.bytes.includes(0))
|
|
32
|
+
continue;
|
|
33
|
+
let text;
|
|
34
|
+
try {
|
|
35
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (containsInlineSecret(text)) {
|
|
41
|
+
sensitivePaths.push(file.path);
|
|
42
|
+
addWarning(warnings, "sensitive-content", "This file may contain a secret; detection is heuristic and cannot guarantee completeness.", file.path);
|
|
43
|
+
}
|
|
44
|
+
if (/\$\{?[A-Z_][A-Z0-9_]*\}?/.test(text))
|
|
45
|
+
addWarning(warnings, "environment-reference", "This resource refers to environment variables that may need to be configured separately.", file.path);
|
|
46
|
+
if (/(?:\/Users\/|\/home\/|[A-Za-z]:\\\\|~\/)[^\s"']+/.test(text))
|
|
47
|
+
addWarning(warnings, "absolute-path", "This resource contains a path that may not exist in another project.", file.path);
|
|
48
|
+
if (relativePathReference.test(text))
|
|
49
|
+
addWarning(warnings, "relative-path-portability", "This resource contains relative paths that may resolve differently after it is copied to another project.", file.path);
|
|
50
|
+
if (candidate.kind === "pi-settings" && ["defaultProjectTrust", "httpProxy", "cacheWarming"].some((key) => text.includes(`\"${key}\"`))) {
|
|
51
|
+
addWarning(warnings, "project-setting-restricted", "This Pi setting is user-only and may not work in a project; copi preserves its bytes without rewriting it.", file.path);
|
|
52
|
+
}
|
|
53
|
+
if (path.basename(file.path) === "package.json") {
|
|
54
|
+
try {
|
|
55
|
+
const manifest = JSON.parse(text);
|
|
56
|
+
const dependencies = [manifest.dependencies, manifest.devDependencies, manifest.peerDependencies].some((value) => typeof value === "object" && value !== null && Object.keys(value).length > 0);
|
|
57
|
+
if (dependencies)
|
|
58
|
+
addWarning(warnings, "dependency-requirement", "This resource declares dependencies; copi does not install them automatically.", file.path);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
addWarning(warnings, "invalid-package-manifest", "This package manifest could not be parsed for dependency warnings.", file.path);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (candidate.destination.kind === "global-only")
|
|
66
|
+
addWarning(warnings, "global-only", "This resource is saved for reference and cannot be applied to a project.");
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
warnings: Object.freeze(warnings.map((warning) => Object.freeze({ ...warning }))),
|
|
69
|
+
sensitivePaths: Object.freeze([...new Set(sensitivePaths)].sort((left, right) => left.localeCompare(right))),
|
|
70
|
+
excludedPaths: Object.freeze([...new Set(excludedPaths)].sort((left, right) => left.localeCompare(right))),
|
|
71
|
+
preparedResource: prepared,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
export async function inspectResource(candidate) {
|
|
75
|
+
const prepared = await prepareResource(candidate);
|
|
76
|
+
const inspection = await inspectPreparedResource(prepared);
|
|
77
|
+
return {
|
|
78
|
+
warnings: [...inspection.warnings],
|
|
79
|
+
sensitivePaths: [...inspection.sensitivePaths],
|
|
80
|
+
excludedPaths: [...inspection.excludedPaths],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function maskSensitiveText(value) {
|
|
84
|
+
return value
|
|
85
|
+
.replace(/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|password|authorization)\s*["']?\s*[:=]\s*["']?)(?!\$\{?[A-Z_][A-Z0-9_]*\}?)([^"'\s,}]{8,})/gi, "$1[REDACTED]")
|
|
86
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer [REDACTED]")
|
|
87
|
+
.replace(/\b(?:sk_live_[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{16,})\b/g, "[REDACTED]");
|
|
88
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type ResourceKind = "instruction" | "skill" | "subagent" | "pi-settings" | "mcp-config" | "model-config" | "keybindings";
|
|
2
|
+
export type ResourceScope = "user" | "project" | "shared" | "package";
|
|
3
|
+
export type ResourceDestination = {
|
|
4
|
+
kind: "project";
|
|
5
|
+
path: string;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "global-only";
|
|
8
|
+
};
|
|
9
|
+
export interface ResourceCandidate {
|
|
10
|
+
id: string;
|
|
11
|
+
kind: ResourceKind;
|
|
12
|
+
source: string;
|
|
13
|
+
scope: ResourceScope;
|
|
14
|
+
sourceRoot: string;
|
|
15
|
+
sourcePath: string;
|
|
16
|
+
name: string;
|
|
17
|
+
destination: ResourceDestination;
|
|
18
|
+
}
|
|
19
|
+
export interface DiscoveryWarning {
|
|
20
|
+
code: string;
|
|
21
|
+
path: string;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
export interface DiscoveryResult {
|
|
25
|
+
candidates: ResourceCandidate[];
|
|
26
|
+
warnings: DiscoveryWarning[];
|
|
27
|
+
excludedPaths: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface InspectionWarning {
|
|
30
|
+
code: string;
|
|
31
|
+
path?: string;
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
export interface InspectionResult {
|
|
35
|
+
warnings: InspectionWarning[];
|
|
36
|
+
sensitivePaths: string[];
|
|
37
|
+
excludedPaths: string[];
|
|
38
|
+
}
|
|
39
|
+
export interface SnapshotFile {
|
|
40
|
+
path: string;
|
|
41
|
+
mode: number;
|
|
42
|
+
sha256: string;
|
|
43
|
+
}
|
|
44
|
+
export interface SnapshotFileContents extends SnapshotFile {
|
|
45
|
+
bytes: Buffer;
|
|
46
|
+
}
|
|
47
|
+
export interface SavedItem {
|
|
48
|
+
id: string;
|
|
49
|
+
kind: ResourceKind;
|
|
50
|
+
source: string;
|
|
51
|
+
scope: ResourceScope;
|
|
52
|
+
sourcePath: string;
|
|
53
|
+
destination: ResourceDestination;
|
|
54
|
+
name: string;
|
|
55
|
+
savedAt: string;
|
|
56
|
+
files: SnapshotFile[];
|
|
57
|
+
}
|
|
58
|
+
export interface Group {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
itemIds: string[];
|
|
62
|
+
}
|
|
63
|
+
export interface Catalog {
|
|
64
|
+
version: 1;
|
|
65
|
+
items: SavedItem[];
|
|
66
|
+
groups: Group[];
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type PreparedInspection, type PreparedResource } from "./catalog.ts";
|
|
2
|
+
import { type DiscoveryOptions } from "./discovery.ts";
|
|
3
|
+
import type { ApplyFileOption } from "./apply.ts";
|
|
4
|
+
import type { Catalog, ResourceCandidate, SavedItem } from "./types.ts";
|
|
5
|
+
export interface SearchRoots {
|
|
6
|
+
homeDir: string;
|
|
7
|
+
agentDir: string;
|
|
8
|
+
projectDir: string;
|
|
9
|
+
}
|
|
10
|
+
export interface SearchScopes {
|
|
11
|
+
home: boolean;
|
|
12
|
+
project: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function buildSearchOptions(roots: SearchRoots, scopes: SearchScopes): DiscoveryOptions | undefined;
|
|
15
|
+
export declare function formatMaskedPreview(relativePath: string, content: string): string;
|
|
16
|
+
export interface CandidateReview {
|
|
17
|
+
candidate: ResourceCandidate;
|
|
18
|
+
prepared: PreparedResource;
|
|
19
|
+
inspection: PreparedInspection;
|
|
20
|
+
preview: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function prepareCandidateForReview(candidate: ResourceCandidate): Promise<CandidateReview>;
|
|
23
|
+
export declare function saveReviewedCandidate(libraryDir: string, review: CandidateReview, groupIds: string[], approvedSensitivePaths: string[], confirmed: boolean): Promise<SavedItem | null>;
|
|
24
|
+
export declare function runSetup(libraryDir: string, roots: SearchRoots): Promise<void>;
|
|
25
|
+
export declare function runGroupManager(libraryDir: string): Promise<void>;
|
|
26
|
+
export declare function printGroups(catalog: Catalog): void;
|
|
27
|
+
export declare function printItems(items: SavedItem[]): void;
|
|
28
|
+
export declare function formatApplyOptionPreview(filePath: string, resourceName: string, option: ApplyFileOption): string;
|
|
29
|
+
export declare function runApplyDryRun(libraryDir: string, groupValue: string, projectDir: string): Promise<void>;
|
|
30
|
+
export declare function runApplyWizard(libraryDir: string, groupValue: string, projectDir: string): Promise<void>;
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { autocompleteMultiselect, cancel, confirm, intro, isCancel, log, note, outro, select, text } from "@clack/prompts";
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { importPreparedResource, loadCatalog, prepareResource, readPreparedResource, saveCatalog } from "./catalog.js";
|
|
5
|
+
import { discoverResources } from "./discovery.js";
|
|
6
|
+
import { createGroup, renameGroup, setGroupItems } from "./groups.js";
|
|
7
|
+
import { inspectPreparedResource, maskSensitiveText } from "./inspect.js";
|
|
8
|
+
import { executeApply, planApply } from "./apply.js";
|
|
9
|
+
const previewMaxBytes = 1_000_000;
|
|
10
|
+
const previewMaxFiles = 12;
|
|
11
|
+
const previewMaxLines = 24;
|
|
12
|
+
export function buildSearchOptions(roots, scopes) {
|
|
13
|
+
if (!scopes.home && !scopes.project)
|
|
14
|
+
return undefined;
|
|
15
|
+
return {
|
|
16
|
+
...roots,
|
|
17
|
+
scopes: { home: scopes.home, project: scopes.project },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function formatMaskedPreview(relativePath, content) {
|
|
21
|
+
const masked = maskSensitiveText(content);
|
|
22
|
+
const lines = masked.split(/\r?\n/);
|
|
23
|
+
const snippet = lines.slice(0, previewMaxLines).join("\n").slice(0, 2_000);
|
|
24
|
+
const omitted = lines.length > previewMaxLines ? `\n… ${lines.length - previewMaxLines} more lines` : "";
|
|
25
|
+
return `${relativePath}:\n${snippet}${omitted}`;
|
|
26
|
+
}
|
|
27
|
+
function formatPreparedFiles(files) {
|
|
28
|
+
return files.slice(0, previewMaxFiles).map((file) => {
|
|
29
|
+
if (file.bytes.length > previewMaxBytes || file.bytes.includes(0)) {
|
|
30
|
+
return `${file.path}: [binary or large file, ${file.bytes.length} bytes]`;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(file.bytes);
|
|
34
|
+
return formatMaskedPreview(file.path, text);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return `${file.path}: [binary or large file, ${file.bytes.length} bytes]`;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function prepareCandidateForReview(candidate) {
|
|
42
|
+
const prepared = await prepareResource(candidate);
|
|
43
|
+
const inspection = await inspectPreparedResource(prepared);
|
|
44
|
+
const snapshot = readPreparedResource(prepared);
|
|
45
|
+
return { candidate: snapshot.candidate, prepared, inspection, preview: formatPreparedFiles(snapshot.files) };
|
|
46
|
+
}
|
|
47
|
+
export async function saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, confirmed) {
|
|
48
|
+
if (!confirmed)
|
|
49
|
+
return null;
|
|
50
|
+
return importPreparedResource(libraryDir, review.prepared, {
|
|
51
|
+
confirmed,
|
|
52
|
+
groupIds,
|
|
53
|
+
inspection: review.inspection,
|
|
54
|
+
approvedSensitivePaths,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function showWarnings(warnings, title) {
|
|
58
|
+
if (warnings.length === 0)
|
|
59
|
+
return;
|
|
60
|
+
note(warnings.map((warning) => `${warning.path ? `${warning.path}: ` : ""}${warning.message}`).join("\n"), title);
|
|
61
|
+
}
|
|
62
|
+
function groupOptions(groups) {
|
|
63
|
+
return groups.map((group) => ({ value: group.id, label: group.name, hint: `${group.itemIds.length} saved resources` }));
|
|
64
|
+
}
|
|
65
|
+
async function askForGroupName(message) {
|
|
66
|
+
const value = await text({
|
|
67
|
+
message,
|
|
68
|
+
validate: (name) => name === undefined || name.trim().length === 0 ? "Enter a non-empty group name." : undefined,
|
|
69
|
+
});
|
|
70
|
+
return isCancel(value) ? undefined : value.trim();
|
|
71
|
+
}
|
|
72
|
+
export async function runSetup(libraryDir, roots) {
|
|
73
|
+
intro("copi setup");
|
|
74
|
+
const scopeOptions = [];
|
|
75
|
+
if (roots.homeDir) {
|
|
76
|
+
scopeOptions.push({
|
|
77
|
+
value: "home",
|
|
78
|
+
label: `Search entire HOME: ${roots.homeDir}`,
|
|
79
|
+
hint: `Includes supported Pi resources under HOME and Pi agent directory ${roots.agentDir}`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
scopeOptions.push({ value: "project", label: `Search current project: ${roots.projectDir}`, hint: "Supported Pi resources and instruction files in this project" });
|
|
83
|
+
const selectedScopes = await autocompleteMultiselect({
|
|
84
|
+
message: "Choose the locations copi may inspect",
|
|
85
|
+
options: scopeOptions,
|
|
86
|
+
required: false,
|
|
87
|
+
placeholder: "Search locations...",
|
|
88
|
+
});
|
|
89
|
+
if (isCancel(selectedScopes)) {
|
|
90
|
+
cancel("Setup cancelled.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const scopes = { home: selectedScopes.includes("home"), project: selectedScopes.includes("project") };
|
|
94
|
+
const searchOptions = buildSearchOptions(roots, scopes);
|
|
95
|
+
if (!searchOptions) {
|
|
96
|
+
outro("No locations selected; nothing was searched or saved.");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
note([
|
|
100
|
+
`HOME: ${scopes.home ? roots.homeDir : "not selected"}`,
|
|
101
|
+
`Pi agent directory: ${scopes.home ? roots.agentDir : "not selected"}`,
|
|
102
|
+
`Project: ${scopes.project ? roots.projectDir : "not selected"}`,
|
|
103
|
+
"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.",
|
|
104
|
+
"Secret detection is heuristic and cannot guarantee completeness. Preview text is masked by default.",
|
|
105
|
+
].join("\n"), "Search scope and exclusions");
|
|
106
|
+
const authorized = await confirm({
|
|
107
|
+
message: "Authorize copi to search only the selected locations now?",
|
|
108
|
+
initialValue: false,
|
|
109
|
+
});
|
|
110
|
+
if (isCancel(authorized) || !authorized) {
|
|
111
|
+
outro("Search not authorized; no resources were inspected or saved.");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const discovery = await discoverResources(searchOptions);
|
|
115
|
+
showWarnings(discovery.warnings, "Search warnings");
|
|
116
|
+
if (discovery.excludedPaths.length > 0)
|
|
117
|
+
note(discovery.excludedPaths.join("\n"), "Excluded paths");
|
|
118
|
+
if (discovery.candidates.length === 0) {
|
|
119
|
+
outro("No supported resources were found.");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const selectedIds = await autocompleteMultiselect({
|
|
123
|
+
message: "Select resources to review and save as copied snapshots",
|
|
124
|
+
options: discovery.candidates.map((candidate) => ({
|
|
125
|
+
value: candidate.id,
|
|
126
|
+
label: `[${candidate.kind}] ${candidate.name}`,
|
|
127
|
+
hint: `${candidate.scope} · ${candidate.sourcePath} · ${candidate.destination.kind === "global-only" ? "save only" : candidate.destination.path}`,
|
|
128
|
+
})),
|
|
129
|
+
required: false,
|
|
130
|
+
placeholder: "Filter resources...",
|
|
131
|
+
});
|
|
132
|
+
if (isCancel(selectedIds)) {
|
|
133
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (selectedIds.length === 0) {
|
|
137
|
+
outro("No resources selected; nothing was saved.");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const catalog = await loadCatalog(libraryDir);
|
|
141
|
+
if (catalog.groups.length === 0) {
|
|
142
|
+
const name = await askForGroupName("Create the first resource group");
|
|
143
|
+
if (name === undefined) {
|
|
144
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
createGroup(catalog, name);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
while (true) {
|
|
151
|
+
const add = await confirm({ message: "Create another group before assigning resources?", initialValue: false });
|
|
152
|
+
if (isCancel(add)) {
|
|
153
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!add)
|
|
157
|
+
break;
|
|
158
|
+
const name = await askForGroupName("New group name");
|
|
159
|
+
if (name === undefined) {
|
|
160
|
+
cancel("Setup cancelled; no resources were saved.");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
createGroup(catalog, name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const selectedCandidates = discovery.candidates.filter((candidate) => selectedIds.includes(candidate.id));
|
|
167
|
+
const approvedCandidates = [];
|
|
168
|
+
for (const candidate of selectedCandidates) {
|
|
169
|
+
const review = await prepareCandidateForReview(candidate);
|
|
170
|
+
note(review.preview.length > 0 ? review.preview.join("\n\n") : "No previewable files.", `Masked preview: ${review.candidate.name}`);
|
|
171
|
+
showWarnings(review.inspection.warnings, `${review.candidate.name} inspection warnings`);
|
|
172
|
+
if (review.inspection.excludedPaths.length > 0) {
|
|
173
|
+
note(review.inspection.excludedPaths.join("\n"), `${review.candidate.name} excluded paths`);
|
|
174
|
+
}
|
|
175
|
+
let allSuspectedFilesApproved = true;
|
|
176
|
+
const approvedSensitivePaths = [];
|
|
177
|
+
for (const relativePath of review.inspection.sensitivePaths) {
|
|
178
|
+
const approval = await confirm({
|
|
179
|
+
message: `Save suspected sensitive file ${relativePath} from ${review.candidate.name}? Detection is heuristic; the file content remains masked in previews.`,
|
|
180
|
+
initialValue: false,
|
|
181
|
+
});
|
|
182
|
+
if (isCancel(approval)) {
|
|
183
|
+
cancel("Setup cancelled; nothing was saved.");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (approval)
|
|
187
|
+
approvedSensitivePaths.push(relativePath);
|
|
188
|
+
else
|
|
189
|
+
allSuspectedFilesApproved = false;
|
|
190
|
+
}
|
|
191
|
+
if (!allSuspectedFilesApproved) {
|
|
192
|
+
log.warn(`${review.candidate.name} was not selected for saving because a suspected sensitive file was not approved.`);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const groupIds = await autocompleteMultiselect({
|
|
196
|
+
message: `Assign ${review.candidate.name} to one or more groups`,
|
|
197
|
+
options: groupOptions(catalog.groups),
|
|
198
|
+
required: true,
|
|
199
|
+
placeholder: "Filter groups...",
|
|
200
|
+
});
|
|
201
|
+
if (isCancel(groupIds)) {
|
|
202
|
+
cancel("Setup cancelled; nothing was saved.");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
approvedCandidates.push({ review, groupIds, approvedSensitivePaths });
|
|
206
|
+
}
|
|
207
|
+
if (approvedCandidates.length === 0) {
|
|
208
|
+
outro("No resources were approved; nothing was saved.");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const summary = approvedCandidates.map(({ review, groupIds }) => {
|
|
212
|
+
const names = groupIds.map((id) => catalog.groups.find((group) => group.id === id)?.name ?? id).join(", ");
|
|
213
|
+
return `${review.candidate.name} → ${names}`;
|
|
214
|
+
}).join("\n");
|
|
215
|
+
note(summary, "Resources ready to copy");
|
|
216
|
+
const save = await confirm({
|
|
217
|
+
message: `Save ${approvedCandidates.length} copied resource(s) in ${libraryDir}? Original files will not be changed.`,
|
|
218
|
+
initialValue: false,
|
|
219
|
+
});
|
|
220
|
+
if (isCancel(save) || !save) {
|
|
221
|
+
outro("Nothing was saved.");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
await saveCatalog(libraryDir, catalog);
|
|
225
|
+
for (const { review, groupIds, approvedSensitivePaths } of approvedCandidates) {
|
|
226
|
+
await saveReviewedCandidate(libraryDir, review, groupIds, approvedSensitivePaths, true);
|
|
227
|
+
}
|
|
228
|
+
outro(`Saved ${approvedCandidates.length} copied resource(s) in ${libraryDir}.`);
|
|
229
|
+
}
|
|
230
|
+
export async function runGroupManager(libraryDir) {
|
|
231
|
+
const catalog = await loadCatalog(libraryDir);
|
|
232
|
+
if (catalog.groups.length === 0) {
|
|
233
|
+
note("No groups are saved yet.", "Groups");
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
note(catalog.groups.map((group) => `${group.name} (${group.itemIds.length} resources)`).join("\n"), "Groups");
|
|
237
|
+
}
|
|
238
|
+
const before = JSON.stringify(catalog);
|
|
239
|
+
while (true) {
|
|
240
|
+
const action = await select({
|
|
241
|
+
message: "Manage saved groups",
|
|
242
|
+
options: [
|
|
243
|
+
{ value: "create", label: "Create group" },
|
|
244
|
+
...(catalog.groups.length > 0 ? [
|
|
245
|
+
{ value: "rename", label: "Rename group" },
|
|
246
|
+
{ value: "assign", label: "Edit group membership" },
|
|
247
|
+
] : []),
|
|
248
|
+
{ value: "done", label: "Finish" },
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
if (isCancel(action)) {
|
|
252
|
+
cancel("Group changes were not saved.");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (action === "done")
|
|
256
|
+
break;
|
|
257
|
+
if (action === "create") {
|
|
258
|
+
const name = await askForGroupName("New group name");
|
|
259
|
+
if (name === undefined) {
|
|
260
|
+
cancel("Group changes were not saved.");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
createGroup(catalog, name);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const groupId = await select({ message: "Select group", options: groupOptions(catalog.groups) });
|
|
267
|
+
if (isCancel(groupId)) {
|
|
268
|
+
cancel("Group changes were not saved.");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (action === "rename") {
|
|
272
|
+
const group = catalog.groups.find((entry) => entry.id === groupId);
|
|
273
|
+
if (!group)
|
|
274
|
+
throw new Error("Selected group is no longer available.");
|
|
275
|
+
const name = await askForGroupName(`New name for ${group.name}`);
|
|
276
|
+
if (name === undefined) {
|
|
277
|
+
cancel("Group changes were not saved.");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
renameGroup(catalog, groupId, name);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const options = catalog.items.map((item) => ({
|
|
284
|
+
value: item.id,
|
|
285
|
+
label: `[${item.kind}] ${item.name}`,
|
|
286
|
+
hint: `${item.source} · ${item.destination.kind === "global-only" ? "saved only" : item.destination.path}`,
|
|
287
|
+
}));
|
|
288
|
+
const itemIds = await autocompleteMultiselect({
|
|
289
|
+
message: `Select resources in ${catalog.groups.find((group) => group.id === groupId)?.name ?? "group"}`,
|
|
290
|
+
options,
|
|
291
|
+
required: false,
|
|
292
|
+
initialValues: catalog.groups.find((group) => group.id === groupId)?.itemIds ?? [],
|
|
293
|
+
placeholder: "Filter saved resources...",
|
|
294
|
+
});
|
|
295
|
+
if (isCancel(itemIds)) {
|
|
296
|
+
cancel("Group changes were not saved.");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
setGroupItems(catalog, groupId, itemIds);
|
|
300
|
+
}
|
|
301
|
+
if (JSON.stringify(catalog) === before) {
|
|
302
|
+
outro("No group changes.");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const save = await confirm({ message: "Save these group changes to the private copi library?", initialValue: false });
|
|
306
|
+
if (isCancel(save) || !save) {
|
|
307
|
+
outro("Group changes were not saved.");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await saveCatalog(libraryDir, catalog);
|
|
311
|
+
outro("Group changes saved.");
|
|
312
|
+
}
|
|
313
|
+
export function printGroups(catalog) {
|
|
314
|
+
if (catalog.groups.length === 0) {
|
|
315
|
+
process.stdout.write("No saved groups.\n");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
for (const group of catalog.groups)
|
|
319
|
+
process.stdout.write(`${group.name} (${group.id}) — ${group.itemIds.length} resource(s)\n`);
|
|
320
|
+
}
|
|
321
|
+
export function printItems(items) {
|
|
322
|
+
if (items.length === 0) {
|
|
323
|
+
process.stdout.write("No saved snapshots.\n");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const item of items) {
|
|
327
|
+
process.stdout.write(`${item.name} [${item.kind}] (${item.id}) — ${item.files.length} file(s), ${item.destination.kind === "global-only" ? "saved only" : item.destination.path}\n`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function resolveGroup(catalog, value) {
|
|
331
|
+
const matching = catalog.groups.filter((group) => group.id === value || group.name === value);
|
|
332
|
+
if (matching.length === 0)
|
|
333
|
+
throw new Error(`Unknown group: ${value}`);
|
|
334
|
+
if (matching.length > 1)
|
|
335
|
+
throw new Error(`Group name is ambiguous: ${value}; use a group ID.`);
|
|
336
|
+
const group = matching[0];
|
|
337
|
+
if (!group)
|
|
338
|
+
throw new Error(`Unknown group: ${value}`);
|
|
339
|
+
return group;
|
|
340
|
+
}
|
|
341
|
+
function formatPlan(plan, catalog) {
|
|
342
|
+
const itemNames = new Map(catalog.items.map((item) => [item.id, item.name]));
|
|
343
|
+
const lines = plan.files.map((file) => {
|
|
344
|
+
const mark = file.alternatives.some((option) => option.sensitive) ? " [suspected secret; approval required]" : "";
|
|
345
|
+
const preview = file.preview.kind === "text" ? `\n${file.preview.diff}` : `\n${file.preview.summary}`;
|
|
346
|
+
const alternatives = file.alternatives.length > 1
|
|
347
|
+
? `\n candidates: ${file.alternatives.map((option) => itemNames.get(option.itemId) ?? option.itemId).join(", ")}`
|
|
348
|
+
: "";
|
|
349
|
+
return `${file.status}: ${file.path}${mark}${alternatives}${preview}`;
|
|
350
|
+
});
|
|
351
|
+
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) ?? id}`);
|
|
353
|
+
return [...lines, ...warnings, ...skipped].join("\n\n") || "No project files need changes.";
|
|
354
|
+
}
|
|
355
|
+
export function formatApplyOptionPreview(filePath, resourceName, option) {
|
|
356
|
+
const preview = option.preview.kind === "text" ? option.preview.diff : option.preview.summary;
|
|
357
|
+
return `${resourceName} → ${filePath}\n${preview}`;
|
|
358
|
+
}
|
|
359
|
+
export async function runApplyDryRun(libraryDir, groupValue, projectDir) {
|
|
360
|
+
const catalog = await loadCatalog(libraryDir);
|
|
361
|
+
const group = resolveGroup(catalog, groupValue);
|
|
362
|
+
const plan = await planApply(libraryDir, group.id, projectDir);
|
|
363
|
+
note(formatPlan(plan, catalog), `Dry run: ${group.name} → ${projectDir}`);
|
|
364
|
+
}
|
|
365
|
+
export async function runApplyWizard(libraryDir, groupValue, projectDir) {
|
|
366
|
+
const catalog = await loadCatalog(libraryDir);
|
|
367
|
+
const group = resolveGroup(catalog, groupValue);
|
|
368
|
+
const plan = await planApply(libraryDir, group.id, projectDir);
|
|
369
|
+
if (plan.files.length === 0) {
|
|
370
|
+
note(formatPlan(plan, catalog), `Apply: ${group.name}`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
intro(`copi apply · ${group.name}`);
|
|
374
|
+
note(formatPlan(plan, catalog), "Masked project diff and warnings");
|
|
375
|
+
const collisionDecisions = {};
|
|
376
|
+
const existingDecisions = {};
|
|
377
|
+
const decisions = { collisions: collisionDecisions, existing: existingDecisions, secretApprovals: [] };
|
|
378
|
+
const selectedSecrets = new Set();
|
|
379
|
+
for (const file of plan.files) {
|
|
380
|
+
let sensitive = file.sensitive;
|
|
381
|
+
if (file.alternatives.length > 1) {
|
|
382
|
+
const selectedItem = await select({
|
|
383
|
+
message: `Choose one resource for colliding path ${file.path}`,
|
|
384
|
+
options: file.alternatives.map((option) => ({
|
|
385
|
+
value: option.itemId,
|
|
386
|
+
label: catalog.items.find((item) => item.id === option.itemId)?.name ?? option.itemId,
|
|
387
|
+
hint: option.preview.kind === "text" ? "masked text diff available" : option.preview.summary,
|
|
388
|
+
})),
|
|
389
|
+
});
|
|
390
|
+
if (isCancel(selectedItem)) {
|
|
391
|
+
cancel("Apply cancelled; project files were not changed.");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const selectedOption = file.alternatives.find((option) => option.itemId === selectedItem);
|
|
395
|
+
if (!selectedOption)
|
|
396
|
+
throw new Error(`Selected resource is unavailable for ${file.path}.`);
|
|
397
|
+
collisionDecisions[file.path] = selectedItem;
|
|
398
|
+
sensitive = selectedOption.sensitive;
|
|
399
|
+
const selectedName = catalog.items.find((item) => item.id === selectedItem)?.name ?? selectedItem;
|
|
400
|
+
note(formatApplyOptionPreview(file.path, selectedName, selectedOption), "Selected colliding resource preview");
|
|
401
|
+
}
|
|
402
|
+
let hasExistingFile = file.status === "different";
|
|
403
|
+
if (file.status === "collision") {
|
|
404
|
+
try {
|
|
405
|
+
await lstat(path.resolve(plan.projectDir, ...file.path.split("/")));
|
|
406
|
+
hasExistingFile = true;
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
const code = error instanceof Error && "code" in error ? error.code : undefined;
|
|
410
|
+
if (code !== "ENOENT")
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const existingChoice = hasExistingFile
|
|
415
|
+
? await select({
|
|
416
|
+
message: `Existing file ${file.path}`,
|
|
417
|
+
options: [
|
|
418
|
+
{ value: "keep", label: "Keep existing file" },
|
|
419
|
+
{ value: "replace", label: "Replace with snapshot and create a private backup" },
|
|
420
|
+
{ value: "abort", label: "Abort the whole apply" },
|
|
421
|
+
],
|
|
422
|
+
initialValue: "abort",
|
|
423
|
+
})
|
|
424
|
+
: undefined;
|
|
425
|
+
if (existingChoice !== undefined) {
|
|
426
|
+
if (isCancel(existingChoice)) {
|
|
427
|
+
cancel("Apply cancelled; project files were not changed.");
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (existingChoice === "abort") {
|
|
431
|
+
outro("Apply aborted; project files were not changed.");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
existingDecisions[file.path] = existingChoice;
|
|
435
|
+
}
|
|
436
|
+
if (sensitive && existingChoice !== "keep" && file.status !== "identical") {
|
|
437
|
+
const approval = await confirm({
|
|
438
|
+
message: `Apply suspected sensitive file ${file.path}? Detection is heuristic; the diff above is masked.`,
|
|
439
|
+
initialValue: false,
|
|
440
|
+
});
|
|
441
|
+
if (isCancel(approval)) {
|
|
442
|
+
cancel("Apply cancelled; project files were not changed.");
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (!approval) {
|
|
446
|
+
outro("Apply cancelled because a suspected sensitive file was not approved; project files were not changed.");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
selectedSecrets.add(file.path);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
decisions.secretApprovals = [...selectedSecrets];
|
|
453
|
+
const apply = await confirm({ message: `Apply the selected snapshots to ${projectDir}?`, initialValue: false });
|
|
454
|
+
if (isCancel(apply) || !apply) {
|
|
455
|
+
outro("Project files were not changed.");
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const result = await executeApply(libraryDir, plan, decisions);
|
|
459
|
+
if (result.cancelled) {
|
|
460
|
+
outro("Apply aborted; project files were not changed.");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
outro(`Applied ${result.written.length} file(s); kept ${result.kept.length}; saved ${result.backups.length} private backup(s).`);
|
|
464
|
+
}
|