Fix company import file selection

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-16 10:14:09 -05:00
parent 5d6dadda83
commit cf8bfe8d8e
5 changed files with 172 additions and 12 deletions

View File

@@ -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,
},
]);
});
});

View File

@@ -612,6 +612,81 @@ function buildMarkdown(frontmatter: Record<string, unknown>, 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<string>,
) {
const parsed = parseFrontmatterMarkdown(markdown);
const includeEntries = readIncludeEntries(parsed.frontmatter);
const filteredIncludes = includeEntries.filter((entry) =>
selectedFiles.has(resolvePortablePath(companyPath, entry.path)),
);
const nextFrontmatter: Record<string, unknown> = { ...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<string, string> = {};
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<ImportPlanInternal> {
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[] = [];