diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 587d469f..af092ef4 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -1,3 +1,4 @@ +import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; const companySvc = { @@ -38,6 +39,11 @@ const companySkillSvc = { importPackageFiles: vi.fn(), }; +const assetSvc = { + getById: vi.fn(), + create: vi.fn(), +}; + const agentInstructionsSvc = { exportFiles: vi.fn(), materializeManagedBundle: vi.fn(), @@ -67,6 +73,10 @@ vi.mock("../services/company-skills.js", () => ({ companySkillService: () => companySkillSvc, })); +vi.mock("../services/assets.js", () => ({ + assetService: () => assetSvc, +})); + vi.mock("../services/agent-instructions.js", () => ({ agentInstructionsService: () => agentInstructionsSvc, })); @@ -85,6 +95,8 @@ describe("company portability", () => { description: null, issuePrefix: "PAP", brandColor: "#5c5fff", + logoAssetId: null, + logoUrl: null, requireBoardApprovalForNewAgents: true, }); agentSvc.list.mockResolvedValue([ @@ -243,6 +255,12 @@ describe("company portability", () => { }; }); companySkillSvc.importPackageFiles.mockResolvedValue([]); + assetSvc.getById.mockReset(); + assetSvc.getById.mockResolvedValue(null); + assetSvc.create.mockReset(); + assetSvc.create.mockResolvedValue({ + id: "asset-created", + }); accessSvc.listActiveUserMemberships.mockResolvedValue([ { id: "membership-1", @@ -332,6 +350,50 @@ describe("company portability", () => { expect(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"]).toContain("# API"); }); + it("exports the company logo into images/ and references it from .paperclip.yaml", async () => { + const storage = { + getObject: vi.fn().mockResolvedValue({ + stream: Readable.from([Buffer.from("png-bytes")]), + }), + }; + companySvc.getById.mockResolvedValue({ + id: "company-1", + name: "Paperclip", + description: null, + issuePrefix: "PAP", + brandColor: "#5c5fff", + logoAssetId: "logo-1", + logoUrl: "/api/assets/logo-1/content", + requireBoardApprovalForNewAgents: true, + }); + assetSvc.getById.mockResolvedValue({ + id: "logo-1", + companyId: "company-1", + objectKey: "assets/companies/logo-1", + contentType: "image/png", + originalFilename: "logo.png", + }); + + const portability = companyPortabilityService({} as any, storage as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: false, + projects: false, + issues: false, + }, + }); + + expect(storage.getObject).toHaveBeenCalledWith("company-1", "assets/companies/logo-1"); + expect(exported.files["images/company-logo.png"]).toEqual({ + encoding: "base64", + data: Buffer.from("png-bytes").toString("base64"), + contentType: "image/png", + }); + expect(exported.files[".paperclip.yaml"]).toContain('logoPath: "images/company-logo.png"'); + }); + it("exports duplicate skill slugs into readable namespaced paths", async () => { const portability = companyPortabilityService({} as any); @@ -574,6 +636,91 @@ describe("company portability", () => { })); }); + it("imports a packaged company logo and attaches it to the target company", async () => { + const storage = { + putFile: vi.fn().mockResolvedValue({ + provider: "local_disk", + objectKey: "assets/companies/imported-logo", + contentType: "image/png", + byteSize: 9, + sha256: "logo-sha", + originalFilename: "company-logo.png", + }), + }; + companySvc.create.mockResolvedValue({ + id: "company-imported", + name: "Imported Paperclip", + logoAssetId: null, + }); + companySvc.update.mockResolvedValue({ + id: "company-imported", + name: "Imported Paperclip", + logoAssetId: "asset-created", + }); + agentSvc.create.mockResolvedValue({ + id: "agent-created", + name: "ClaudeCoder", + }); + + const portability = companyPortabilityService({} as any, storage as any); + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + }); + + exported.files["images/company-logo.png"] = { + encoding: "base64", + data: Buffer.from("png-bytes").toString("base64"), + contentType: "image/png", + }; + exported.files[".paperclip.yaml"] = `${exported.files[".paperclip.yaml"]}`.replace( + 'brandColor: "#5c5fff"\n', + 'brandColor: "#5c5fff"\n logoPath: "images/company-logo.png"\n', + ); + + agentSvc.list.mockResolvedValue([]); + + await portability.importBundle({ + source: { + type: "inline", + rootPath: exported.rootPath, + files: exported.files, + }, + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + target: { + mode: "new_company", + newCompanyName: "Imported Paperclip", + }, + agents: "all", + collisionStrategy: "rename", + }, "user-1"); + + expect(storage.putFile).toHaveBeenCalledWith(expect.objectContaining({ + companyId: "company-imported", + namespace: "assets/companies", + originalFilename: "company-logo.png", + contentType: "image/png", + body: Buffer.from("png-bytes"), + })); + expect(assetSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ + objectKey: "assets/companies/imported-logo", + contentType: "image/png", + createdByUserId: "user-1", + })); + expect(companySvc.update).toHaveBeenCalledWith("company-imported", { + logoAssetId: "asset-created", + }); + }); + it("copies source company memberships for safe new-company imports", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/app.ts b/server/src/app.ts index ec08edb7..87e4316d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -136,7 +136,7 @@ export async function createApp( companyDeletionEnabled: opts.companyDeletionEnabled, }), ); - api.use("/companies", companyRoutes(db)); + api.use("/companies", companyRoutes(db, opts.storageService)); api.use(companySkillRoutes(db)); api.use(agentRoutes(db)); api.use(assetRoutes(db, opts.storageService)); diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 8cf6c307..a86655bc 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -18,13 +18,14 @@ import { companyService, logActivity, } from "../services/index.js"; +import type { StorageService } from "../storage/types.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; -export function companyRoutes(db: Db) { +export function companyRoutes(db: Db, storage?: StorageService) { const router = Router(); const svc = companyService(db); const agents = agentService(db); - const portability = companyPortabilityService(db); + const portability = companyPortabilityService(db, storage); const access = accessService(db); const budgets = budgetService(db); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 15ea14b3..828f68f2 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -628,6 +628,16 @@ function normalizeFileMap( return out; } +function pickTextFiles(files: Record) { + const out: Record = {}; + for (const [filePath, content] of Object.entries(files)) { + if (typeof content === "string") { + out[filePath] = content; + } + } + return out; +} + function collectSelectedExportSlugs(selectedFiles: Set) { const agents = new Set(); const projects = new Set(); @@ -707,7 +717,12 @@ function filterPortableExtensionYaml(yaml: string, selectedFiles: Set) { } flushSection(); - return out.join("\n"); + let filtered = out.join("\n"); + const logoPathMatch = filtered.match(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*$/m); + if (logoPathMatch && !selectedFiles.has(logoPathMatch[1]!)) { + filtered = filtered.replace(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*\n?/m, ""); + } + return filtered; } function filterExportFiles( @@ -956,6 +971,7 @@ const YAML_KEY_PRIORITY = [ "icon", "capabilities", "brandColor", + "logoPath", "adapter", "runtime", "permissions", @@ -1105,7 +1121,7 @@ function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: stri throw unprocessable("Company package is missing COMPANY.md"); } - const effectiveFiles: Record = {}; + const effectiveFiles: Record = {}; for (const [filePath, content] of Object.entries(source.files)) { const normalizedPath = normalizePortablePath(filePath); if (!normalizedSelection.has(normalizedPath)) continue; @@ -1993,10 +2009,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const company = await companies.getById(companyId); if (!company) throw notFound("Company not found"); - const files: Record = {}; + const files: Record = {}; const warnings: string[] = []; const envInputs: CompanyPortabilityManifest["envInputs"] = []; const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package"; + let companyLogoPath: string | null = null; const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : []; const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated"); @@ -2165,6 +2182,26 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { companyBodySections.join("\n\n").trim(), ); + if (include.company && company.logoAssetId) { + if (!storage) { + warnings.push("Skipped company logo from export because storage is unavailable."); + } else { + const logoAsset = await assetRecords.getById(company.logoAssetId); + if (!logoAsset) { + warnings.push(`Skipped company logo ${company.logoAssetId} because the asset record was not found.`); + } else { + try { + const object = await storage.getObject(company.id, logoAsset.objectKey); + const body = await streamToBuffer(object.stream); + companyLogoPath = `images/${COMPANY_LOGO_FILE_NAME}${resolveCompanyLogoExtension(logoAsset.contentType, logoAsset.originalFilename)}`; + files[companyLogoPath] = bufferToPortableBinaryFile(body, logoAsset.contentType); + } catch (err) { + warnings.push(`Failed to export company logo ${company.logoAssetId}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + } + const paperclipAgentsOut: Record> = {}; const paperclipProjectsOut: Record> = {}; const paperclipTasksOut: Record> = {}; @@ -2359,6 +2396,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { schema: "paperclip/v1", company: stripEmptyValues({ brandColor: company.brandColor ?? null, + logoPath: companyLogoPath, requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false, }), agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined, @@ -2506,7 +2544,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { for (const agent of selectedAgents) { const filePath = ensureMarkdownPath(agent.path); - const markdown = source.files[filePath]; + const markdown = readPortableTextFile(source.files, filePath); if (typeof markdown !== "string") { errors.push(`Missing markdown file for agent ${agent.slug}: ${filePath}`); continue; @@ -2525,7 +2563,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (include.projects) { for (const project of manifest.projects) { - const markdown = source.files[ensureMarkdownPath(project.path)]; + const markdown = readPortableTextFile(source.files, ensureMarkdownPath(project.path)); if (typeof markdown !== "string") { errors.push(`Missing markdown file for project ${project.slug}: ${project.path}`); continue; @@ -2539,7 +2577,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (include.issues) { for (const issue of manifest.issues) { - const markdown = source.files[ensureMarkdownPath(issue.path)]; + const markdown = readPortableTextFile(source.files, ensureMarkdownPath(issue.path)); if (typeof markdown !== "string") { errors.push(`Missing markdown file for task ${issue.slug}: ${issue.path}`); continue; @@ -2861,6 +2899,55 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (!targetCompany) throw notFound("Target company not found"); + if (include.company) { + const logoPath = sourceManifest.company?.logoPath ?? null; + if (!logoPath) { + const cleared = await companies.update(targetCompany.id, { logoAssetId: null }); + targetCompany = cleared ?? targetCompany; + } else { + const logoFile = plan.source.files[logoPath]; + if (!logoFile) { + warnings.push(`Skipped company logo import because ${logoPath} is missing from the package.`); + } else if (!storage) { + warnings.push("Skipped company logo import because storage is unavailable."); + } else { + const contentType = isPortableBinaryFile(logoFile) + ? (logoFile.contentType ?? inferContentTypeFromPath(logoPath)) + : inferContentTypeFromPath(logoPath); + if (!contentType || !COMPANY_LOGO_CONTENT_TYPE_EXTENSIONS[contentType]) { + warnings.push(`Skipped company logo import for ${logoPath} because the file type is unsupported.`); + } else { + try { + const body = portableFileToBuffer(logoFile, logoPath); + const stored = await storage.putFile({ + companyId: targetCompany.id, + namespace: "assets/companies", + originalFilename: path.posix.basename(logoPath), + contentType, + body, + }); + const createdAsset = await assetRecords.create(targetCompany.id, { + provider: stored.provider, + objectKey: stored.objectKey, + contentType: stored.contentType, + byteSize: stored.byteSize, + sha256: stored.sha256, + originalFilename: stored.originalFilename, + createdByAgentId: null, + createdByUserId: actorUserId ?? null, + }); + const updated = await companies.update(targetCompany.id, { + logoAssetId: createdAsset.id, + }); + targetCompany = updated ?? targetCompany; + } catch (err) { + warnings.push(`Failed to import company logo ${logoPath}: ${err instanceof Error ? err.message : String(err)}`); + } + } + } + } + } + const resultAgents: CompanyPortabilityImportResult["agents"] = []; const importedSlugToAgentId = new Map(); const existingSlugToAgentId = new Map(); @@ -2875,7 +2962,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { existingProjectSlugToId.set(existing.urlKey, existing.id); } - const importedSkills = await companySkills.importPackageFiles(targetCompany.id, plan.source.files, { + const importedSkills = await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), { onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy), }); const desiredSkillRefMap = new Map(); @@ -2908,9 +2995,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const bundleFiles = Object.fromEntries( Object.entries(plan.source.files) .filter(([filePath]) => filePath.startsWith(bundlePrefix)) - .map(([filePath, content]) => [normalizePortablePath(filePath.slice(bundlePrefix.length)), content]), + .flatMap(([filePath, content]) => typeof content === "string" + ? [[normalizePortablePath(filePath.slice(bundlePrefix.length)), content] as const] + : []), ); - const markdownRaw = bundleFiles["AGENTS.md"] ?? plan.source.files[manifestAgent.path]; + const markdownRaw = bundleFiles["AGENTS.md"] ?? readPortableTextFile(plan.source.files, manifestAgent.path); const fallbackPromptTemplate = asString((manifestAgent.adapterConfig as Record).promptTemplate) || ""; if (!markdownRaw && fallbackPromptTemplate) { bundleFiles["AGENTS.md"] = fallbackPromptTemplate; @@ -3065,7 +3154,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { if (include.issues) { for (const manifestIssue of sourceManifest.issues) { - const markdownRaw = plan.source.files[manifestIssue.path]; + const markdownRaw = readPortableTextFile(plan.source.files, manifestIssue.path); const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null; const description = parsed?.body || manifestIssue.description || null; const assigneeAgentId = manifestIssue.assigneeAgentSlug diff --git a/ui/src/lib/portable-files.ts b/ui/src/lib/portable-files.ts new file mode 100644 index 00000000..88671dfa --- /dev/null +++ b/ui/src/lib/portable-files.ts @@ -0,0 +1,41 @@ +import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; + +const contentTypeByExtension: Record = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +}; + +export function getPortableFileText(entry: CompanyPortabilityFileEntry | null | undefined) { + return typeof entry === "string" ? entry : null; +} + +export function getPortableFileContentType( + filePath: string, + entry: CompanyPortabilityFileEntry | null | undefined, +) { + if (entry && typeof entry === "object" && entry.contentType) return entry.contentType; + const extensionIndex = filePath.toLowerCase().lastIndexOf("."); + if (extensionIndex === -1) return null; + return contentTypeByExtension[filePath.toLowerCase().slice(extensionIndex)] ?? null; +} + +export function getPortableFileDataUrl( + filePath: string, + entry: CompanyPortabilityFileEntry | null | undefined, +) { + if (!entry || typeof entry === "string") return null; + const contentType = getPortableFileContentType(filePath, entry) ?? "application/octet-stream"; + return `data:${contentType};base64,${entry.data}`; +} + +export function isPortableImageFile( + filePath: string, + entry: CompanyPortabilityFileEntry | null | undefined, +) { + const contentType = getPortableFileContentType(filePath, entry); + return typeof contentType === "string" && contentType.startsWith("image/"); +} diff --git a/ui/src/lib/zip.test.ts b/ui/src/lib/zip.test.ts index 8537f014..747c8b8c 100644 --- a/ui/src/lib/zip.test.ts +++ b/ui/src/lib/zip.test.ts @@ -70,4 +70,28 @@ describe("createZipArchive", () => { }, }); }); + + it("round-trips binary image files without coercing them to text", () => { + const archive = createZipArchive( + { + "images/company-logo.png": { + encoding: "base64", + data: Buffer.from("png-bytes").toString("base64"), + contentType: "image/png", + }, + }, + "paperclip-demo", + ); + + expect(readZipArchive(archive)).toEqual({ + rootPath: "paperclip-demo", + files: { + "images/company-logo.png": { + encoding: "base64", + data: Buffer.from("png-bytes").toString("base64"), + contentType: "image/png", + }, + }, + }); + }); });