Add company logo portability support
This commit is contained in:
parent
6d564e0539
commit
7a652b8998
6 changed files with 315 additions and 13 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Readable } from "node:stream";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const companySvc = {
|
const companySvc = {
|
||||||
|
|
@ -38,6 +39,11 @@ const companySkillSvc = {
|
||||||
importPackageFiles: vi.fn(),
|
importPackageFiles: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const assetSvc = {
|
||||||
|
getById: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const agentInstructionsSvc = {
|
const agentInstructionsSvc = {
|
||||||
exportFiles: vi.fn(),
|
exportFiles: vi.fn(),
|
||||||
materializeManagedBundle: vi.fn(),
|
materializeManagedBundle: vi.fn(),
|
||||||
|
|
@ -67,6 +73,10 @@ vi.mock("../services/company-skills.js", () => ({
|
||||||
companySkillService: () => companySkillSvc,
|
companySkillService: () => companySkillSvc,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/assets.js", () => ({
|
||||||
|
assetService: () => assetSvc,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../services/agent-instructions.js", () => ({
|
vi.mock("../services/agent-instructions.js", () => ({
|
||||||
agentInstructionsService: () => agentInstructionsSvc,
|
agentInstructionsService: () => agentInstructionsSvc,
|
||||||
}));
|
}));
|
||||||
|
|
@ -85,6 +95,8 @@ describe("company portability", () => {
|
||||||
description: null,
|
description: null,
|
||||||
issuePrefix: "PAP",
|
issuePrefix: "PAP",
|
||||||
brandColor: "#5c5fff",
|
brandColor: "#5c5fff",
|
||||||
|
logoAssetId: null,
|
||||||
|
logoUrl: null,
|
||||||
requireBoardApprovalForNewAgents: true,
|
requireBoardApprovalForNewAgents: true,
|
||||||
});
|
});
|
||||||
agentSvc.list.mockResolvedValue([
|
agentSvc.list.mockResolvedValue([
|
||||||
|
|
@ -243,6 +255,12 @@ describe("company portability", () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
companySkillSvc.importPackageFiles.mockResolvedValue([]);
|
companySkillSvc.importPackageFiles.mockResolvedValue([]);
|
||||||
|
assetSvc.getById.mockReset();
|
||||||
|
assetSvc.getById.mockResolvedValue(null);
|
||||||
|
assetSvc.create.mockReset();
|
||||||
|
assetSvc.create.mockResolvedValue({
|
||||||
|
id: "asset-created",
|
||||||
|
});
|
||||||
accessSvc.listActiveUserMemberships.mockResolvedValue([
|
accessSvc.listActiveUserMemberships.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: "membership-1",
|
id: "membership-1",
|
||||||
|
|
@ -332,6 +350,50 @@ describe("company portability", () => {
|
||||||
expect(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"]).toContain("# API");
|
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 () => {
|
it("exports duplicate skill slugs into readable namespaced paths", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
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 () => {
|
it("copies source company memberships for safe new-company imports", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ export async function createApp(
|
||||||
companyDeletionEnabled: opts.companyDeletionEnabled,
|
companyDeletionEnabled: opts.companyDeletionEnabled,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
api.use("/companies", companyRoutes(db));
|
api.use("/companies", companyRoutes(db, opts.storageService));
|
||||||
api.use(companySkillRoutes(db));
|
api.use(companySkillRoutes(db));
|
||||||
api.use(agentRoutes(db));
|
api.use(agentRoutes(db));
|
||||||
api.use(assetRoutes(db, opts.storageService));
|
api.use(assetRoutes(db, opts.storageService));
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,14 @@ import {
|
||||||
companyService,
|
companyService,
|
||||||
logActivity,
|
logActivity,
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
|
import type { StorageService } from "../storage/types.js";
|
||||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
|
||||||
export function companyRoutes(db: Db) {
|
export function companyRoutes(db: Db, storage?: StorageService) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = companyService(db);
|
const svc = companyService(db);
|
||||||
const agents = agentService(db);
|
const agents = agentService(db);
|
||||||
const portability = companyPortabilityService(db);
|
const portability = companyPortabilityService(db, storage);
|
||||||
const access = accessService(db);
|
const access = accessService(db);
|
||||||
const budgets = budgetService(db);
|
const budgets = budgetService(db);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -628,6 +628,16 @@ function normalizeFileMap(
|
||||||
return out;
|
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>) {
|
function collectSelectedExportSlugs(selectedFiles: Set<string>) {
|
||||||
const agents = new Set<string>();
|
const agents = new Set<string>();
|
||||||
const projects = new Set<string>();
|
const projects = new Set<string>();
|
||||||
|
|
@ -707,7 +717,12 @@ function filterPortableExtensionYaml(yaml: string, selectedFiles: Set<string>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
flushSection();
|
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(
|
function filterExportFiles(
|
||||||
|
|
@ -956,6 +971,7 @@ const YAML_KEY_PRIORITY = [
|
||||||
"icon",
|
"icon",
|
||||||
"capabilities",
|
"capabilities",
|
||||||
"brandColor",
|
"brandColor",
|
||||||
|
"logoPath",
|
||||||
"adapter",
|
"adapter",
|
||||||
"runtime",
|
"runtime",
|
||||||
"permissions",
|
"permissions",
|
||||||
|
|
@ -1105,7 +1121,7 @@ function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: stri
|
||||||
throw unprocessable("Company package is missing COMPANY.md");
|
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)) {
|
for (const [filePath, content] of Object.entries(source.files)) {
|
||||||
const normalizedPath = normalizePortablePath(filePath);
|
const normalizedPath = normalizePortablePath(filePath);
|
||||||
if (!normalizedSelection.has(normalizedPath)) continue;
|
if (!normalizedSelection.has(normalizedPath)) continue;
|
||||||
|
|
@ -1993,10 +2009,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
const company = await companies.getById(companyId);
|
const company = await companies.getById(companyId);
|
||||||
if (!company) throw notFound("Company not found");
|
if (!company) throw notFound("Company not found");
|
||||||
|
|
||||||
const files: Record<string, string> = {};
|
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
|
const envInputs: CompanyPortabilityManifest["envInputs"] = [];
|
||||||
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
|
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
|
||||||
|
let companyLogoPath: string | null = null;
|
||||||
|
|
||||||
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
||||||
const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
||||||
|
|
@ -2165,6 +2182,26 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
companyBodySections.join("\n\n").trim(),
|
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 paperclipAgentsOut: Record<string, Record<string, unknown>> = {};
|
||||||
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
|
const paperclipProjectsOut: Record<string, Record<string, unknown>> = {};
|
||||||
const paperclipTasksOut: 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",
|
schema: "paperclip/v1",
|
||||||
company: stripEmptyValues({
|
company: stripEmptyValues({
|
||||||
brandColor: company.brandColor ?? null,
|
brandColor: company.brandColor ?? null,
|
||||||
|
logoPath: companyLogoPath,
|
||||||
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false,
|
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents ? undefined : false,
|
||||||
}),
|
}),
|
||||||
agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined,
|
agents: Object.keys(paperclipAgents).length > 0 ? paperclipAgents : undefined,
|
||||||
|
|
@ -2506,7 +2544,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
|
|
||||||
for (const agent of selectedAgents) {
|
for (const agent of selectedAgents) {
|
||||||
const filePath = ensureMarkdownPath(agent.path);
|
const filePath = ensureMarkdownPath(agent.path);
|
||||||
const markdown = source.files[filePath];
|
const markdown = readPortableTextFile(source.files, filePath);
|
||||||
if (typeof markdown !== "string") {
|
if (typeof markdown !== "string") {
|
||||||
errors.push(`Missing markdown file for agent ${agent.slug}: ${filePath}`);
|
errors.push(`Missing markdown file for agent ${agent.slug}: ${filePath}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -2525,7 +2563,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
|
|
||||||
if (include.projects) {
|
if (include.projects) {
|
||||||
for (const project of manifest.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") {
|
if (typeof markdown !== "string") {
|
||||||
errors.push(`Missing markdown file for project ${project.slug}: ${project.path}`);
|
errors.push(`Missing markdown file for project ${project.slug}: ${project.path}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -2539,7 +2577,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
|
|
||||||
if (include.issues) {
|
if (include.issues) {
|
||||||
for (const issue of manifest.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") {
|
if (typeof markdown !== "string") {
|
||||||
errors.push(`Missing markdown file for task ${issue.slug}: ${issue.path}`);
|
errors.push(`Missing markdown file for task ${issue.slug}: ${issue.path}`);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -2861,6 +2899,55 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
|
|
||||||
if (!targetCompany) throw notFound("Target company not found");
|
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 resultAgents: CompanyPortabilityImportResult["agents"] = [];
|
||||||
const importedSlugToAgentId = new Map<string, string>();
|
const importedSlugToAgentId = new Map<string, string>();
|
||||||
const existingSlugToAgentId = 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);
|
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),
|
onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy),
|
||||||
});
|
});
|
||||||
const desiredSkillRefMap = new Map<string, string>();
|
const desiredSkillRefMap = new Map<string, string>();
|
||||||
|
|
@ -2908,9 +2995,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
const bundleFiles = Object.fromEntries(
|
const bundleFiles = Object.fromEntries(
|
||||||
Object.entries(plan.source.files)
|
Object.entries(plan.source.files)
|
||||||
.filter(([filePath]) => filePath.startsWith(bundlePrefix))
|
.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) || "";
|
const fallbackPromptTemplate = asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
|
||||||
if (!markdownRaw && fallbackPromptTemplate) {
|
if (!markdownRaw && fallbackPromptTemplate) {
|
||||||
bundleFiles["AGENTS.md"] = fallbackPromptTemplate;
|
bundleFiles["AGENTS.md"] = fallbackPromptTemplate;
|
||||||
|
|
@ -3065,7 +3154,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
|
|
||||||
if (include.issues) {
|
if (include.issues) {
|
||||||
for (const manifestIssue of sourceManifest.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 parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null;
|
||||||
const description = parsed?.body || manifestIssue.description || null;
|
const description = parsed?.body || manifestIssue.description || null;
|
||||||
const assigneeAgentId = manifestIssue.assigneeAgentSlug
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue