diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 6cf8e78a..9734b682 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -146,6 +146,7 @@ export interface CompanyPortabilityPreviewRequest { agents?: CompanyPortabilityAgentSelection; collisionStrategy?: CompanyPortabilityCollisionStrategy; nameOverrides?: Record; + selectedFiles?: string[]; } export interface CompanyPortabilityPreviewAgentPlan { diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index d8bcd39b..9cc853e5 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -164,6 +164,7 @@ export const companyPortabilityPreviewSchema = z.object({ agents: portabilityAgentSelectionSchema.optional(), collisionStrategy: portabilityCollisionStrategySchema.optional(), nameOverrides: z.record(z.string().min(1), z.string().min(1)).optional(), + selectedFiles: z.array(z.string().min(1)).optional(), }); export type CompanyPortabilityPreview = z.infer; diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index aeaf1197..58c3af85 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -394,4 +394,81 @@ describe("company portability", () => { }), })); }); + + it("imports only selected files and leaves unchecked company metadata alone", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + agentSvc.list.mockResolvedValue([]); + projectSvc.list.mockResolvedValue([]); + companySvc.getById.mockResolvedValue({ + id: "company-1", + name: "Paperclip", + description: "Existing company", + brandColor: "#123456", + requireBoardApprovalForNewAgents: false, + }); + agentSvc.create.mockResolvedValue({ + id: "agent-cmo", + name: "CMO", + }); + + const result = await portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: true, + agents: true, + projects: true, + issues: true, + }, + selectedFiles: ["agents/cmo/AGENTS.md"], + target: { + mode: "existing_company", + companyId: "company-1", + }, + agents: "all", + collisionStrategy: "rename", + }, "user-1"); + + expect(companySvc.update).not.toHaveBeenCalled(); + expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + "COMPANY.md": expect.any(String), + "agents/cmo/AGENTS.md": expect.any(String), + }), + ); + expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith( + "company-1", + expect.not.objectContaining({ + "agents/claudecoder/AGENTS.md": expect.any(String), + }), + ); + expect(agentSvc.create).toHaveBeenCalledTimes(1); + expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + name: "CMO", + })); + expect(result.company.action).toBe("unchanged"); + expect(result.agents).toEqual([ + { + slug: "cmo", + id: "agent-cmo", + action: "created", + name: "CMO", + reason: null, + }, + ]); + }); }); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index cdc737b2..e18016ca 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -612,6 +612,81 @@ function buildMarkdown(frontmatter: Record, body: string) { return `${renderFrontmatter(frontmatter)}\n${cleanBody}\n`; } +function normalizeSelectedFiles(selectedFiles?: string[]) { + if (!selectedFiles) return null; + return new Set( + selectedFiles + .map((entry) => normalizePortablePath(entry)) + .filter((entry) => entry.length > 0), + ); +} + +function filterCompanyMarkdownIncludes( + companyPath: string, + markdown: string, + selectedFiles: Set, +) { + const parsed = parseFrontmatterMarkdown(markdown); + const includeEntries = readIncludeEntries(parsed.frontmatter); + const filteredIncludes = includeEntries.filter((entry) => + selectedFiles.has(resolvePortablePath(companyPath, entry.path)), + ); + const nextFrontmatter: Record = { ...parsed.frontmatter }; + if (filteredIncludes.length > 0) { + nextFrontmatter.includes = filteredIncludes.map((entry) => entry.path); + } else { + delete nextFrontmatter.includes; + } + return buildMarkdown(nextFrontmatter, parsed.body); +} + +function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: string[]): ResolvedSource { + const normalizedSelection = normalizeSelectedFiles(selectedFiles); + if (!normalizedSelection) return source; + + const companyPath = source.manifest.company + ? ensureMarkdownPath(source.manifest.company.path) + : Object.keys(source.files).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md") ?? null; + if (!companyPath) { + throw unprocessable("Company package is missing COMPANY.md"); + } + + const companyMarkdown = source.files[companyPath]; + if (typeof companyMarkdown !== "string") { + throw unprocessable("Company package is missing COMPANY.md"); + } + + const effectiveFiles: Record = {}; + for (const [filePath, content] of Object.entries(source.files)) { + const normalizedPath = normalizePortablePath(filePath); + if (!normalizedSelection.has(normalizedPath)) continue; + effectiveFiles[normalizedPath] = content; + } + + effectiveFiles[companyPath] = filterCompanyMarkdownIncludes( + companyPath, + companyMarkdown, + normalizedSelection, + ); + + const filtered = buildManifestFromPackageFiles(effectiveFiles, { + sourceLabel: source.manifest.source, + }); + + if (!normalizedSelection.has(companyPath)) { + filtered.manifest.company = null; + } + + filtered.manifest.includes = { + company: filtered.manifest.company !== null, + agents: filtered.manifest.agents.length > 0, + projects: filtered.manifest.projects.length > 0, + issues: filtered.manifest.issues.length > 0, + }; + + return filtered; +} + async function resolveBundledSkillsCommit() { if (!bundledSkillsCommitPromise) { bundledSkillsCommitPromise = execFileAsync("git", ["rev-parse", "HEAD"], { @@ -1796,9 +1871,15 @@ export function companyPortabilityService(db: Db) { } async function buildPreview(input: CompanyPortabilityPreview): Promise { - const include = normalizeInclude(input.include); - const source = await resolveSource(input.source); + const requestedInclude = normalizeInclude(input.include); + const source = applySelectedFilesToSource(await resolveSource(input.source), input.selectedFiles); const manifest = source.manifest; + const include: CompanyPortabilityInclude = { + company: requestedInclude.company && manifest.company !== null, + agents: requestedInclude.agents && manifest.agents.length > 0, + projects: requestedInclude.projects && manifest.projects.length > 0, + issues: requestedInclude.issues && manifest.issues.length > 0, + }; const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY; const warnings = [...source.warnings]; const errors: string[] = []; diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index ffb4b20e..8282eb62 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -600,6 +600,11 @@ export function CompanyImport() { return Object.keys(overrides).length > 0 ? overrides : undefined; } + function buildSelectedFiles(): string[] | undefined { + const selected = Array.from(checkedFiles).sort(); + return selected.length > 0 ? selected : undefined; + } + // Apply mutation const importMutation = useMutation({ mutationFn: () => { @@ -614,25 +619,20 @@ export function CompanyImport() { : { mode: "existing_company", companyId: selectedCompanyId! }, collisionStrategy: "rename", nameOverrides: buildFinalNameOverrides(), + selectedFiles: buildSelectedFiles(), }); }, onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); - if (result.company.action === "created") { - setSelectedCompanyId(result.company.id); - } + const importedCompany = await companiesApi.get(result.company.id); + setSelectedCompanyId(importedCompany.id); pushToast({ tone: "success", title: "Import complete", body: `${result.company.name}: ${result.agents.length} agent${result.agents.length === 1 ? "" : "s"} processed.`, }); - // Reset - setImportPreview(null); - setLocalPackage(null); - setImportUrl(""); - setNameOverrides({}); - setSkippedSlugs(new Set()); - setConfirmedSlugs(new Set()); + // Force a fresh dashboard load so newly imported agents are immediately visible. + window.location.assign(`/${importedCompany.issuePrefix}/dashboard`); }, onError: (err) => { pushToast({