Add company logo portability support

This commit is contained in:
dotta
2026-03-19 07:24:04 -05:00
parent 6d564e0539
commit 7a652b8998
6 changed files with 315 additions and 13 deletions

View File

@@ -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);

View File

@@ -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));

View File

@@ -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);

View File

@@ -628,6 +628,16 @@ function normalizeFileMap(
return out;
}
function pickTextFiles(files: Record<string, CompanyPortabilityFileEntry>) {
const out: Record<string, string> = {};
for (const [filePath, content] of Object.entries(files)) {
if (typeof content === "string") {
out[filePath] = content;
}
}
return out;
}
function collectSelectedExportSlugs(selectedFiles: Set<string>) {
const agents = new Set<string>();
const projects = new Set<string>();
@@ -707,7 +717,12 @@ function filterPortableExtensionYaml(yaml: string, selectedFiles: Set<string>) {
}
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<string, string> = {};
const effectiveFiles: Record<string, CompanyPortabilityFileEntry> = {};
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<string, string> = {};
const files: Record<string, CompanyPortabilityFileEntry> = {};
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<string, Record<string, unknown>> = {};
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
const paperclipTasksOut: Record<string, Record<string, unknown>> = {};
@@ -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<string, string>();
const existingSlugToAgentId = new Map<string, string>();
@@ -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<string, string>();
@@ -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<string, unknown>).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

View File

@@ -0,0 +1,41 @@
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
const contentTypeByExtension: Record<string, string> = {
".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/");
}

View File

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