Add nested import picker
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
a339b488ae
commit
1246ccf250
2 changed files with 467 additions and 33 deletions
|
|
@ -1,6 +1,9 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
|
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
|
buildDefaultImportSelectionState,
|
||||||
|
buildImportSelectionCatalog,
|
||||||
|
buildSelectedFilesFromImportSelection,
|
||||||
renderCompanyImportPreview,
|
renderCompanyImportPreview,
|
||||||
renderCompanyImportResult,
|
renderCompanyImportResult,
|
||||||
resolveCompanyImportApiPath,
|
resolveCompanyImportApiPath,
|
||||||
|
|
@ -254,3 +257,167 @@ describe("renderCompanyImportResult", () => {
|
||||||
expect(rendered).toContain("Review API keys");
|
expect(rendered).toContain("Review API keys");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("import selection catalog", () => {
|
||||||
|
it("defaults to everything and keeps project selection separate from task selection", () => {
|
||||||
|
const preview: CompanyPortabilityPreviewResult = {
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
skills: true,
|
||||||
|
},
|
||||||
|
targetCompanyId: "company-123",
|
||||||
|
targetCompanyName: "Imported Co",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
selectedAgentSlugs: ["ceo"],
|
||||||
|
plan: {
|
||||||
|
companyAction: "create",
|
||||||
|
agentPlans: [],
|
||||||
|
projectPlans: [],
|
||||||
|
issuePlans: [],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: "2026-03-23T18:00:00.000Z",
|
||||||
|
source: {
|
||||||
|
companyId: "company-src",
|
||||||
|
companyName: "Source Co",
|
||||||
|
},
|
||||||
|
includes: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
projects: true,
|
||||||
|
issues: true,
|
||||||
|
skills: true,
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
path: "COMPANY.md",
|
||||||
|
name: "Source Co",
|
||||||
|
description: null,
|
||||||
|
brandColor: null,
|
||||||
|
logoPath: "images/company-logo.png",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
},
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
slug: "ceo",
|
||||||
|
name: "CEO",
|
||||||
|
path: "agents/ceo/AGENT.md",
|
||||||
|
skills: [],
|
||||||
|
role: "ceo",
|
||||||
|
title: null,
|
||||||
|
icon: null,
|
||||||
|
capabilities: null,
|
||||||
|
reportsToSlug: null,
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [
|
||||||
|
{
|
||||||
|
key: "skill-a",
|
||||||
|
slug: "skill-a",
|
||||||
|
name: "Skill A",
|
||||||
|
path: "skills/skill-a/SKILL.md",
|
||||||
|
description: null,
|
||||||
|
sourceType: "inline",
|
||||||
|
sourceLocator: null,
|
||||||
|
sourceRef: null,
|
||||||
|
trustLevel: null,
|
||||||
|
compatibility: null,
|
||||||
|
metadata: null,
|
||||||
|
fileInventory: [{ path: "skills/skill-a/helper.md", kind: "doc" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
slug: "alpha",
|
||||||
|
name: "Alpha",
|
||||||
|
path: "projects/alpha/PROJECT.md",
|
||||||
|
description: null,
|
||||||
|
ownerAgentSlug: null,
|
||||||
|
leadAgentSlug: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: null,
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
workspaces: [],
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
slug: "kickoff",
|
||||||
|
identifier: null,
|
||||||
|
title: "Kickoff",
|
||||||
|
path: "projects/alpha/issues/kickoff/TASK.md",
|
||||||
|
projectSlug: "alpha",
|
||||||
|
projectWorkspaceKey: null,
|
||||||
|
assigneeAgentSlug: "ceo",
|
||||||
|
description: null,
|
||||||
|
recurring: false,
|
||||||
|
routine: null,
|
||||||
|
legacyRecurrence: null,
|
||||||
|
status: null,
|
||||||
|
priority: null,
|
||||||
|
labelIds: [],
|
||||||
|
billingCode: null,
|
||||||
|
executionWorkspaceSettings: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
envInputs: [],
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
"COMPANY.md": "# Source Co",
|
||||||
|
"README.md": "# Readme",
|
||||||
|
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||||
|
"images/company-logo.png": {
|
||||||
|
encoding: "base64",
|
||||||
|
data: "",
|
||||||
|
contentType: "image/png",
|
||||||
|
},
|
||||||
|
"projects/alpha/PROJECT.md": "# Alpha",
|
||||||
|
"projects/alpha/notes.md": "project notes",
|
||||||
|
"projects/alpha/issues/kickoff/TASK.md": "# Kickoff",
|
||||||
|
"projects/alpha/issues/kickoff/details.md": "task details",
|
||||||
|
"agents/ceo/AGENT.md": "# CEO",
|
||||||
|
"agents/ceo/prompt.md": "prompt",
|
||||||
|
"skills/skill-a/SKILL.md": "# Skill A",
|
||||||
|
"skills/skill-a/helper.md": "helper",
|
||||||
|
},
|
||||||
|
envInputs: [],
|
||||||
|
warnings: [],
|
||||||
|
errors: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const catalog = buildImportSelectionCatalog(preview);
|
||||||
|
const state = buildDefaultImportSelectionState(catalog);
|
||||||
|
|
||||||
|
expect(state.company).toBe(true);
|
||||||
|
expect(state.projects.has("alpha")).toBe(true);
|
||||||
|
expect(state.issues.has("kickoff")).toBe(true);
|
||||||
|
expect(state.agents.has("ceo")).toBe(true);
|
||||||
|
expect(state.skills.has("skill-a")).toBe(true);
|
||||||
|
|
||||||
|
state.company = false;
|
||||||
|
state.issues.clear();
|
||||||
|
state.agents.clear();
|
||||||
|
state.skills.clear();
|
||||||
|
|
||||||
|
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
|
||||||
|
|
||||||
|
expect(selectedFiles).toContain(".paperclip.yaml");
|
||||||
|
expect(selectedFiles).toContain("projects/alpha/PROJECT.md");
|
||||||
|
expect(selectedFiles).toContain("projects/alpha/notes.md");
|
||||||
|
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md");
|
||||||
|
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ interface CompanyImportOptions extends BaseClientOptions {
|
||||||
agents?: string;
|
agents?: string;
|
||||||
collision?: CompanyCollisionMode;
|
collision?: CompanyCollisionMode;
|
||||||
ref?: string;
|
ref?: string;
|
||||||
|
yes?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +84,28 @@ const IMPORT_INCLUDE_OPTIONS: Array<{
|
||||||
|
|
||||||
const IMPORT_PREVIEW_SAMPLE_LIMIT = 6;
|
const IMPORT_PREVIEW_SAMPLE_LIMIT = 6;
|
||||||
|
|
||||||
|
type ImportSelectableGroup = "projects" | "issues" | "agents" | "skills";
|
||||||
|
|
||||||
|
type ImportSelectionCatalog = {
|
||||||
|
company: {
|
||||||
|
includedByDefault: boolean;
|
||||||
|
files: string[];
|
||||||
|
};
|
||||||
|
projects: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||||
|
issues: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||||
|
agents: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||||
|
skills: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||||
|
extensionPath: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportSelectionState = {
|
||||||
|
company: boolean;
|
||||||
|
projects: Set<string>;
|
||||||
|
issues: Set<string>;
|
||||||
|
agents: Set<string>;
|
||||||
|
skills: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
const binaryContentTypeByExtension: Record<string, string> = {
|
const binaryContentTypeByExtension: Record<string, string> = {
|
||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
".jpeg": "image/jpeg",
|
".jpeg": "image/jpeg",
|
||||||
|
|
@ -152,46 +175,268 @@ function isInteractiveTerminal(): boolean {
|
||||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function includeToValues(include: CompanyPortabilityInclude): Array<keyof CompanyPortabilityInclude> {
|
function resolveImportInclude(input: string | undefined): CompanyPortabilityInclude {
|
||||||
return IMPORT_INCLUDE_OPTIONS
|
return parseInclude(input, DEFAULT_IMPORT_INCLUDE);
|
||||||
.map((option) => option.value)
|
|
||||||
.filter((value) => include[value]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveImportIncludeSelection(
|
function normalizePortablePath(filePath: string): string {
|
||||||
input: string | undefined,
|
return filePath.replace(/\\/g, "/");
|
||||||
opts?: { prompt?: boolean },
|
}
|
||||||
): Promise<CompanyPortabilityInclude> {
|
|
||||||
if (input?.trim()) {
|
function findPortableExtensionPath(files: Record<string, CompanyPortabilityFileEntry>): string | null {
|
||||||
return parseInclude(input, DEFAULT_IMPORT_INCLUDE);
|
if (files[".paperclip.yaml"] !== undefined) return ".paperclip.yaml";
|
||||||
|
if (files[".paperclip.yml"] !== undefined) return ".paperclip.yml";
|
||||||
|
return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFilesUnderDirectory(
|
||||||
|
files: Record<string, CompanyPortabilityFileEntry>,
|
||||||
|
directory: string,
|
||||||
|
opts?: { excludePrefixes?: string[] },
|
||||||
|
): string[] {
|
||||||
|
const normalizedDirectory = normalizePortablePath(directory).replace(/\/+$/, "");
|
||||||
|
if (!normalizedDirectory) return [];
|
||||||
|
const prefix = `${normalizedDirectory}/`;
|
||||||
|
const excluded = (opts?.excludePrefixes ?? []).map((entry) => normalizePortablePath(entry).replace(/\/+$/, "")).filter(Boolean);
|
||||||
|
return Object.keys(files)
|
||||||
|
.map(normalizePortablePath)
|
||||||
|
.filter((filePath) => filePath.startsWith(prefix))
|
||||||
|
.filter((filePath) => !excluded.some((excludePrefix) => filePath.startsWith(`${excludePrefix}/`)))
|
||||||
|
.sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectEntityFiles(
|
||||||
|
files: Record<string, CompanyPortabilityFileEntry>,
|
||||||
|
entryPath: string,
|
||||||
|
opts?: { excludePrefixes?: string[] },
|
||||||
|
): string[] {
|
||||||
|
const normalizedPath = normalizePortablePath(entryPath);
|
||||||
|
const directory = normalizedPath.includes("/") ? normalizedPath.slice(0, normalizedPath.lastIndexOf("/")) : "";
|
||||||
|
const selected = new Set<string>([normalizedPath]);
|
||||||
|
if (directory) {
|
||||||
|
for (const filePath of collectFilesUnderDirectory(files, directory, opts)) {
|
||||||
|
selected.add(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(selected).sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildImportSelectionCatalog(preview: CompanyPortabilityPreviewResult): ImportSelectionCatalog {
|
||||||
|
const selectedAgentSlugs = new Set(preview.selectedAgentSlugs);
|
||||||
|
const companyFiles = new Set<string>();
|
||||||
|
const companyPath = preview.manifest.company?.path ? normalizePortablePath(preview.manifest.company.path) : null;
|
||||||
|
if (companyPath) {
|
||||||
|
companyFiles.add(companyPath);
|
||||||
|
}
|
||||||
|
const readmePath = Object.keys(preview.files).find((entry) => normalizePortablePath(entry) === "README.md");
|
||||||
|
if (readmePath) {
|
||||||
|
companyFiles.add(normalizePortablePath(readmePath));
|
||||||
|
}
|
||||||
|
const logoPath = preview.manifest.company?.logoPath ? normalizePortablePath(preview.manifest.company.logoPath) : null;
|
||||||
|
if (logoPath && preview.files[logoPath] !== undefined) {
|
||||||
|
companyFiles.add(logoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!opts?.prompt || !isInteractiveTerminal()) {
|
|
||||||
return { ...DEFAULT_IMPORT_INCLUDE };
|
|
||||||
}
|
|
||||||
|
|
||||||
const selection = await p.multiselect<keyof CompanyPortabilityInclude>({
|
|
||||||
message: "What should Paperclip import?",
|
|
||||||
options: IMPORT_INCLUDE_OPTIONS,
|
|
||||||
initialValues: includeToValues(DEFAULT_IMPORT_INCLUDE),
|
|
||||||
required: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (p.isCancel(selection)) {
|
|
||||||
p.cancel("Import cancelled.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = new Set(selection);
|
|
||||||
return {
|
return {
|
||||||
company: values.has("company"),
|
company: {
|
||||||
agents: values.has("agents"),
|
includedByDefault: preview.include.company && preview.manifest.company !== null,
|
||||||
projects: values.has("projects"),
|
files: Array.from(companyFiles).sort((left, right) => left.localeCompare(right)),
|
||||||
issues: values.has("issues"),
|
},
|
||||||
skills: values.has("skills"),
|
projects: preview.manifest.projects.map((project) => {
|
||||||
|
const projectPath = normalizePortablePath(project.path);
|
||||||
|
const projectDir = projectPath.includes("/") ? projectPath.slice(0, projectPath.lastIndexOf("/")) : "";
|
||||||
|
return {
|
||||||
|
key: project.slug,
|
||||||
|
label: project.name,
|
||||||
|
hint: project.slug,
|
||||||
|
files: collectEntityFiles(preview.files, projectPath, {
|
||||||
|
excludePrefixes: projectDir ? [`${projectDir}/issues`] : [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
issues: preview.manifest.issues.map((issue) => ({
|
||||||
|
key: issue.slug,
|
||||||
|
label: issue.title,
|
||||||
|
hint: issue.identifier ?? issue.slug,
|
||||||
|
files: collectEntityFiles(preview.files, normalizePortablePath(issue.path)),
|
||||||
|
})),
|
||||||
|
agents: preview.manifest.agents
|
||||||
|
.filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug))
|
||||||
|
.map((agent) => ({
|
||||||
|
key: agent.slug,
|
||||||
|
label: agent.name,
|
||||||
|
hint: agent.slug,
|
||||||
|
files: collectEntityFiles(preview.files, normalizePortablePath(agent.path)),
|
||||||
|
})),
|
||||||
|
skills: preview.manifest.skills.map((skill) => ({
|
||||||
|
key: skill.slug,
|
||||||
|
label: skill.name,
|
||||||
|
hint: skill.slug,
|
||||||
|
files: collectEntityFiles(preview.files, normalizePortablePath(skill.path)),
|
||||||
|
})),
|
||||||
|
extensionPath: findPortableExtensionPath(preview.files),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toKeySet(items: Array<{ key: string }>): Set<string> {
|
||||||
|
return new Set(items.map((item) => item.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultImportSelectionState(catalog: ImportSelectionCatalog): ImportSelectionState {
|
||||||
|
return {
|
||||||
|
company: catalog.company.includedByDefault,
|
||||||
|
projects: toKeySet(catalog.projects),
|
||||||
|
issues: toKeySet(catalog.issues),
|
||||||
|
agents: toKeySet(catalog.agents),
|
||||||
|
skills: toKeySet(catalog.skills),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSelected(state: ImportSelectionState, group: ImportSelectableGroup): number {
|
||||||
|
return state[group].size;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countTotal(catalog: ImportSelectionCatalog, group: ImportSelectableGroup): number {
|
||||||
|
return catalog[group].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeGroupSelection(catalog: ImportSelectionCatalog, state: ImportSelectionState, group: ImportSelectableGroup): string {
|
||||||
|
return `${countSelected(state, group)}/${countTotal(catalog, group)} selected`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupLabel(group: ImportSelectableGroup): string {
|
||||||
|
switch (group) {
|
||||||
|
case "projects":
|
||||||
|
return "Projects";
|
||||||
|
case "issues":
|
||||||
|
return "Tasks";
|
||||||
|
case "agents":
|
||||||
|
return "Agents";
|
||||||
|
case "skills":
|
||||||
|
return "Skills";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSelectedFilesFromImportSelection(
|
||||||
|
catalog: ImportSelectionCatalog,
|
||||||
|
state: ImportSelectionState,
|
||||||
|
): string[] {
|
||||||
|
const selected = new Set<string>();
|
||||||
|
|
||||||
|
if (state.company) {
|
||||||
|
for (const filePath of catalog.company.files) {
|
||||||
|
selected.add(normalizePortablePath(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of ["projects", "issues", "agents", "skills"] as const) {
|
||||||
|
const selectedKeys = state[group];
|
||||||
|
for (const item of catalog[group]) {
|
||||||
|
if (!selectedKeys.has(item.key)) continue;
|
||||||
|
for (const filePath of item.files) {
|
||||||
|
selected.add(normalizePortablePath(filePath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.size > 0 && catalog.extensionPath) {
|
||||||
|
selected.add(normalizePortablePath(catalog.extensionPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(selected).sort((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptForImportSelection(preview: CompanyPortabilityPreviewResult): Promise<string[]> {
|
||||||
|
const catalog = buildImportSelectionCatalog(preview);
|
||||||
|
const state = buildDefaultImportSelectionState(catalog);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const choice = await p.select<ImportSelectableGroup | "company" | "confirm">({
|
||||||
|
message: "Select what Paperclip should import",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: "company",
|
||||||
|
label: state.company ? "Company: included" : "Company: skipped",
|
||||||
|
hint: catalog.company.files.length > 0 ? "toggle company metadata" : "no company metadata in package",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "projects",
|
||||||
|
label: "Select Projects",
|
||||||
|
hint: summarizeGroupSelection(catalog, state, "projects"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "issues",
|
||||||
|
label: "Select Tasks",
|
||||||
|
hint: summarizeGroupSelection(catalog, state, "issues"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "agents",
|
||||||
|
label: "Select Agents",
|
||||||
|
hint: summarizeGroupSelection(catalog, state, "agents"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "skills",
|
||||||
|
label: "Select Skills",
|
||||||
|
hint: summarizeGroupSelection(catalog, state, "skills"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "confirm",
|
||||||
|
label: "Confirm",
|
||||||
|
hint: `${buildSelectedFilesFromImportSelection(catalog, state).length} files selected`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: "confirm",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(choice)) {
|
||||||
|
p.cancel("Import cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === "confirm") {
|
||||||
|
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
p.note("Select at least one import target before confirming.", "Nothing selected");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return selectedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice === "company") {
|
||||||
|
if (catalog.company.files.length === 0) {
|
||||||
|
p.note("This package does not include company metadata to toggle.", "No company metadata");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
state.company = !state.company;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = choice;
|
||||||
|
const groupItems = catalog[group];
|
||||||
|
if (groupItems.length === 0) {
|
||||||
|
p.note(`This package does not include any ${getGroupLabel(group).toLowerCase()}.`, `No ${getGroupLabel(group)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = await p.multiselect<string>({
|
||||||
|
message: `${getGroupLabel(group)} to import. Press enter to go back.`,
|
||||||
|
options: groupItems.map((item) => ({
|
||||||
|
value: item.key,
|
||||||
|
label: item.label,
|
||||||
|
hint: item.hint,
|
||||||
|
})),
|
||||||
|
initialValues: Array.from(state[group]),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (p.isCancel(selection)) {
|
||||||
|
p.cancel("Import cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
state[group] = new Set(selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeInclude(include: CompanyPortabilityInclude): string {
|
function summarizeInclude(include: CompanyPortabilityInclude): string {
|
||||||
const labels = IMPORT_INCLUDE_OPTIONS
|
const labels = IMPORT_INCLUDE_OPTIONS
|
||||||
.filter((option) => include[option.value])
|
.filter((option) => include[option.value])
|
||||||
|
|
@ -814,6 +1059,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
.option("--agents <list>", "Comma-separated agent slugs to import, or all", "all")
|
.option("--agents <list>", "Comma-separated agent slugs to import, or all", "all")
|
||||||
.option("--collision <mode>", "Collision strategy: rename | skip | replace", "rename")
|
.option("--collision <mode>", "Collision strategy: rename | skip | replace", "rename")
|
||||||
.option("--ref <value>", "Git ref to use for GitHub imports (branch, tag, or commit)")
|
.option("--ref <value>", "Git ref to use for GitHub imports (branch, tag, or commit)")
|
||||||
|
.option("--yes", "Accept the default import selection without opening the TUI", false)
|
||||||
.option("--dry-run", "Run preview only without applying", false)
|
.option("--dry-run", "Run preview only without applying", false)
|
||||||
.action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => {
|
.action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -824,7 +1070,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
throw new Error("Source path or URL is required.");
|
throw new Error("Source path or URL is required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const include = await resolveImportIncludeSelection(opts.include, { prompt: interactiveView });
|
const include = resolveImportInclude(opts.include);
|
||||||
const agents = parseAgents(opts.agents);
|
const agents = parseAgents(opts.agents);
|
||||||
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
|
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
|
||||||
if (!["rename", "skip", "replace"].includes(collision)) {
|
if (!["rename", "skip", "replace"].includes(collision)) {
|
||||||
|
|
@ -882,6 +1128,26 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
|
|
||||||
const sourceLabel = formatSourceLabel(sourcePayload);
|
const sourceLabel = formatSourceLabel(sourcePayload);
|
||||||
const targetLabel = formatTargetLabel(targetPayload);
|
const targetLabel = formatTargetLabel(targetPayload);
|
||||||
|
const previewApiPath = resolveCompanyImportApiPath({
|
||||||
|
dryRun: true,
|
||||||
|
targetMode: targetPayload.mode,
|
||||||
|
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectedFiles: string[] | undefined;
|
||||||
|
if (interactiveView && !opts.yes && !opts.include?.trim()) {
|
||||||
|
const initialPreview = await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, {
|
||||||
|
source: sourcePayload,
|
||||||
|
include,
|
||||||
|
target: targetPayload,
|
||||||
|
agents,
|
||||||
|
collisionStrategy: collision,
|
||||||
|
});
|
||||||
|
if (!initialPreview) {
|
||||||
|
throw new Error("Import preview returned no data.");
|
||||||
|
}
|
||||||
|
selectedFiles = await promptForImportSelection(initialPreview);
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
source: sourcePayload,
|
source: sourcePayload,
|
||||||
|
|
@ -889,6 +1155,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
target: targetPayload,
|
target: targetPayload,
|
||||||
agents,
|
agents,
|
||||||
collisionStrategy: collision,
|
collisionStrategy: collision,
|
||||||
|
selectedFiles,
|
||||||
};
|
};
|
||||||
const importApiPath = resolveCompanyImportApiPath({
|
const importApiPath = resolveCompanyImportApiPath({
|
||||||
dryRun: Boolean(opts.dryRun),
|
dryRun: Boolean(opts.dryRun),
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue