Add company logo portability support
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
41
ui/src/lib/portable-files.ts
Normal file
41
ui/src/lib/portable-files.ts
Normal 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/");
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user