offwatch 0.5.12 → 0.5.14
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 +132 -178
- package/bin/offwatch.js +6 -7
- package/lib/downloader.js +112 -0
- package/package.json +18 -11
- package/postinstall.js +18 -0
- package/src/__tests__/agent-jwt-env.test.ts +0 -79
- package/src/__tests__/allowed-hostname.test.ts +0 -80
- package/src/__tests__/auth-command-registration.test.ts +0 -16
- package/src/__tests__/board-auth.test.ts +0 -53
- package/src/__tests__/common.test.ts +0 -98
- package/src/__tests__/company-delete.test.ts +0 -95
- package/src/__tests__/company-import-export-e2e.test.ts +0 -502
- package/src/__tests__/company-import-url.test.ts +0 -74
- package/src/__tests__/company-import-zip.test.ts +0 -44
- package/src/__tests__/company.test.ts +0 -599
- package/src/__tests__/context.test.ts +0 -70
- package/src/__tests__/data-dir.test.ts +0 -79
- package/src/__tests__/doctor.test.ts +0 -102
- package/src/__tests__/feedback.test.ts +0 -177
- package/src/__tests__/helpers/embedded-postgres.ts +0 -6
- package/src/__tests__/helpers/zip.ts +0 -87
- package/src/__tests__/home-paths.test.ts +0 -44
- package/src/__tests__/http.test.ts +0 -106
- package/src/__tests__/network-bind.test.ts +0 -62
- package/src/__tests__/onboard.test.ts +0 -166
- package/src/__tests__/routines.test.ts +0 -249
- package/src/__tests__/telemetry.test.ts +0 -117
- package/src/__tests__/worktree-merge-history.test.ts +0 -492
- package/src/__tests__/worktree.test.ts +0 -982
- package/src/adapters/http/format-event.ts +0 -4
- package/src/adapters/http/index.ts +0 -7
- package/src/adapters/index.ts +0 -2
- package/src/adapters/process/format-event.ts +0 -4
- package/src/adapters/process/index.ts +0 -7
- package/src/adapters/registry.ts +0 -63
- package/src/checks/agent-jwt-secret-check.ts +0 -40
- package/src/checks/config-check.ts +0 -33
- package/src/checks/database-check.ts +0 -59
- package/src/checks/deployment-auth-check.ts +0 -88
- package/src/checks/index.ts +0 -18
- package/src/checks/llm-check.ts +0 -82
- package/src/checks/log-check.ts +0 -30
- package/src/checks/path-resolver.ts +0 -1
- package/src/checks/port-check.ts +0 -24
- package/src/checks/secrets-check.ts +0 -146
- package/src/checks/storage-check.ts +0 -51
- package/src/client/board-auth.ts +0 -282
- package/src/client/command-label.ts +0 -4
- package/src/client/context.ts +0 -175
- package/src/client/http.ts +0 -255
- package/src/commands/allowed-hostname.ts +0 -40
- package/src/commands/auth-bootstrap-ceo.ts +0 -138
- package/src/commands/client/activity.ts +0 -71
- package/src/commands/client/agent.ts +0 -315
- package/src/commands/client/approval.ts +0 -259
- package/src/commands/client/auth.ts +0 -113
- package/src/commands/client/common.ts +0 -221
- package/src/commands/client/company.ts +0 -1578
- package/src/commands/client/context.ts +0 -125
- package/src/commands/client/dashboard.ts +0 -34
- package/src/commands/client/feedback.ts +0 -645
- package/src/commands/client/issue.ts +0 -411
- package/src/commands/client/plugin.ts +0 -374
- package/src/commands/client/zip.ts +0 -129
- package/src/commands/configure.ts +0 -201
- package/src/commands/db-backup.ts +0 -102
- package/src/commands/doctor.ts +0 -203
- package/src/commands/env.ts +0 -411
- package/src/commands/heartbeat-run.ts +0 -344
- package/src/commands/onboard.ts +0 -692
- package/src/commands/routines.ts +0 -352
- package/src/commands/run.ts +0 -216
- package/src/commands/worktree-lib.ts +0 -279
- package/src/commands/worktree-merge-history-lib.ts +0 -764
- package/src/commands/worktree.ts +0 -2876
- package/src/config/data-dir.ts +0 -48
- package/src/config/env.ts +0 -125
- package/src/config/home.ts +0 -80
- package/src/config/hostnames.ts +0 -26
- package/src/config/schema.ts +0 -30
- package/src/config/secrets-key.ts +0 -48
- package/src/config/server-bind.ts +0 -183
- package/src/config/store.ts +0 -120
- package/src/index.ts +0 -182
- package/src/prompts/database.ts +0 -157
- package/src/prompts/llm.ts +0 -43
- package/src/prompts/logging.ts +0 -37
- package/src/prompts/secrets.ts +0 -99
- package/src/prompts/server.ts +0 -221
- package/src/prompts/storage.ts +0 -146
- package/src/telemetry.ts +0 -49
- package/src/utils/banner.ts +0 -24
- package/src/utils/net.ts +0 -18
- package/src/utils/path-resolver.ts +0 -25
- package/src/version.ts +0 -10
|
@@ -1,1578 +0,0 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
2
|
-
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import * as p from "@clack/prompts";
|
|
5
|
-
import pc from "picocolors";
|
|
6
|
-
import type {
|
|
7
|
-
Company,
|
|
8
|
-
FeedbackTrace,
|
|
9
|
-
CompanyPortabilityFileEntry,
|
|
10
|
-
CompanyPortabilityExportResult,
|
|
11
|
-
CompanyPortabilityInclude,
|
|
12
|
-
CompanyPortabilityPreviewResult,
|
|
13
|
-
CompanyPortabilityImportResult,
|
|
14
|
-
} from "@paperclipai/shared";
|
|
15
|
-
import { getTelemetryClient, trackCompanyImported } from "../../telemetry.js";
|
|
16
|
-
import { ApiRequestError } from "../../client/http.js";
|
|
17
|
-
import { openUrl } from "../../client/board-auth.js";
|
|
18
|
-
import { binaryContentTypeByExtension, readZipArchive } from "./zip.js";
|
|
19
|
-
import {
|
|
20
|
-
addCommonClientOptions,
|
|
21
|
-
formatInlineRecord,
|
|
22
|
-
handleCommandError,
|
|
23
|
-
printOutput,
|
|
24
|
-
resolveCommandContext,
|
|
25
|
-
type BaseClientOptions,
|
|
26
|
-
} from "./common.js";
|
|
27
|
-
import {
|
|
28
|
-
buildFeedbackTraceQuery,
|
|
29
|
-
normalizeFeedbackTraceExportFormat,
|
|
30
|
-
serializeFeedbackTraces,
|
|
31
|
-
} from "./feedback.js";
|
|
32
|
-
|
|
33
|
-
interface CompanyCommandOptions extends BaseClientOptions {}
|
|
34
|
-
type CompanyDeleteSelectorMode = "auto" | "id" | "prefix";
|
|
35
|
-
type CompanyImportTargetMode = "new" | "existing";
|
|
36
|
-
type CompanyCollisionMode = "rename" | "skip" | "replace";
|
|
37
|
-
|
|
38
|
-
interface CompanyDeleteOptions extends BaseClientOptions {
|
|
39
|
-
by?: CompanyDeleteSelectorMode;
|
|
40
|
-
yes?: boolean;
|
|
41
|
-
confirm?: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface CompanyExportOptions extends BaseClientOptions {
|
|
45
|
-
out?: string;
|
|
46
|
-
include?: string;
|
|
47
|
-
skills?: string;
|
|
48
|
-
projects?: string;
|
|
49
|
-
issues?: string;
|
|
50
|
-
projectIssues?: string;
|
|
51
|
-
expandReferencedSkills?: boolean;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
interface CompanyFeedbackOptions extends BaseClientOptions {
|
|
55
|
-
targetType?: string;
|
|
56
|
-
vote?: string;
|
|
57
|
-
status?: string;
|
|
58
|
-
projectId?: string;
|
|
59
|
-
issueId?: string;
|
|
60
|
-
from?: string;
|
|
61
|
-
to?: string;
|
|
62
|
-
sharedOnly?: boolean;
|
|
63
|
-
includePayload?: boolean;
|
|
64
|
-
out?: string;
|
|
65
|
-
format?: string;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
interface CompanyImportOptions extends BaseClientOptions {
|
|
69
|
-
include?: string;
|
|
70
|
-
target?: CompanyImportTargetMode;
|
|
71
|
-
companyId?: string;
|
|
72
|
-
newCompanyName?: string;
|
|
73
|
-
agents?: string;
|
|
74
|
-
collision?: CompanyCollisionMode;
|
|
75
|
-
ref?: string;
|
|
76
|
-
paperclipUrl?: string;
|
|
77
|
-
yes?: boolean;
|
|
78
|
-
dryRun?: boolean;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const DEFAULT_EXPORT_INCLUDE: CompanyPortabilityInclude = {
|
|
82
|
-
company: true,
|
|
83
|
-
agents: true,
|
|
84
|
-
projects: false,
|
|
85
|
-
issues: false,
|
|
86
|
-
skills: false,
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const DEFAULT_IMPORT_INCLUDE: CompanyPortabilityInclude = {
|
|
90
|
-
company: true,
|
|
91
|
-
agents: true,
|
|
92
|
-
projects: true,
|
|
93
|
-
issues: true,
|
|
94
|
-
skills: true,
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const IMPORT_INCLUDE_OPTIONS: Array<{
|
|
98
|
-
value: keyof CompanyPortabilityInclude;
|
|
99
|
-
label: string;
|
|
100
|
-
hint: string;
|
|
101
|
-
}> = [
|
|
102
|
-
{ value: "company", label: "Company", hint: "name, branding, and company settings" },
|
|
103
|
-
{ value: "projects", label: "Projects", hint: "projects and workspace metadata" },
|
|
104
|
-
{ value: "issues", label: "Tasks", hint: "tasks and recurring routines" },
|
|
105
|
-
{ value: "agents", label: "Agents", hint: "agent records and org structure" },
|
|
106
|
-
{ value: "skills", label: "Skills", hint: "company skill packages and references" },
|
|
107
|
-
];
|
|
108
|
-
|
|
109
|
-
const IMPORT_PREVIEW_SAMPLE_LIMIT = 6;
|
|
110
|
-
|
|
111
|
-
type ImportSelectableGroup = "projects" | "issues" | "agents" | "skills";
|
|
112
|
-
|
|
113
|
-
type ImportSelectionCatalog = {
|
|
114
|
-
company: {
|
|
115
|
-
includedByDefault: boolean;
|
|
116
|
-
files: string[];
|
|
117
|
-
};
|
|
118
|
-
projects: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
|
119
|
-
issues: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
|
120
|
-
agents: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
|
121
|
-
skills: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
|
122
|
-
extensionPath: string | null;
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
type ImportSelectionState = {
|
|
126
|
-
company: boolean;
|
|
127
|
-
projects: Set<string>;
|
|
128
|
-
issues: Set<string>;
|
|
129
|
-
agents: Set<string>;
|
|
130
|
-
skills: Set<string>;
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry {
|
|
134
|
-
const contentType = binaryContentTypeByExtension[path.extname(filePath).toLowerCase()];
|
|
135
|
-
if (!contentType) return contents.toString("utf8");
|
|
136
|
-
return {
|
|
137
|
-
encoding: "base64",
|
|
138
|
-
data: contents.toString("base64"),
|
|
139
|
-
contentType,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function portableFileEntryToWriteValue(entry: CompanyPortabilityFileEntry): string | Uint8Array {
|
|
144
|
-
if (typeof entry === "string") return entry;
|
|
145
|
-
return Buffer.from(entry.data, "base64");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function isUuidLike(value: string): boolean {
|
|
149
|
-
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function normalizeSelector(input: string): string {
|
|
153
|
-
return input.trim();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function parseInclude(
|
|
157
|
-
input: string | undefined,
|
|
158
|
-
fallback: CompanyPortabilityInclude = DEFAULT_EXPORT_INCLUDE,
|
|
159
|
-
): CompanyPortabilityInclude {
|
|
160
|
-
if (!input || !input.trim()) return { ...fallback };
|
|
161
|
-
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
|
|
162
|
-
const include = {
|
|
163
|
-
company: values.includes("company"),
|
|
164
|
-
agents: values.includes("agents"),
|
|
165
|
-
projects: values.includes("projects"),
|
|
166
|
-
issues: values.includes("issues") || values.includes("tasks"),
|
|
167
|
-
skills: values.includes("skills"),
|
|
168
|
-
};
|
|
169
|
-
if (!include.company && !include.agents && !include.projects && !include.issues && !include.skills) {
|
|
170
|
-
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills");
|
|
171
|
-
}
|
|
172
|
-
return include;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function parseAgents(input: string | undefined): "all" | string[] {
|
|
176
|
-
if (!input || !input.trim()) return "all";
|
|
177
|
-
const normalized = input.trim().toLowerCase();
|
|
178
|
-
if (normalized === "all") return "all";
|
|
179
|
-
const values = input.split(",").map((part) => part.trim()).filter(Boolean);
|
|
180
|
-
if (values.length === 0) return "all";
|
|
181
|
-
return Array.from(new Set(values));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function parseCsvValues(input: string | undefined): string[] {
|
|
185
|
-
if (!input || !input.trim()) return [];
|
|
186
|
-
return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean)));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function isInteractiveTerminal(): boolean {
|
|
190
|
-
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function resolveImportInclude(input: string | undefined): CompanyPortabilityInclude {
|
|
194
|
-
return parseInclude(input, DEFAULT_IMPORT_INCLUDE);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function normalizePortablePath(filePath: string): string {
|
|
198
|
-
return filePath.replace(/\\/g, "/");
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function shouldIncludePortableFile(filePath: string): boolean {
|
|
202
|
-
const baseName = path.basename(filePath);
|
|
203
|
-
const isMarkdown = baseName.endsWith(".md");
|
|
204
|
-
const isPaperclipYaml = baseName === ".paperclip.yaml" || baseName === ".paperclip.yml";
|
|
205
|
-
const contentType = binaryContentTypeByExtension[path.extname(baseName).toLowerCase()];
|
|
206
|
-
return isMarkdown || isPaperclipYaml || Boolean(contentType);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function findPortableExtensionPath(files: Record<string, CompanyPortabilityFileEntry>): string | null {
|
|
210
|
-
if (files[".paperclip.yaml"] !== undefined) return ".paperclip.yaml";
|
|
211
|
-
if (files[".paperclip.yml"] !== undefined) return ".paperclip.yml";
|
|
212
|
-
return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function collectFilesUnderDirectory(
|
|
216
|
-
files: Record<string, CompanyPortabilityFileEntry>,
|
|
217
|
-
directory: string,
|
|
218
|
-
opts?: { excludePrefixes?: string[] },
|
|
219
|
-
): string[] {
|
|
220
|
-
const normalizedDirectory = normalizePortablePath(directory).replace(/\/+$/, "");
|
|
221
|
-
if (!normalizedDirectory) return [];
|
|
222
|
-
const prefix = `${normalizedDirectory}/`;
|
|
223
|
-
const excluded = (opts?.excludePrefixes ?? []).map((entry) => normalizePortablePath(entry).replace(/\/+$/, "")).filter(Boolean);
|
|
224
|
-
return Object.keys(files)
|
|
225
|
-
.map(normalizePortablePath)
|
|
226
|
-
.filter((filePath) => filePath.startsWith(prefix))
|
|
227
|
-
.filter((filePath) => !excluded.some((excludePrefix) => filePath.startsWith(`${excludePrefix}/`)))
|
|
228
|
-
.sort((left, right) => left.localeCompare(right));
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function collectEntityFiles(
|
|
232
|
-
files: Record<string, CompanyPortabilityFileEntry>,
|
|
233
|
-
entryPath: string,
|
|
234
|
-
opts?: { excludePrefixes?: string[] },
|
|
235
|
-
): string[] {
|
|
236
|
-
const normalizedPath = normalizePortablePath(entryPath);
|
|
237
|
-
const directory = normalizedPath.includes("/") ? normalizedPath.slice(0, normalizedPath.lastIndexOf("/")) : "";
|
|
238
|
-
const selected = new Set<string>([normalizedPath]);
|
|
239
|
-
if (directory) {
|
|
240
|
-
for (const filePath of collectFilesUnderDirectory(files, directory, opts)) {
|
|
241
|
-
selected.add(filePath);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
return Array.from(selected).sort((left, right) => left.localeCompare(right));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export function buildImportSelectionCatalog(preview: CompanyPortabilityPreviewResult): ImportSelectionCatalog {
|
|
248
|
-
const selectedAgentSlugs = new Set(preview.selectedAgentSlugs);
|
|
249
|
-
const companyFiles = new Set<string>();
|
|
250
|
-
const companyPath = preview.manifest.company?.path ? normalizePortablePath(preview.manifest.company.path) : null;
|
|
251
|
-
if (companyPath) {
|
|
252
|
-
companyFiles.add(companyPath);
|
|
253
|
-
}
|
|
254
|
-
const readmePath = Object.keys(preview.files).find((entry) => normalizePortablePath(entry) === "README.md");
|
|
255
|
-
if (readmePath) {
|
|
256
|
-
companyFiles.add(normalizePortablePath(readmePath));
|
|
257
|
-
}
|
|
258
|
-
const logoPath = preview.manifest.company?.logoPath ? normalizePortablePath(preview.manifest.company.logoPath) : null;
|
|
259
|
-
if (logoPath && preview.files[logoPath] !== undefined) {
|
|
260
|
-
companyFiles.add(logoPath);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return {
|
|
264
|
-
company: {
|
|
265
|
-
includedByDefault: preview.include.company && preview.manifest.company !== null,
|
|
266
|
-
files: Array.from(companyFiles).sort((left, right) => left.localeCompare(right)),
|
|
267
|
-
},
|
|
268
|
-
projects: preview.manifest.projects.map((project) => {
|
|
269
|
-
const projectPath = normalizePortablePath(project.path);
|
|
270
|
-
const projectDir = projectPath.includes("/") ? projectPath.slice(0, projectPath.lastIndexOf("/")) : "";
|
|
271
|
-
return {
|
|
272
|
-
key: project.slug,
|
|
273
|
-
label: project.name,
|
|
274
|
-
hint: project.slug,
|
|
275
|
-
files: collectEntityFiles(preview.files, projectPath, {
|
|
276
|
-
excludePrefixes: projectDir ? [`${projectDir}/issues`] : [],
|
|
277
|
-
}),
|
|
278
|
-
};
|
|
279
|
-
}),
|
|
280
|
-
issues: preview.manifest.issues.map((issue) => ({
|
|
281
|
-
key: issue.slug,
|
|
282
|
-
label: issue.title,
|
|
283
|
-
hint: issue.identifier ?? issue.slug,
|
|
284
|
-
files: collectEntityFiles(preview.files, normalizePortablePath(issue.path)),
|
|
285
|
-
})),
|
|
286
|
-
agents: preview.manifest.agents
|
|
287
|
-
.filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug))
|
|
288
|
-
.map((agent) => ({
|
|
289
|
-
key: agent.slug,
|
|
290
|
-
label: agent.name,
|
|
291
|
-
hint: agent.slug,
|
|
292
|
-
files: collectEntityFiles(preview.files, normalizePortablePath(agent.path)),
|
|
293
|
-
})),
|
|
294
|
-
skills: preview.manifest.skills.map((skill) => ({
|
|
295
|
-
key: skill.slug,
|
|
296
|
-
label: skill.name,
|
|
297
|
-
hint: skill.slug,
|
|
298
|
-
files: collectEntityFiles(preview.files, normalizePortablePath(skill.path)),
|
|
299
|
-
})),
|
|
300
|
-
extensionPath: findPortableExtensionPath(preview.files),
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function toKeySet(items: Array<{ key: string }>): Set<string> {
|
|
305
|
-
return new Set(items.map((item) => item.key));
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export function buildDefaultImportSelectionState(catalog: ImportSelectionCatalog): ImportSelectionState {
|
|
309
|
-
return {
|
|
310
|
-
company: catalog.company.includedByDefault,
|
|
311
|
-
projects: toKeySet(catalog.projects),
|
|
312
|
-
issues: toKeySet(catalog.issues),
|
|
313
|
-
agents: toKeySet(catalog.agents),
|
|
314
|
-
skills: toKeySet(catalog.skills),
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function countSelected(state: ImportSelectionState, group: ImportSelectableGroup): number {
|
|
319
|
-
return state[group].size;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function countTotal(catalog: ImportSelectionCatalog, group: ImportSelectableGroup): number {
|
|
323
|
-
return catalog[group].length;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function summarizeGroupSelection(catalog: ImportSelectionCatalog, state: ImportSelectionState, group: ImportSelectableGroup): string {
|
|
327
|
-
return `${countSelected(state, group)}/${countTotal(catalog, group)} selected`;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function getGroupLabel(group: ImportSelectableGroup): string {
|
|
331
|
-
switch (group) {
|
|
332
|
-
case "projects":
|
|
333
|
-
return "Projects";
|
|
334
|
-
case "issues":
|
|
335
|
-
return "Tasks";
|
|
336
|
-
case "agents":
|
|
337
|
-
return "Agents";
|
|
338
|
-
case "skills":
|
|
339
|
-
return "Skills";
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
export function buildSelectedFilesFromImportSelection(
|
|
344
|
-
catalog: ImportSelectionCatalog,
|
|
345
|
-
state: ImportSelectionState,
|
|
346
|
-
): string[] {
|
|
347
|
-
const selected = new Set<string>();
|
|
348
|
-
|
|
349
|
-
if (state.company) {
|
|
350
|
-
for (const filePath of catalog.company.files) {
|
|
351
|
-
selected.add(normalizePortablePath(filePath));
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
for (const group of ["projects", "issues", "agents", "skills"] as const) {
|
|
356
|
-
const selectedKeys = state[group];
|
|
357
|
-
for (const item of catalog[group]) {
|
|
358
|
-
if (!selectedKeys.has(item.key)) continue;
|
|
359
|
-
for (const filePath of item.files) {
|
|
360
|
-
selected.add(normalizePortablePath(filePath));
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
if (selected.size > 0 && catalog.extensionPath) {
|
|
366
|
-
selected.add(normalizePortablePath(catalog.extensionPath));
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
return Array.from(selected).sort((left, right) => left.localeCompare(right));
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
export function buildDefaultImportAdapterOverrides(
|
|
373
|
-
preview: Pick<CompanyPortabilityPreviewResult, "manifest" | "selectedAgentSlugs">,
|
|
374
|
-
): Record<string, { adapterType: string }> | undefined {
|
|
375
|
-
const selectedAgentSlugs = new Set(preview.selectedAgentSlugs);
|
|
376
|
-
const overrides = Object.fromEntries(
|
|
377
|
-
preview.manifest.agents
|
|
378
|
-
.filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug))
|
|
379
|
-
.filter((agent) => agent.adapterType === "process")
|
|
380
|
-
.map((agent) => [
|
|
381
|
-
agent.slug,
|
|
382
|
-
{
|
|
383
|
-
// TODO: replace this temporary claude_local fallback with adapter selection in the import TUI.
|
|
384
|
-
adapterType: "claude_local",
|
|
385
|
-
},
|
|
386
|
-
]),
|
|
387
|
-
);
|
|
388
|
-
return Object.keys(overrides).length > 0 ? overrides : undefined;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function buildDefaultImportAdapterMessages(
|
|
392
|
-
overrides: Record<string, { adapterType: string }> | undefined,
|
|
393
|
-
): string[] {
|
|
394
|
-
if (!overrides) return [];
|
|
395
|
-
const adapterTypes = Array.from(new Set(Object.values(overrides).map((override) => override.adapterType)))
|
|
396
|
-
.map((adapterType) => adapterType.replace(/_/g, "-"));
|
|
397
|
-
const agentCount = Object.keys(overrides).length;
|
|
398
|
-
return [
|
|
399
|
-
`Using ${adapterTypes.join(", ")} adapter${adapterTypes.length === 1 ? "" : "s"} for ${agentCount} imported ${pluralize(agentCount, "agent")} without an explicit adapter.`,
|
|
400
|
-
];
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
async function promptForImportSelection(preview: CompanyPortabilityPreviewResult): Promise<string[]> {
|
|
404
|
-
const catalog = buildImportSelectionCatalog(preview);
|
|
405
|
-
const state = buildDefaultImportSelectionState(catalog);
|
|
406
|
-
|
|
407
|
-
while (true) {
|
|
408
|
-
const choice = await p.select<ImportSelectableGroup | "company" | "confirm">({
|
|
409
|
-
message: "Select what Paperclip should import",
|
|
410
|
-
options: [
|
|
411
|
-
{
|
|
412
|
-
value: "company",
|
|
413
|
-
label: state.company ? "Company: included" : "Company: skipped",
|
|
414
|
-
hint: catalog.company.files.length > 0 ? "toggle company metadata" : "no company metadata in package",
|
|
415
|
-
},
|
|
416
|
-
{
|
|
417
|
-
value: "projects",
|
|
418
|
-
label: "Select Projects",
|
|
419
|
-
hint: summarizeGroupSelection(catalog, state, "projects"),
|
|
420
|
-
},
|
|
421
|
-
{
|
|
422
|
-
value: "issues",
|
|
423
|
-
label: "Select Tasks",
|
|
424
|
-
hint: summarizeGroupSelection(catalog, state, "issues"),
|
|
425
|
-
},
|
|
426
|
-
{
|
|
427
|
-
value: "agents",
|
|
428
|
-
label: "Select Agents",
|
|
429
|
-
hint: summarizeGroupSelection(catalog, state, "agents"),
|
|
430
|
-
},
|
|
431
|
-
{
|
|
432
|
-
value: "skills",
|
|
433
|
-
label: "Select Skills",
|
|
434
|
-
hint: summarizeGroupSelection(catalog, state, "skills"),
|
|
435
|
-
},
|
|
436
|
-
{
|
|
437
|
-
value: "confirm",
|
|
438
|
-
label: "Confirm",
|
|
439
|
-
hint: `${buildSelectedFilesFromImportSelection(catalog, state).length} files selected`,
|
|
440
|
-
},
|
|
441
|
-
],
|
|
442
|
-
initialValue: "confirm",
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
if (p.isCancel(choice)) {
|
|
446
|
-
p.cancel("Import cancelled.");
|
|
447
|
-
process.exit(0);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (choice === "confirm") {
|
|
451
|
-
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
|
|
452
|
-
if (selectedFiles.length === 0) {
|
|
453
|
-
p.note("Select at least one import target before confirming.", "Nothing selected");
|
|
454
|
-
continue;
|
|
455
|
-
}
|
|
456
|
-
return selectedFiles;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (choice === "company") {
|
|
460
|
-
if (catalog.company.files.length === 0) {
|
|
461
|
-
p.note("This package does not include company metadata to toggle.", "No company metadata");
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
state.company = !state.company;
|
|
465
|
-
continue;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const group = choice;
|
|
469
|
-
const groupItems = catalog[group];
|
|
470
|
-
if (groupItems.length === 0) {
|
|
471
|
-
p.note(`This package does not include any ${getGroupLabel(group).toLowerCase()}.`, `No ${getGroupLabel(group)}`);
|
|
472
|
-
continue;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
const selection = await p.multiselect<string>({
|
|
476
|
-
message: `${getGroupLabel(group)} to import. Space toggles, enter returns to the main menu.`,
|
|
477
|
-
options: groupItems.map((item) => ({
|
|
478
|
-
value: item.key,
|
|
479
|
-
label: item.label,
|
|
480
|
-
hint: item.hint,
|
|
481
|
-
})),
|
|
482
|
-
initialValues: Array.from(state[group]),
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
if (p.isCancel(selection)) {
|
|
486
|
-
p.cancel("Import cancelled.");
|
|
487
|
-
process.exit(0);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
state[group] = new Set(selection);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function summarizeInclude(include: CompanyPortabilityInclude): string {
|
|
495
|
-
const labels = IMPORT_INCLUDE_OPTIONS
|
|
496
|
-
.filter((option) => include[option.value])
|
|
497
|
-
.map((option) => option.label.toLowerCase());
|
|
498
|
-
return labels.length > 0 ? labels.join(", ") : "nothing selected";
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function formatSourceLabel(source: { type: "inline"; rootPath?: string | null } | { type: "github"; url: string }): string {
|
|
502
|
-
if (source.type === "github") {
|
|
503
|
-
return `GitHub: ${source.url}`;
|
|
504
|
-
}
|
|
505
|
-
return `Local package: ${source.rootPath?.trim() || "(current folder)"}`;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function formatTargetLabel(
|
|
509
|
-
target: { mode: "existing_company"; companyId?: string | null } | { mode: "new_company"; newCompanyName?: string | null },
|
|
510
|
-
preview?: CompanyPortabilityPreviewResult,
|
|
511
|
-
): string {
|
|
512
|
-
if (target.mode === "existing_company") {
|
|
513
|
-
const targetName = preview?.targetCompanyName?.trim();
|
|
514
|
-
const targetId = preview?.targetCompanyId?.trim() || target.companyId?.trim() || "unknown-company";
|
|
515
|
-
return targetName ? `${targetName} (${targetId})` : targetId;
|
|
516
|
-
}
|
|
517
|
-
return target.newCompanyName?.trim() || preview?.manifest.company?.name || "new company";
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function pluralize(count: number, singular: string, plural = `${singular}s`): string {
|
|
521
|
-
return count === 1 ? singular : plural;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function summarizePlanCounts(
|
|
525
|
-
plans: Array<{ action: "create" | "update" | "skip" }>,
|
|
526
|
-
noun: string,
|
|
527
|
-
): string {
|
|
528
|
-
if (plans.length === 0) return `0 ${pluralize(0, noun)} selected`;
|
|
529
|
-
const createCount = plans.filter((plan) => plan.action === "create").length;
|
|
530
|
-
const updateCount = plans.filter((plan) => plan.action === "update").length;
|
|
531
|
-
const skipCount = plans.filter((plan) => plan.action === "skip").length;
|
|
532
|
-
const parts: string[] = [];
|
|
533
|
-
if (createCount > 0) parts.push(`${createCount} create`);
|
|
534
|
-
if (updateCount > 0) parts.push(`${updateCount} update`);
|
|
535
|
-
if (skipCount > 0) parts.push(`${skipCount} skip`);
|
|
536
|
-
return `${plans.length} ${pluralize(plans.length, noun)} total (${parts.join(", ")})`;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function summarizeImportAgentResults(agents: CompanyPortabilityImportResult["agents"]): string {
|
|
540
|
-
if (agents.length === 0) return "0 agents changed";
|
|
541
|
-
const created = agents.filter((agent) => agent.action === "created").length;
|
|
542
|
-
const updated = agents.filter((agent) => agent.action === "updated").length;
|
|
543
|
-
const skipped = agents.filter((agent) => agent.action === "skipped").length;
|
|
544
|
-
const parts: string[] = [];
|
|
545
|
-
if (created > 0) parts.push(`${created} created`);
|
|
546
|
-
if (updated > 0) parts.push(`${updated} updated`);
|
|
547
|
-
if (skipped > 0) parts.push(`${skipped} skipped`);
|
|
548
|
-
return `${agents.length} ${pluralize(agents.length, "agent")} total (${parts.join(", ")})`;
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function summarizeImportProjectResults(projects: CompanyPortabilityImportResult["projects"]): string {
|
|
552
|
-
if (projects.length === 0) return "0 projects changed";
|
|
553
|
-
const created = projects.filter((project) => project.action === "created").length;
|
|
554
|
-
const updated = projects.filter((project) => project.action === "updated").length;
|
|
555
|
-
const skipped = projects.filter((project) => project.action === "skipped").length;
|
|
556
|
-
const parts: string[] = [];
|
|
557
|
-
if (created > 0) parts.push(`${created} created`);
|
|
558
|
-
if (updated > 0) parts.push(`${updated} updated`);
|
|
559
|
-
if (skipped > 0) parts.push(`${skipped} skipped`);
|
|
560
|
-
return `${projects.length} ${pluralize(projects.length, "project")} total (${parts.join(", ")})`;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
function actionChip(action: string): string {
|
|
564
|
-
switch (action) {
|
|
565
|
-
case "create":
|
|
566
|
-
case "created":
|
|
567
|
-
return pc.green(action);
|
|
568
|
-
case "update":
|
|
569
|
-
case "updated":
|
|
570
|
-
return pc.yellow(action);
|
|
571
|
-
case "skip":
|
|
572
|
-
case "skipped":
|
|
573
|
-
case "none":
|
|
574
|
-
case "unchanged":
|
|
575
|
-
return pc.dim(action);
|
|
576
|
-
default:
|
|
577
|
-
return action;
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
function appendPreviewExamples(
|
|
582
|
-
lines: string[],
|
|
583
|
-
title: string,
|
|
584
|
-
entries: Array<{ action: string; label: string; reason?: string | null }>,
|
|
585
|
-
): void {
|
|
586
|
-
if (entries.length === 0) return;
|
|
587
|
-
lines.push("");
|
|
588
|
-
lines.push(pc.bold(title));
|
|
589
|
-
const shown = entries.slice(0, IMPORT_PREVIEW_SAMPLE_LIMIT);
|
|
590
|
-
for (const entry of shown) {
|
|
591
|
-
const reason = entry.reason?.trim() ? pc.dim(` (${entry.reason.trim()})`) : "";
|
|
592
|
-
lines.push(`- ${actionChip(entry.action)} ${entry.label}${reason}`);
|
|
593
|
-
}
|
|
594
|
-
if (entries.length > shown.length) {
|
|
595
|
-
lines.push(pc.dim(`- +${entries.length - shown.length} more`));
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function appendMessageBlock(lines: string[], title: string, messages: string[]): void {
|
|
600
|
-
if (messages.length === 0) return;
|
|
601
|
-
lines.push("");
|
|
602
|
-
lines.push(pc.bold(title));
|
|
603
|
-
for (const message of messages) {
|
|
604
|
-
lines.push(`- ${message}`);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
export function renderCompanyImportPreview(
|
|
609
|
-
preview: CompanyPortabilityPreviewResult,
|
|
610
|
-
meta: {
|
|
611
|
-
sourceLabel: string;
|
|
612
|
-
targetLabel: string;
|
|
613
|
-
infoMessages?: string[];
|
|
614
|
-
},
|
|
615
|
-
): string {
|
|
616
|
-
const lines: string[] = [
|
|
617
|
-
`${pc.bold("Source")} ${meta.sourceLabel}`,
|
|
618
|
-
`${pc.bold("Target")} ${meta.targetLabel}`,
|
|
619
|
-
`${pc.bold("Include")} ${summarizeInclude(preview.include)}`,
|
|
620
|
-
`${pc.bold("Mode")} ${preview.collisionStrategy} collisions`,
|
|
621
|
-
"",
|
|
622
|
-
pc.bold("Package"),
|
|
623
|
-
`- company: ${preview.manifest.company?.name ?? preview.manifest.source?.companyName ?? "not included"}`,
|
|
624
|
-
`- agents: ${preview.manifest.agents.length}`,
|
|
625
|
-
`- projects: ${preview.manifest.projects.length}`,
|
|
626
|
-
`- tasks: ${preview.manifest.issues.length}`,
|
|
627
|
-
`- skills: ${preview.manifest.skills.length}`,
|
|
628
|
-
];
|
|
629
|
-
|
|
630
|
-
if (preview.envInputs.length > 0) {
|
|
631
|
-
const requiredCount = preview.envInputs.filter((item) => item.requirement === "required").length;
|
|
632
|
-
lines.push(`- env inputs: ${preview.envInputs.length} (${requiredCount} required)`);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
lines.push("");
|
|
636
|
-
lines.push(pc.bold("Plan"));
|
|
637
|
-
lines.push(`- company: ${actionChip(preview.plan.companyAction === "none" ? "unchanged" : preview.plan.companyAction)}`);
|
|
638
|
-
lines.push(`- agents: ${summarizePlanCounts(preview.plan.agentPlans, "agent")}`);
|
|
639
|
-
lines.push(`- projects: ${summarizePlanCounts(preview.plan.projectPlans, "project")}`);
|
|
640
|
-
lines.push(`- tasks: ${summarizePlanCounts(preview.plan.issuePlans, "task")}`);
|
|
641
|
-
if (preview.include.skills) {
|
|
642
|
-
lines.push(`- skills: ${preview.manifest.skills.length} ${pluralize(preview.manifest.skills.length, "skill")} packaged`);
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
appendPreviewExamples(
|
|
646
|
-
lines,
|
|
647
|
-
"Agent examples",
|
|
648
|
-
preview.plan.agentPlans.map((plan) => ({
|
|
649
|
-
action: plan.action,
|
|
650
|
-
label: `${plan.slug} -> ${plan.plannedName}`,
|
|
651
|
-
reason: plan.reason,
|
|
652
|
-
})),
|
|
653
|
-
);
|
|
654
|
-
appendPreviewExamples(
|
|
655
|
-
lines,
|
|
656
|
-
"Project examples",
|
|
657
|
-
preview.plan.projectPlans.map((plan) => ({
|
|
658
|
-
action: plan.action,
|
|
659
|
-
label: `${plan.slug} -> ${plan.plannedName}`,
|
|
660
|
-
reason: plan.reason,
|
|
661
|
-
})),
|
|
662
|
-
);
|
|
663
|
-
appendPreviewExamples(
|
|
664
|
-
lines,
|
|
665
|
-
"Task examples",
|
|
666
|
-
preview.plan.issuePlans.map((plan) => ({
|
|
667
|
-
action: plan.action,
|
|
668
|
-
label: `${plan.slug} -> ${plan.plannedTitle}`,
|
|
669
|
-
reason: plan.reason,
|
|
670
|
-
})),
|
|
671
|
-
);
|
|
672
|
-
|
|
673
|
-
appendMessageBlock(lines, pc.cyan("Info"), meta.infoMessages ?? []);
|
|
674
|
-
appendMessageBlock(lines, pc.yellow("Warnings"), preview.warnings);
|
|
675
|
-
appendMessageBlock(lines, pc.red("Errors"), preview.errors);
|
|
676
|
-
|
|
677
|
-
return lines.join("\n");
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
export function renderCompanyImportResult(
|
|
681
|
-
result: CompanyPortabilityImportResult,
|
|
682
|
-
meta: { targetLabel: string; companyUrl?: string; infoMessages?: string[] },
|
|
683
|
-
): string {
|
|
684
|
-
const lines: string[] = [
|
|
685
|
-
`${pc.bold("Target")} ${meta.targetLabel}`,
|
|
686
|
-
`${pc.bold("Company")} ${result.company.name} (${actionChip(result.company.action)})`,
|
|
687
|
-
`${pc.bold("Agents")} ${summarizeImportAgentResults(result.agents)}`,
|
|
688
|
-
`${pc.bold("Projects")} ${summarizeImportProjectResults(result.projects)}`,
|
|
689
|
-
];
|
|
690
|
-
|
|
691
|
-
if (meta.companyUrl) {
|
|
692
|
-
lines.splice(1, 0, `${pc.bold("URL")} ${meta.companyUrl}`);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
appendPreviewExamples(
|
|
696
|
-
lines,
|
|
697
|
-
"Agent results",
|
|
698
|
-
result.agents.map((agent) => ({
|
|
699
|
-
action: agent.action,
|
|
700
|
-
label: `${agent.slug} -> ${agent.name}`,
|
|
701
|
-
reason: agent.reason,
|
|
702
|
-
})),
|
|
703
|
-
);
|
|
704
|
-
appendPreviewExamples(
|
|
705
|
-
lines,
|
|
706
|
-
"Project results",
|
|
707
|
-
result.projects.map((project) => ({
|
|
708
|
-
action: project.action,
|
|
709
|
-
label: `${project.slug} -> ${project.name}`,
|
|
710
|
-
reason: project.reason,
|
|
711
|
-
})),
|
|
712
|
-
);
|
|
713
|
-
|
|
714
|
-
if (result.envInputs.length > 0) {
|
|
715
|
-
lines.push("");
|
|
716
|
-
lines.push(pc.bold("Env inputs"));
|
|
717
|
-
lines.push(
|
|
718
|
-
`- ${result.envInputs.length} ${pluralize(result.envInputs.length, "input")} may need values after import`,
|
|
719
|
-
);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
appendMessageBlock(lines, pc.cyan("Info"), meta.infoMessages ?? []);
|
|
723
|
-
appendMessageBlock(lines, pc.yellow("Warnings"), result.warnings);
|
|
724
|
-
|
|
725
|
-
return lines.join("\n");
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
function printCompanyImportView(title: string, body: string, opts?: { interactive?: boolean }): void {
|
|
729
|
-
if (opts?.interactive) {
|
|
730
|
-
p.note(body, title);
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
console.log(pc.bold(title));
|
|
734
|
-
console.log(body);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
export function resolveCompanyImportApiPath(input: {
|
|
738
|
-
dryRun: boolean;
|
|
739
|
-
targetMode: "new_company" | "existing_company";
|
|
740
|
-
companyId?: string | null;
|
|
741
|
-
}): string {
|
|
742
|
-
if (input.targetMode === "existing_company") {
|
|
743
|
-
const companyId = input.companyId?.trim();
|
|
744
|
-
if (!companyId) {
|
|
745
|
-
throw new Error("Existing-company imports require a companyId to resolve the API route.");
|
|
746
|
-
}
|
|
747
|
-
return input.dryRun
|
|
748
|
-
? `/api/companies/${companyId}/imports/preview`
|
|
749
|
-
: `/api/companies/${companyId}/imports/apply`;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
return input.dryRun ? "/api/companies/import/preview" : "/api/companies/import";
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
export function buildCompanyDashboardUrl(apiBase: string, issuePrefix: string): string {
|
|
756
|
-
const url = new URL(apiBase);
|
|
757
|
-
const normalizedPrefix = issuePrefix.trim().replace(/^\/+|\/+$/g, "");
|
|
758
|
-
url.pathname = `${url.pathname.replace(/\/+$/, "")}/${normalizedPrefix}/dashboard`;
|
|
759
|
-
url.search = "";
|
|
760
|
-
url.hash = "";
|
|
761
|
-
return url.toString();
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
export function resolveCompanyImportApplyConfirmationMode(input: {
|
|
765
|
-
yes?: boolean;
|
|
766
|
-
interactive: boolean;
|
|
767
|
-
json: boolean;
|
|
768
|
-
}): "skip" | "prompt" {
|
|
769
|
-
if (input.yes) {
|
|
770
|
-
return "skip";
|
|
771
|
-
}
|
|
772
|
-
if (input.json) {
|
|
773
|
-
throw new Error(
|
|
774
|
-
"Applying a company import with --json requires --yes. Use --dry-run first to inspect the preview.",
|
|
775
|
-
);
|
|
776
|
-
}
|
|
777
|
-
if (!input.interactive) {
|
|
778
|
-
throw new Error(
|
|
779
|
-
"Applying a company import from a non-interactive terminal requires --yes. Use --dry-run first to inspect the preview.",
|
|
780
|
-
);
|
|
781
|
-
}
|
|
782
|
-
return "prompt";
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
export function isHttpUrl(input: string): boolean {
|
|
786
|
-
return /^https?:\/\//i.test(input.trim());
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
export function looksLikeRepoUrl(input: string): boolean {
|
|
790
|
-
try {
|
|
791
|
-
const url = new URL(input.trim());
|
|
792
|
-
if (url.protocol !== "https:") return false;
|
|
793
|
-
const segments = url.pathname.split("/").filter(Boolean);
|
|
794
|
-
return segments.length >= 2;
|
|
795
|
-
} catch {
|
|
796
|
-
return false;
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
function isGithubSegment(input: string): boolean {
|
|
801
|
-
return /^[A-Za-z0-9._-]+$/.test(input);
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
export function isGithubShorthand(input: string): boolean {
|
|
805
|
-
const trimmed = input.trim();
|
|
806
|
-
if (!trimmed || isHttpUrl(trimmed)) return false;
|
|
807
|
-
if (
|
|
808
|
-
trimmed.startsWith(".") ||
|
|
809
|
-
trimmed.startsWith("/") ||
|
|
810
|
-
trimmed.startsWith("~") ||
|
|
811
|
-
trimmed.includes("\\") ||
|
|
812
|
-
/^[A-Za-z]:/.test(trimmed)
|
|
813
|
-
) {
|
|
814
|
-
return false;
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
const segments = trimmed.split("/").filter(Boolean);
|
|
818
|
-
return segments.length >= 2 && segments.every(isGithubSegment);
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
function normalizeGithubImportPath(input: string | null | undefined): string | null {
|
|
822
|
-
if (!input) return null;
|
|
823
|
-
const trimmed = input.trim().replace(/^\/+|\/+$/g, "");
|
|
824
|
-
return trimmed || null;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
function buildGithubImportUrl(input: {
|
|
828
|
-
hostname?: string;
|
|
829
|
-
owner: string;
|
|
830
|
-
repo: string;
|
|
831
|
-
ref?: string | null;
|
|
832
|
-
path?: string | null;
|
|
833
|
-
companyPath?: string | null;
|
|
834
|
-
}): string {
|
|
835
|
-
const host = input.hostname || "github.com";
|
|
836
|
-
const url = new URL(`https://${host}/${input.owner}/${input.repo.replace(/\.git$/i, "")}`);
|
|
837
|
-
const ref = input.ref?.trim();
|
|
838
|
-
if (ref) {
|
|
839
|
-
url.searchParams.set("ref", ref);
|
|
840
|
-
}
|
|
841
|
-
const companyPath = normalizeGithubImportPath(input.companyPath);
|
|
842
|
-
if (companyPath) {
|
|
843
|
-
url.searchParams.set("companyPath", companyPath);
|
|
844
|
-
return url.toString();
|
|
845
|
-
}
|
|
846
|
-
const sourcePath = normalizeGithubImportPath(input.path);
|
|
847
|
-
if (sourcePath) {
|
|
848
|
-
url.searchParams.set("path", sourcePath);
|
|
849
|
-
}
|
|
850
|
-
return url.toString();
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
export function normalizeGithubImportSource(input: string, refOverride?: string): string {
|
|
854
|
-
const trimmed = input.trim();
|
|
855
|
-
const ref = refOverride?.trim();
|
|
856
|
-
|
|
857
|
-
if (isGithubShorthand(trimmed)) {
|
|
858
|
-
const [owner, repo, ...repoPath] = trimmed.split("/").filter(Boolean);
|
|
859
|
-
return buildGithubImportUrl({
|
|
860
|
-
owner: owner!,
|
|
861
|
-
repo: repo!,
|
|
862
|
-
ref: ref || "main",
|
|
863
|
-
path: repoPath.join("/"),
|
|
864
|
-
});
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
if (!looksLikeRepoUrl(trimmed)) {
|
|
868
|
-
throw new Error("GitHub source must be a GitHub or GitHub Enterprise URL, or owner/repo[/path] shorthand.");
|
|
869
|
-
}
|
|
870
|
-
if (!ref) {
|
|
871
|
-
return trimmed;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
const url = new URL(trimmed);
|
|
875
|
-
const hostname = url.hostname;
|
|
876
|
-
const parts = url.pathname.split("/").filter(Boolean);
|
|
877
|
-
if (parts.length < 2) {
|
|
878
|
-
throw new Error("Invalid GitHub URL.");
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
const owner = parts[0]!;
|
|
882
|
-
const repo = parts[1]!;
|
|
883
|
-
const existingPath = normalizeGithubImportPath(url.searchParams.get("path"));
|
|
884
|
-
const existingCompanyPath = normalizeGithubImportPath(url.searchParams.get("companyPath"));
|
|
885
|
-
if (existingCompanyPath) {
|
|
886
|
-
return buildGithubImportUrl({ hostname, owner, repo, ref, companyPath: existingCompanyPath });
|
|
887
|
-
}
|
|
888
|
-
if (existingPath) {
|
|
889
|
-
return buildGithubImportUrl({ hostname, owner, repo, ref, path: existingPath });
|
|
890
|
-
}
|
|
891
|
-
if (parts[2] === "tree") {
|
|
892
|
-
return buildGithubImportUrl({ hostname, owner, repo, ref, path: parts.slice(4).join("/") });
|
|
893
|
-
}
|
|
894
|
-
if (parts[2] === "blob") {
|
|
895
|
-
return buildGithubImportUrl({ hostname, owner, repo, ref, companyPath: parts.slice(4).join("/") });
|
|
896
|
-
}
|
|
897
|
-
return buildGithubImportUrl({ hostname, owner, repo, ref });
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
async function pathExists(inputPath: string): Promise<boolean> {
|
|
901
|
-
try {
|
|
902
|
-
await stat(path.resolve(inputPath));
|
|
903
|
-
return true;
|
|
904
|
-
} catch {
|
|
905
|
-
return false;
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
async function collectPackageFiles(
|
|
910
|
-
root: string,
|
|
911
|
-
current: string,
|
|
912
|
-
files: Record<string, CompanyPortabilityFileEntry>,
|
|
913
|
-
): Promise<void> {
|
|
914
|
-
const entries = await readdir(current, { withFileTypes: true });
|
|
915
|
-
for (const entry of entries) {
|
|
916
|
-
if (entry.name.startsWith(".git")) continue;
|
|
917
|
-
const absolutePath = path.join(current, entry.name);
|
|
918
|
-
if (entry.isDirectory()) {
|
|
919
|
-
await collectPackageFiles(root, absolutePath, files);
|
|
920
|
-
continue;
|
|
921
|
-
}
|
|
922
|
-
if (!entry.isFile()) continue;
|
|
923
|
-
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
|
|
924
|
-
if (!shouldIncludePortableFile(relativePath)) continue;
|
|
925
|
-
files[relativePath] = readPortableFileEntry(relativePath, await readFile(absolutePath));
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
export async function resolveInlineSourceFromPath(inputPath: string): Promise<{
|
|
930
|
-
rootPath: string;
|
|
931
|
-
files: Record<string, CompanyPortabilityFileEntry>;
|
|
932
|
-
}> {
|
|
933
|
-
const resolved = path.resolve(inputPath);
|
|
934
|
-
const resolvedStat = await stat(resolved);
|
|
935
|
-
if (resolvedStat.isFile() && path.extname(resolved).toLowerCase() === ".zip") {
|
|
936
|
-
const archive = await readZipArchive(await readFile(resolved));
|
|
937
|
-
const filteredFiles = Object.fromEntries(
|
|
938
|
-
Object.entries(archive.files).filter(([relativePath]) => shouldIncludePortableFile(relativePath)),
|
|
939
|
-
);
|
|
940
|
-
return {
|
|
941
|
-
rootPath: archive.rootPath ?? path.basename(resolved, ".zip"),
|
|
942
|
-
files: filteredFiles,
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved);
|
|
947
|
-
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
|
948
|
-
await collectPackageFiles(rootDir, rootDir, files);
|
|
949
|
-
return {
|
|
950
|
-
rootPath: path.basename(rootDir),
|
|
951
|
-
files,
|
|
952
|
-
};
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise<void> {
|
|
956
|
-
const root = path.resolve(outDir);
|
|
957
|
-
await mkdir(root, { recursive: true });
|
|
958
|
-
for (const [relativePath, content] of Object.entries(exported.files)) {
|
|
959
|
-
const normalized = relativePath.replace(/\\/g, "/");
|
|
960
|
-
const filePath = path.join(root, normalized);
|
|
961
|
-
await mkdir(path.dirname(filePath), { recursive: true });
|
|
962
|
-
const writeValue = portableFileEntryToWriteValue(content);
|
|
963
|
-
if (typeof writeValue === "string") {
|
|
964
|
-
await writeFile(filePath, writeValue, "utf8");
|
|
965
|
-
} else {
|
|
966
|
-
await writeFile(filePath, writeValue);
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
async function confirmOverwriteExportDirectory(outDir: string): Promise<void> {
|
|
972
|
-
const root = path.resolve(outDir);
|
|
973
|
-
const stats = await stat(root).catch(() => null);
|
|
974
|
-
if (!stats) return;
|
|
975
|
-
if (!stats.isDirectory()) {
|
|
976
|
-
throw new Error(`Export output path ${root} exists and is not a directory.`);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
const entries = await readdir(root);
|
|
980
|
-
if (entries.length === 0) return;
|
|
981
|
-
|
|
982
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
983
|
-
throw new Error(`Export output directory ${root} already contains files. Re-run interactively or choose an empty directory.`);
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
const confirmed = await p.confirm({
|
|
987
|
-
message: `Overwrite existing files in ${root}?`,
|
|
988
|
-
initialValue: false,
|
|
989
|
-
});
|
|
990
|
-
|
|
991
|
-
if (p.isCancel(confirmed) || !confirmed) {
|
|
992
|
-
throw new Error("Export cancelled.");
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
function matchesPrefix(company: Company, selector: string): boolean {
|
|
997
|
-
return company.issuePrefix.toUpperCase() === selector.toUpperCase();
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
export function resolveCompanyForDeletion(
|
|
1001
|
-
companies: Company[],
|
|
1002
|
-
selectorRaw: string,
|
|
1003
|
-
by: CompanyDeleteSelectorMode = "auto",
|
|
1004
|
-
): Company {
|
|
1005
|
-
const selector = normalizeSelector(selectorRaw);
|
|
1006
|
-
if (!selector) {
|
|
1007
|
-
throw new Error("Company selector is required.");
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
const idMatch = companies.find((company) => company.id === selector);
|
|
1011
|
-
const prefixMatch = companies.find((company) => matchesPrefix(company, selector));
|
|
1012
|
-
|
|
1013
|
-
if (by === "id") {
|
|
1014
|
-
if (!idMatch) {
|
|
1015
|
-
throw new Error(`No company found by ID '${selector}'.`);
|
|
1016
|
-
}
|
|
1017
|
-
return idMatch;
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
|
-
if (by === "prefix") {
|
|
1021
|
-
if (!prefixMatch) {
|
|
1022
|
-
throw new Error(`No company found by shortname/prefix '${selector}'.`);
|
|
1023
|
-
}
|
|
1024
|
-
return prefixMatch;
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
if (idMatch && prefixMatch && idMatch.id !== prefixMatch.id) {
|
|
1028
|
-
throw new Error(
|
|
1029
|
-
`Selector '${selector}' is ambiguous (matches both an ID and a shortname). Re-run with --by id or --by prefix.`,
|
|
1030
|
-
);
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
if (idMatch) return idMatch;
|
|
1034
|
-
if (prefixMatch) return prefixMatch;
|
|
1035
|
-
|
|
1036
|
-
throw new Error(
|
|
1037
|
-
`No company found for selector '${selector}'. Use company ID or issue prefix (for example PAP).`,
|
|
1038
|
-
);
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
export function assertDeleteConfirmation(company: Company, opts: CompanyDeleteOptions): void {
|
|
1042
|
-
if (!opts.yes) {
|
|
1043
|
-
throw new Error("Deletion requires --yes.");
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
const confirm = opts.confirm?.trim();
|
|
1047
|
-
if (!confirm) {
|
|
1048
|
-
throw new Error(
|
|
1049
|
-
"Deletion requires --confirm <value> where value matches the company ID or issue prefix.",
|
|
1050
|
-
);
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
const confirmsById = confirm === company.id;
|
|
1054
|
-
const confirmsByPrefix = confirm.toUpperCase() === company.issuePrefix.toUpperCase();
|
|
1055
|
-
if (!confirmsById && !confirmsByPrefix) {
|
|
1056
|
-
throw new Error(
|
|
1057
|
-
`Confirmation '${confirm}' does not match target company. Expected ID '${company.id}' or prefix '${company.issuePrefix}'.`,
|
|
1058
|
-
);
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
function assertDeleteFlags(opts: CompanyDeleteOptions): void {
|
|
1063
|
-
if (!opts.yes) {
|
|
1064
|
-
throw new Error("Deletion requires --yes.");
|
|
1065
|
-
}
|
|
1066
|
-
if (!opts.confirm?.trim()) {
|
|
1067
|
-
throw new Error(
|
|
1068
|
-
"Deletion requires --confirm <value> where value matches the company ID or issue prefix.",
|
|
1069
|
-
);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
export function registerCompanyCommands(program: Command): void {
|
|
1074
|
-
const company = program.command("company").description("Company operations");
|
|
1075
|
-
|
|
1076
|
-
addCommonClientOptions(
|
|
1077
|
-
company
|
|
1078
|
-
.command("list")
|
|
1079
|
-
.description("List companies")
|
|
1080
|
-
.action(async (opts: CompanyCommandOptions) => {
|
|
1081
|
-
try {
|
|
1082
|
-
const ctx = resolveCommandContext(opts);
|
|
1083
|
-
const rows = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
|
|
1084
|
-
if (ctx.json) {
|
|
1085
|
-
printOutput(rows, { json: true });
|
|
1086
|
-
return;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
if (rows.length === 0) {
|
|
1090
|
-
printOutput([], { json: false });
|
|
1091
|
-
return;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
const formatted = rows.map((row) => ({
|
|
1095
|
-
id: row.id,
|
|
1096
|
-
name: row.name,
|
|
1097
|
-
status: row.status,
|
|
1098
|
-
budgetMonthlyCents: row.budgetMonthlyCents,
|
|
1099
|
-
spentMonthlyCents: row.spentMonthlyCents,
|
|
1100
|
-
requireBoardApprovalForNewAgents: row.requireBoardApprovalForNewAgents,
|
|
1101
|
-
}));
|
|
1102
|
-
for (const row of formatted) {
|
|
1103
|
-
console.log(formatInlineRecord(row));
|
|
1104
|
-
}
|
|
1105
|
-
} catch (err) {
|
|
1106
|
-
handleCommandError(err);
|
|
1107
|
-
}
|
|
1108
|
-
}),
|
|
1109
|
-
);
|
|
1110
|
-
|
|
1111
|
-
addCommonClientOptions(
|
|
1112
|
-
company
|
|
1113
|
-
.command("get")
|
|
1114
|
-
.description("Get one company")
|
|
1115
|
-
.argument("<companyId>", "Company ID")
|
|
1116
|
-
.action(async (companyId: string, opts: CompanyCommandOptions) => {
|
|
1117
|
-
try {
|
|
1118
|
-
const ctx = resolveCommandContext(opts);
|
|
1119
|
-
const row = await ctx.api.get<Company>(`/api/companies/${companyId}`);
|
|
1120
|
-
printOutput(row, { json: ctx.json });
|
|
1121
|
-
} catch (err) {
|
|
1122
|
-
handleCommandError(err);
|
|
1123
|
-
}
|
|
1124
|
-
}),
|
|
1125
|
-
);
|
|
1126
|
-
|
|
1127
|
-
addCommonClientOptions(
|
|
1128
|
-
company
|
|
1129
|
-
.command("feedback:list")
|
|
1130
|
-
.description("List feedback traces for a company")
|
|
1131
|
-
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
1132
|
-
.option("--target-type <type>", "Filter by target type")
|
|
1133
|
-
.option("--vote <vote>", "Filter by vote value")
|
|
1134
|
-
.option("--status <status>", "Filter by trace status")
|
|
1135
|
-
.option("--project-id <id>", "Filter by project ID")
|
|
1136
|
-
.option("--issue-id <id>", "Filter by issue ID")
|
|
1137
|
-
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
|
1138
|
-
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
|
1139
|
-
.option("--shared-only", "Only include traces eligible for sharing/export")
|
|
1140
|
-
.option("--include-payload", "Include stored payload snapshots in the response")
|
|
1141
|
-
.action(async (opts: CompanyFeedbackOptions) => {
|
|
1142
|
-
try {
|
|
1143
|
-
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
1144
|
-
const traces = (await ctx.api.get<FeedbackTrace[]>(
|
|
1145
|
-
`/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts)}`,
|
|
1146
|
-
)) ?? [];
|
|
1147
|
-
if (ctx.json) {
|
|
1148
|
-
printOutput(traces, { json: true });
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
printOutput(
|
|
1152
|
-
traces.map((trace) => ({
|
|
1153
|
-
id: trace.id,
|
|
1154
|
-
issue: trace.issueIdentifier ?? trace.issueId,
|
|
1155
|
-
vote: trace.vote,
|
|
1156
|
-
status: trace.status,
|
|
1157
|
-
targetType: trace.targetType,
|
|
1158
|
-
target: trace.targetSummary.label,
|
|
1159
|
-
})),
|
|
1160
|
-
{ json: false },
|
|
1161
|
-
);
|
|
1162
|
-
} catch (err) {
|
|
1163
|
-
handleCommandError(err);
|
|
1164
|
-
}
|
|
1165
|
-
}),
|
|
1166
|
-
{ includeCompany: false },
|
|
1167
|
-
);
|
|
1168
|
-
|
|
1169
|
-
addCommonClientOptions(
|
|
1170
|
-
company
|
|
1171
|
-
.command("feedback:export")
|
|
1172
|
-
.description("Export feedback traces for a company")
|
|
1173
|
-
.requiredOption("-C, --company-id <id>", "Company ID")
|
|
1174
|
-
.option("--target-type <type>", "Filter by target type")
|
|
1175
|
-
.option("--vote <vote>", "Filter by vote value")
|
|
1176
|
-
.option("--status <status>", "Filter by trace status")
|
|
1177
|
-
.option("--project-id <id>", "Filter by project ID")
|
|
1178
|
-
.option("--issue-id <id>", "Filter by issue ID")
|
|
1179
|
-
.option("--from <iso8601>", "Only include traces created at or after this timestamp")
|
|
1180
|
-
.option("--to <iso8601>", "Only include traces created at or before this timestamp")
|
|
1181
|
-
.option("--shared-only", "Only include traces eligible for sharing/export")
|
|
1182
|
-
.option("--include-payload", "Include stored payload snapshots in the export")
|
|
1183
|
-
.option("--out <path>", "Write export to a file path instead of stdout")
|
|
1184
|
-
.option("--format <format>", "Export format: json or ndjson", "ndjson")
|
|
1185
|
-
.action(async (opts: CompanyFeedbackOptions) => {
|
|
1186
|
-
try {
|
|
1187
|
-
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
|
1188
|
-
const traces = (await ctx.api.get<FeedbackTrace[]>(
|
|
1189
|
-
`/api/companies/${ctx.companyId}/feedback-traces${buildFeedbackTraceQuery(opts, opts.includePayload ?? true)}`,
|
|
1190
|
-
)) ?? [];
|
|
1191
|
-
const serialized = serializeFeedbackTraces(traces, opts.format);
|
|
1192
|
-
if (opts.out?.trim()) {
|
|
1193
|
-
await writeFile(opts.out, serialized, "utf8");
|
|
1194
|
-
if (ctx.json) {
|
|
1195
|
-
printOutput(
|
|
1196
|
-
{ out: opts.out, count: traces.length, format: normalizeFeedbackTraceExportFormat(opts.format) },
|
|
1197
|
-
{ json: true },
|
|
1198
|
-
);
|
|
1199
|
-
return;
|
|
1200
|
-
}
|
|
1201
|
-
console.log(`Wrote ${traces.length} feedback trace(s) to ${opts.out}`);
|
|
1202
|
-
return;
|
|
1203
|
-
}
|
|
1204
|
-
process.stdout.write(`${serialized}${serialized.endsWith("\n") ? "" : "\n"}`);
|
|
1205
|
-
} catch (err) {
|
|
1206
|
-
handleCommandError(err);
|
|
1207
|
-
}
|
|
1208
|
-
}),
|
|
1209
|
-
{ includeCompany: false },
|
|
1210
|
-
);
|
|
1211
|
-
|
|
1212
|
-
addCommonClientOptions(
|
|
1213
|
-
company
|
|
1214
|
-
.command("export")
|
|
1215
|
-
.description("Export a company into a portable markdown package")
|
|
1216
|
-
.argument("<companyId>", "Company ID")
|
|
1217
|
-
.requiredOption("--out <path>", "Output directory")
|
|
1218
|
-
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
|
|
1219
|
-
.option("--skills <values>", "Comma-separated skill slugs/keys to export")
|
|
1220
|
-
.option("--projects <values>", "Comma-separated project shortnames/ids to export")
|
|
1221
|
-
.option("--issues <values>", "Comma-separated issue identifiers/ids to export")
|
|
1222
|
-
.option("--project-issues <values>", "Comma-separated project shortnames/ids whose issues should be exported")
|
|
1223
|
-
.option("--expand-referenced-skills", "Vendor skill contents instead of exporting upstream references", false)
|
|
1224
|
-
.action(async (companyId: string, opts: CompanyExportOptions) => {
|
|
1225
|
-
try {
|
|
1226
|
-
const ctx = resolveCommandContext(opts);
|
|
1227
|
-
const include = parseInclude(opts.include);
|
|
1228
|
-
const exported = await ctx.api.post<CompanyPortabilityExportResult>(
|
|
1229
|
-
`/api/companies/${companyId}/export`,
|
|
1230
|
-
{
|
|
1231
|
-
include,
|
|
1232
|
-
skills: parseCsvValues(opts.skills),
|
|
1233
|
-
projects: parseCsvValues(opts.projects),
|
|
1234
|
-
issues: parseCsvValues(opts.issues),
|
|
1235
|
-
projectIssues: parseCsvValues(opts.projectIssues),
|
|
1236
|
-
expandReferencedSkills: Boolean(opts.expandReferencedSkills),
|
|
1237
|
-
},
|
|
1238
|
-
);
|
|
1239
|
-
if (!exported) {
|
|
1240
|
-
throw new Error("Export request returned no data");
|
|
1241
|
-
}
|
|
1242
|
-
await confirmOverwriteExportDirectory(opts.out!);
|
|
1243
|
-
await writeExportToFolder(opts.out!, exported);
|
|
1244
|
-
printOutput(
|
|
1245
|
-
{
|
|
1246
|
-
ok: true,
|
|
1247
|
-
out: path.resolve(opts.out!),
|
|
1248
|
-
rootPath: exported.rootPath,
|
|
1249
|
-
filesWritten: Object.keys(exported.files).length,
|
|
1250
|
-
paperclipExtensionPath: exported.paperclipExtensionPath,
|
|
1251
|
-
warningCount: exported.warnings.length,
|
|
1252
|
-
},
|
|
1253
|
-
{ json: ctx.json },
|
|
1254
|
-
);
|
|
1255
|
-
if (!ctx.json && exported.warnings.length > 0) {
|
|
1256
|
-
for (const warning of exported.warnings) {
|
|
1257
|
-
console.log(`warning=${warning}`);
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
} catch (err) {
|
|
1261
|
-
handleCommandError(err);
|
|
1262
|
-
}
|
|
1263
|
-
}),
|
|
1264
|
-
);
|
|
1265
|
-
|
|
1266
|
-
addCommonClientOptions(
|
|
1267
|
-
company
|
|
1268
|
-
.command("import")
|
|
1269
|
-
.description("Import a portable markdown company package from local path, URL, or GitHub")
|
|
1270
|
-
.argument("<fromPathOrUrl>", "Source path or URL")
|
|
1271
|
-
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills")
|
|
1272
|
-
.option("--target <mode>", "Target mode: new | existing")
|
|
1273
|
-
.option("-C, --company-id <id>", "Existing target company ID")
|
|
1274
|
-
.option("--new-company-name <name>", "Name override for --target new")
|
|
1275
|
-
.option("--agents <list>", "Comma-separated agent slugs to import, or all", "all")
|
|
1276
|
-
.option("--collision <mode>", "Collision strategy: rename | skip | replace", "rename")
|
|
1277
|
-
.option("--ref <value>", "Git ref to use for GitHub imports (branch, tag, or commit)")
|
|
1278
|
-
.option("--paperclip-url <url>", "Alias for --api-base on this command")
|
|
1279
|
-
.option("--yes", "Accept default selection and skip the pre-import confirmation prompt", false)
|
|
1280
|
-
.option("--dry-run", "Run preview only without applying", false)
|
|
1281
|
-
.action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => {
|
|
1282
|
-
try {
|
|
1283
|
-
if (!opts.apiBase?.trim() && opts.paperclipUrl?.trim()) {
|
|
1284
|
-
opts.apiBase = opts.paperclipUrl.trim();
|
|
1285
|
-
}
|
|
1286
|
-
const ctx = resolveCommandContext(opts);
|
|
1287
|
-
const interactiveView = isInteractiveTerminal() && !ctx.json;
|
|
1288
|
-
const from = fromPathOrUrl.trim();
|
|
1289
|
-
if (!from) {
|
|
1290
|
-
throw new Error("Source path or URL is required.");
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
const include = resolveImportInclude(opts.include);
|
|
1294
|
-
const agents = parseAgents(opts.agents);
|
|
1295
|
-
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
|
|
1296
|
-
if (!["rename", "skip", "replace"].includes(collision)) {
|
|
1297
|
-
throw new Error("Invalid --collision value. Use: rename, skip, replace");
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
const inferredTarget = opts.target ?? (opts.companyId || ctx.companyId ? "existing" : "new");
|
|
1301
|
-
const target = inferredTarget.toLowerCase() as CompanyImportTargetMode;
|
|
1302
|
-
if (!["new", "existing"].includes(target)) {
|
|
1303
|
-
throw new Error("Invalid --target value. Use: new | existing");
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
const existingTargetCompanyId = opts.companyId?.trim() || ctx.companyId;
|
|
1307
|
-
const targetPayload =
|
|
1308
|
-
target === "existing"
|
|
1309
|
-
? {
|
|
1310
|
-
mode: "existing_company" as const,
|
|
1311
|
-
companyId: existingTargetCompanyId,
|
|
1312
|
-
}
|
|
1313
|
-
: {
|
|
1314
|
-
mode: "new_company" as const,
|
|
1315
|
-
newCompanyName: opts.newCompanyName?.trim() || null,
|
|
1316
|
-
};
|
|
1317
|
-
|
|
1318
|
-
if (targetPayload.mode === "existing_company" && !targetPayload.companyId) {
|
|
1319
|
-
throw new Error("Target existing company requires --company-id (or context default companyId).");
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
let sourcePayload:
|
|
1323
|
-
| { type: "inline"; rootPath?: string | null; files: Record<string, CompanyPortabilityFileEntry> }
|
|
1324
|
-
| { type: "github"; url: string };
|
|
1325
|
-
|
|
1326
|
-
const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from);
|
|
1327
|
-
const isGithubSource = looksLikeRepoUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath);
|
|
1328
|
-
|
|
1329
|
-
if (isHttpUrl(from) || isGithubSource) {
|
|
1330
|
-
if (!looksLikeRepoUrl(from) && !isGithubShorthand(from)) {
|
|
1331
|
-
throw new Error(
|
|
1332
|
-
"Only GitHub URLs and local paths are supported for import. " +
|
|
1333
|
-
"Generic HTTP URLs are not supported. Use a GitHub or GitHub Enterprise URL (https://github.com/... or https://ghe.example.com/...) or a local directory path.",
|
|
1334
|
-
);
|
|
1335
|
-
}
|
|
1336
|
-
sourcePayload = { type: "github", url: normalizeGithubImportSource(from, opts.ref) };
|
|
1337
|
-
} else {
|
|
1338
|
-
if (opts.ref?.trim()) {
|
|
1339
|
-
throw new Error("--ref is only supported for GitHub import sources.");
|
|
1340
|
-
}
|
|
1341
|
-
const inline = await resolveInlineSourceFromPath(from);
|
|
1342
|
-
sourcePayload = {
|
|
1343
|
-
type: "inline",
|
|
1344
|
-
rootPath: inline.rootPath,
|
|
1345
|
-
files: inline.files,
|
|
1346
|
-
};
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
const sourceLabel = formatSourceLabel(sourcePayload);
|
|
1350
|
-
const targetLabel = formatTargetLabel(targetPayload);
|
|
1351
|
-
const previewApiPath = resolveCompanyImportApiPath({
|
|
1352
|
-
dryRun: true,
|
|
1353
|
-
targetMode: targetPayload.mode,
|
|
1354
|
-
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
|
|
1355
|
-
});
|
|
1356
|
-
|
|
1357
|
-
let selectedFiles: string[] | undefined;
|
|
1358
|
-
if (interactiveView && !opts.yes && !opts.include?.trim()) {
|
|
1359
|
-
const initialPreview = await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, {
|
|
1360
|
-
source: sourcePayload,
|
|
1361
|
-
include,
|
|
1362
|
-
target: targetPayload,
|
|
1363
|
-
agents,
|
|
1364
|
-
collisionStrategy: collision,
|
|
1365
|
-
});
|
|
1366
|
-
if (!initialPreview) {
|
|
1367
|
-
throw new Error("Import preview returned no data.");
|
|
1368
|
-
}
|
|
1369
|
-
selectedFiles = await promptForImportSelection(initialPreview);
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
const previewPayload = {
|
|
1373
|
-
source: sourcePayload,
|
|
1374
|
-
include,
|
|
1375
|
-
target: targetPayload,
|
|
1376
|
-
agents,
|
|
1377
|
-
collisionStrategy: collision,
|
|
1378
|
-
selectedFiles,
|
|
1379
|
-
};
|
|
1380
|
-
const preview = await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, previewPayload);
|
|
1381
|
-
if (!preview) {
|
|
1382
|
-
throw new Error("Import preview returned no data.");
|
|
1383
|
-
}
|
|
1384
|
-
const adapterOverrides = buildDefaultImportAdapterOverrides(preview);
|
|
1385
|
-
const adapterMessages = buildDefaultImportAdapterMessages(adapterOverrides);
|
|
1386
|
-
|
|
1387
|
-
if (opts.dryRun) {
|
|
1388
|
-
if (ctx.json) {
|
|
1389
|
-
printOutput(preview, { json: true });
|
|
1390
|
-
} else {
|
|
1391
|
-
printCompanyImportView(
|
|
1392
|
-
"Import Preview",
|
|
1393
|
-
renderCompanyImportPreview(preview, {
|
|
1394
|
-
sourceLabel,
|
|
1395
|
-
targetLabel: formatTargetLabel(targetPayload, preview),
|
|
1396
|
-
infoMessages: adapterMessages,
|
|
1397
|
-
}),
|
|
1398
|
-
{ interactive: interactiveView },
|
|
1399
|
-
);
|
|
1400
|
-
}
|
|
1401
|
-
return;
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
if (!ctx.json) {
|
|
1405
|
-
printCompanyImportView(
|
|
1406
|
-
"Import Preview",
|
|
1407
|
-
renderCompanyImportPreview(preview, {
|
|
1408
|
-
sourceLabel,
|
|
1409
|
-
targetLabel: formatTargetLabel(targetPayload, preview),
|
|
1410
|
-
infoMessages: adapterMessages,
|
|
1411
|
-
}),
|
|
1412
|
-
{ interactive: interactiveView },
|
|
1413
|
-
);
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
const confirmationMode = resolveCompanyImportApplyConfirmationMode({
|
|
1417
|
-
yes: opts.yes,
|
|
1418
|
-
interactive: interactiveView,
|
|
1419
|
-
json: ctx.json,
|
|
1420
|
-
});
|
|
1421
|
-
if (confirmationMode === "prompt") {
|
|
1422
|
-
const confirmed = await p.confirm({
|
|
1423
|
-
message: "Apply this import? (y/N)",
|
|
1424
|
-
initialValue: false,
|
|
1425
|
-
});
|
|
1426
|
-
if (p.isCancel(confirmed) || !confirmed) {
|
|
1427
|
-
p.log.warn("Import cancelled.");
|
|
1428
|
-
return;
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
const importApiPath = resolveCompanyImportApiPath({
|
|
1433
|
-
dryRun: false,
|
|
1434
|
-
targetMode: targetPayload.mode,
|
|
1435
|
-
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
|
|
1436
|
-
});
|
|
1437
|
-
const imported = await ctx.api.post<CompanyPortabilityImportResult>(importApiPath, {
|
|
1438
|
-
...previewPayload,
|
|
1439
|
-
adapterOverrides,
|
|
1440
|
-
});
|
|
1441
|
-
if (!imported) {
|
|
1442
|
-
throw new Error("Import request returned no data.");
|
|
1443
|
-
}
|
|
1444
|
-
const tc = getTelemetryClient();
|
|
1445
|
-
if (tc) {
|
|
1446
|
-
const isPrivate = sourcePayload.type !== "github";
|
|
1447
|
-
const sourceRef = sourcePayload.type === "github" ? sourcePayload.url : from;
|
|
1448
|
-
trackCompanyImported(tc, { sourceType: sourcePayload.type, sourceRef, isPrivate });
|
|
1449
|
-
}
|
|
1450
|
-
let companyUrl: string | undefined;
|
|
1451
|
-
if (!ctx.json) {
|
|
1452
|
-
try {
|
|
1453
|
-
const importedCompany = await ctx.api.get<Company>(`/api/companies/${imported.company.id}`);
|
|
1454
|
-
const issuePrefix = importedCompany?.issuePrefix?.trim();
|
|
1455
|
-
if (issuePrefix) {
|
|
1456
|
-
companyUrl = buildCompanyDashboardUrl(ctx.api.apiBase, issuePrefix);
|
|
1457
|
-
}
|
|
1458
|
-
} catch {
|
|
1459
|
-
companyUrl = undefined;
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
if (ctx.json) {
|
|
1463
|
-
printOutput(imported, { json: true });
|
|
1464
|
-
} else {
|
|
1465
|
-
printCompanyImportView(
|
|
1466
|
-
"Import Result",
|
|
1467
|
-
renderCompanyImportResult(imported, {
|
|
1468
|
-
targetLabel,
|
|
1469
|
-
companyUrl,
|
|
1470
|
-
infoMessages: adapterMessages,
|
|
1471
|
-
}),
|
|
1472
|
-
{ interactive: interactiveView },
|
|
1473
|
-
);
|
|
1474
|
-
if (interactiveView && companyUrl) {
|
|
1475
|
-
const openImportedCompany = await p.confirm({
|
|
1476
|
-
message: "Open the imported company in your browser?",
|
|
1477
|
-
initialValue: true,
|
|
1478
|
-
});
|
|
1479
|
-
if (!p.isCancel(openImportedCompany) && openImportedCompany) {
|
|
1480
|
-
if (openUrl(companyUrl)) {
|
|
1481
|
-
p.log.info(`Opened ${companyUrl}`);
|
|
1482
|
-
} else {
|
|
1483
|
-
p.log.warn(`Could not open your browser automatically. Open this URL manually:\n${companyUrl}`);
|
|
1484
|
-
}
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
}
|
|
1488
|
-
} catch (err) {
|
|
1489
|
-
handleCommandError(err);
|
|
1490
|
-
}
|
|
1491
|
-
}),
|
|
1492
|
-
);
|
|
1493
|
-
|
|
1494
|
-
addCommonClientOptions(
|
|
1495
|
-
company
|
|
1496
|
-
.command("delete")
|
|
1497
|
-
.description("Delete a company by ID or shortname/prefix (destructive)")
|
|
1498
|
-
.argument("<selector>", "Company ID or issue prefix (for example PAP)")
|
|
1499
|
-
.option(
|
|
1500
|
-
"--by <mode>",
|
|
1501
|
-
"Selector mode: auto | id | prefix",
|
|
1502
|
-
"auto",
|
|
1503
|
-
)
|
|
1504
|
-
.option("--yes", "Required safety flag to confirm destructive action", false)
|
|
1505
|
-
.option(
|
|
1506
|
-
"--confirm <value>",
|
|
1507
|
-
"Required safety value: target company ID or shortname/prefix",
|
|
1508
|
-
)
|
|
1509
|
-
.action(async (selector: string, opts: CompanyDeleteOptions) => {
|
|
1510
|
-
try {
|
|
1511
|
-
const by = (opts.by ?? "auto").trim().toLowerCase() as CompanyDeleteSelectorMode;
|
|
1512
|
-
if (!["auto", "id", "prefix"].includes(by)) {
|
|
1513
|
-
throw new Error(`Invalid --by mode '${opts.by}'. Expected one of: auto, id, prefix.`);
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
const ctx = resolveCommandContext(opts);
|
|
1517
|
-
const normalizedSelector = normalizeSelector(selector);
|
|
1518
|
-
assertDeleteFlags(opts);
|
|
1519
|
-
|
|
1520
|
-
let target: Company | null = null;
|
|
1521
|
-
const shouldTryIdLookup = by === "id" || (by === "auto" && isUuidLike(normalizedSelector));
|
|
1522
|
-
if (shouldTryIdLookup) {
|
|
1523
|
-
const byId = await ctx.api.get<Company>(`/api/companies/${normalizedSelector}`, { ignoreNotFound: true });
|
|
1524
|
-
if (byId) {
|
|
1525
|
-
target = byId;
|
|
1526
|
-
} else if (by === "id") {
|
|
1527
|
-
throw new Error(`No company found by ID '${normalizedSelector}'.`);
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
if (!target && ctx.companyId) {
|
|
1532
|
-
const scoped = await ctx.api.get<Company>(`/api/companies/${ctx.companyId}`, { ignoreNotFound: true });
|
|
1533
|
-
if (scoped) {
|
|
1534
|
-
try {
|
|
1535
|
-
target = resolveCompanyForDeletion([scoped], normalizedSelector, by);
|
|
1536
|
-
} catch {
|
|
1537
|
-
// Fallback to board-wide lookup below.
|
|
1538
|
-
}
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
if (!target) {
|
|
1543
|
-
try {
|
|
1544
|
-
const companies = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
|
|
1545
|
-
target = resolveCompanyForDeletion(companies, normalizedSelector, by);
|
|
1546
|
-
} catch (error) {
|
|
1547
|
-
if (error instanceof ApiRequestError && error.status === 403 && error.message.includes("Board access required")) {
|
|
1548
|
-
throw new Error(
|
|
1549
|
-
"Board access is required to resolve companies across the instance. Use a company ID/prefix for your current company, or run with board authentication.",
|
|
1550
|
-
);
|
|
1551
|
-
}
|
|
1552
|
-
throw error;
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
if (!target) {
|
|
1557
|
-
throw new Error(`No company found for selector '${normalizedSelector}'.`);
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
assertDeleteConfirmation(target, opts);
|
|
1561
|
-
|
|
1562
|
-
await ctx.api.delete<{ ok: true }>(`/api/companies/${target.id}`);
|
|
1563
|
-
|
|
1564
|
-
printOutput(
|
|
1565
|
-
{
|
|
1566
|
-
ok: true,
|
|
1567
|
-
deletedCompanyId: target.id,
|
|
1568
|
-
deletedCompanyName: target.name,
|
|
1569
|
-
deletedCompanyPrefix: target.issuePrefix,
|
|
1570
|
-
},
|
|
1571
|
-
{ json: ctx.json },
|
|
1572
|
-
);
|
|
1573
|
-
} catch (err) {
|
|
1574
|
-
handleCommandError(err);
|
|
1575
|
-
}
|
|
1576
|
-
}),
|
|
1577
|
-
);
|
|
1578
|
-
}
|