diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index a6b84097..6cf8e78a 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -120,10 +120,6 @@ export type CompanyPortabilitySource = rootPath?: string | null; files: Record; } - | { - type: "url"; - url: string; - } | { type: "github"; url: string; diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index 900a108e..d8bcd39b 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -123,10 +123,6 @@ export const portabilitySourceSchema = z.discriminatedUnion("type", [ rootPath: z.string().min(1).optional().nullable(), files: z.record(z.string()), }), - z.object({ - type: z.literal("url"), - url: z.string().url(), - }), z.object({ type: z.literal("github"), url: z.string().url(), diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 206c06f8..cdc737b2 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1380,33 +1380,6 @@ export function companyPortabilityService(db: Db) { ); } - if (source.type === "url") { - const normalizedUrl = source.url.trim(); - const companyUrl = normalizedUrl.endsWith(".md") - ? normalizedUrl - : new URL("COMPANY.md", normalizedUrl.endsWith("/") ? normalizedUrl : `${normalizedUrl}/`).toString(); - const companyMarkdown = await fetchText(companyUrl); - const files: Record = { - "COMPANY.md": companyMarkdown, - }; - const paperclipYaml = await fetchOptionalText( - new URL(".paperclip.yaml", companyUrl).toString(), - ).catch(() => null); - if (paperclipYaml) { - files[".paperclip.yaml"] = paperclipYaml; - } - const companyDoc = parseFrontmatterMarkdown(companyMarkdown); - const includeEntries = readIncludeEntries(companyDoc.frontmatter); - - for (const includeEntry of includeEntries) { - const includePath = normalizePortablePath(includeEntry.path); - if (!includePath.endsWith(".md")) continue; - const includeUrl = new URL(includeEntry.path, companyUrl).toString(); - files[includePath] = await fetchText(includeUrl); - } - return buildManifestFromPackageFiles(files); - } - const parsed = parseGitHubSourceUrl(source.url); let ref = parsed.ref; const warnings: string[] = []; diff --git a/ui/src/lib/zip.test.ts b/ui/src/lib/zip.test.ts index 6c36492d..8537f014 100644 --- a/ui/src/lib/zip.test.ts +++ b/ui/src/lib/zip.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { createZipArchive } from "./zip"; +import { createZipArchive, readZipArchive } from "./zip"; function readUint16(bytes: Uint8Array, offset: number) { return bytes[offset]! | (bytes[offset + 1]! << 8); @@ -50,4 +50,24 @@ describe("createZipArchive", () => { expect(readUint16(archive, endOffset + 8)).toBe(2); expect(readUint16(archive, endOffset + 10)).toBe(2); }); + + it("reads a Paperclip zip archive back into rootPath and file contents", () => { + const archive = createZipArchive( + { + "COMPANY.md": "# Company\n", + "agents/ceo/AGENTS.md": "# CEO\n", + ".paperclip.yaml": "schema: paperclip/v1\n", + }, + "paperclip-demo", + ); + + expect(readZipArchive(archive)).toEqual({ + rootPath: "paperclip-demo", + files: { + "COMPANY.md": "# Company\n", + "agents/ceo/AGENTS.md": "# CEO\n", + ".paperclip.yaml": "schema: paperclip/v1\n", + }, + }); + }); }); diff --git a/ui/src/lib/zip.ts b/ui/src/lib/zip.ts index 7f677f65..ae9ee9b1 100644 --- a/ui/src/lib/zip.ts +++ b/ui/src/lib/zip.ts @@ -1,4 +1,5 @@ const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); const crcTable = new Uint32Array(256); for (let i = 0; i < 256; i++) { @@ -37,6 +38,19 @@ function writeUint32(target: Uint8Array, offset: number, value: number) { target[offset + 3] = (value >>> 24) & 0xff; } +function readUint16(source: Uint8Array, offset: number) { + return source[offset]! | (source[offset + 1]! << 8); +} + +function readUint32(source: Uint8Array, offset: number) { + return ( + source[offset]! | + (source[offset + 1]! << 8) | + (source[offset + 2]! << 16) | + (source[offset + 3]! << 24) + ) >>> 0; +} + function getDosDateTime(date: Date) { const year = Math.min(Math.max(date.getFullYear(), 1980), 2107); const month = date.getMonth() + 1; @@ -62,6 +76,84 @@ function concatChunks(chunks: Uint8Array[]) { return archive; } +function sharedArchiveRoot(paths: string[]) { + if (paths.length === 0) return null; + const firstSegments = paths + .map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean)) + .filter((parts) => parts.length > 0); + if (firstSegments.length === 0) return null; + const candidate = firstSegments[0]![0]!; + return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate) + ? candidate + : null; +} + +export function readZipArchive(source: ArrayBuffer | Uint8Array): { + rootPath: string | null; + files: Record; +} { + const bytes = source instanceof Uint8Array ? source : new Uint8Array(source); + const entries: Array<{ path: string; body: string }> = []; + let offset = 0; + + while (offset + 4 <= bytes.length) { + const signature = readUint32(bytes, offset); + if (signature === 0x02014b50 || signature === 0x06054b50) break; + if (signature !== 0x04034b50) { + throw new Error("Invalid zip archive: unsupported local file header."); + } + + if (offset + 30 > bytes.length) { + throw new Error("Invalid zip archive: truncated local file header."); + } + + const generalPurposeFlag = readUint16(bytes, offset + 6); + const compressionMethod = readUint16(bytes, offset + 8); + const compressedSize = readUint32(bytes, offset + 18); + const fileNameLength = readUint16(bytes, offset + 26); + const extraFieldLength = readUint16(bytes, offset + 28); + + if ((generalPurposeFlag & 0x0008) !== 0) { + throw new Error("Unsupported zip archive: data descriptors are not supported."); + } + if (compressionMethod !== 0) { + throw new Error("Unsupported zip archive: only uncompressed entries are supported."); + } + + const nameOffset = offset + 30; + const bodyOffset = nameOffset + fileNameLength + extraFieldLength; + const bodyEnd = bodyOffset + compressedSize; + if (bodyEnd > bytes.length) { + throw new Error("Invalid zip archive: truncated file contents."); + } + + const archivePath = normalizeArchivePath( + textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)), + ); + if (archivePath && !archivePath.endsWith("/")) { + entries.push({ + path: archivePath, + body: textDecoder.decode(bytes.slice(bodyOffset, bodyEnd)), + }); + } + + offset = bodyEnd; + } + + const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path)); + const files: Record = {}; + for (const entry of entries) { + const normalizedPath = + rootPath && entry.path.startsWith(`${rootPath}/`) + ? entry.path.slice(rootPath.length + 1) + : entry.path; + if (!normalizedPath) continue; + files[normalizedPath] = entry.body; + } + + return { rootPath, files }; +} + export function createZipArchive(files: Record, rootPath: string): Uint8Array { const normalizedRoot = normalizeArchivePath(rootPath); const localChunks: Uint8Array[] = []; diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index de45d7a3..d53e9b8e 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -18,7 +18,6 @@ import { Check, Download, Github, - Link2, Package, Upload, } from "lucide-react"; @@ -33,6 +32,7 @@ import { FRONTMATTER_FIELD_LABELS, PackageFileTree, } from "../components/PackageFileTree"; +import { readZipArchive } from "../lib/zip"; // ── Import-specific helpers ─────────────────────────────────────────── @@ -253,12 +253,19 @@ function buildConflictList( return conflicts; } -/** Extract a prefix from the import source URL or local folder name */ -function deriveSourcePrefix(sourceMode: string, importUrl: string, localRootPath: string | null): string | null { - if (sourceMode === "local" && localRootPath) { - return localRootPath.split("/").pop() ?? null; +/** Extract a prefix from the import source URL or uploaded zip package name */ +function deriveSourcePrefix( + sourceMode: string, + importUrl: string, + localPackageName: string | null, + localRootPath: string | null, +): string | null { + if (sourceMode === "local") { + if (localRootPath) return localRootPath.split("/").pop() ?? null; + if (!localPackageName) return null; + return localPackageName.replace(/\.zip$/i, "") || null; } - if (sourceMode === "github" || sourceMode === "url") { + if (sourceMode === "github") { const url = importUrl.trim(); if (!url) return null; try { @@ -407,30 +414,23 @@ function ConflictResolutionList({ // ── Helpers ─────────────────────────────────────────────────────────── -async function readLocalPackageSelection(fileList: FileList): Promise<{ +async function readLocalPackageZip(file: File): Promise<{ + name: string; rootPath: string | null; files: Record; }> { - const files: Record = {}; - let rootPath: string | null = null; - for (const file of Array.from(fileList)) { - const relativePath = - (file as File & { webkitRelativePath?: string }).webkitRelativePath?.replace( - /\\/g, - "/", - ) || file.name; - const isMarkdown = relativePath.endsWith(".md"); - const isPaperclipYaml = - relativePath.endsWith(".paperclip.yaml") || relativePath.endsWith(".paperclip.yml"); - if (!isMarkdown && !isPaperclipYaml) continue; - const topLevel = relativePath.split("/")[0] ?? null; - if (!rootPath && topLevel) rootPath = topLevel; - files[relativePath] = await file.text(); + if (!/\.zip$/i.test(file.name)) { + throw new Error("Select a .zip company package."); } - if (Object.keys(files).length === 0) { - throw new Error("No package files were found in the selected folder."); + const archive = readZipArchive(await file.arrayBuffer()); + if (Object.keys(archive.files).length === 0) { + throw new Error("No package files were found in the selected zip archive."); } - return { rootPath, files }; + return { + name: file.name, + rootPath: archive.rootPath, + files: archive.files, + }; } // ── Main page ───────────────────────────────────────────────────────── @@ -447,9 +447,10 @@ export function CompanyImport() { const packageInputRef = useRef(null); // Source state - const [sourceMode, setSourceMode] = useState<"github" | "url" | "local">("github"); + const [sourceMode, setSourceMode] = useState<"github" | "local">("github"); const [importUrl, setImportUrl] = useState(""); const [localPackage, setLocalPackage] = useState<{ + name: string; rootPath: string | null; files: Record; } | null>(null); @@ -484,15 +485,9 @@ export function CompanyImport() { } const url = importUrl.trim(); if (!url) return null; - if (sourceMode === "github") return { type: "github", url }; - return { type: "url", url }; + return { type: "github", url }; } - const sourcePrefix = useMemo( - () => deriveSourcePrefix(sourceMode, importUrl, localPackage?.rootPath ?? null), - [sourceMode, importUrl, localPackage], - ); - // Preview mutation const previewMutation = useMutation({ mutationFn: () => { @@ -513,7 +508,12 @@ export function CompanyImport() { // Build conflicts and set default name overrides with prefix const conflicts = buildConflictList(result); - const prefix = deriveSourcePrefix(sourceMode, importUrl, localPackage?.rootPath ?? null); + const prefix = deriveSourcePrefix( + sourceMode, + importUrl, + localPackage?.name ?? null, + localPackage?.rootPath ?? null, + ); const defaultOverrides: Record = {}; for (const c of conflicts) { @@ -625,7 +625,7 @@ export function CompanyImport() { const fileList = e.target.files; if (!fileList || fileList.length === 0) return; try { - const pkg = await readLocalPackageSelection(fileList); + const pkg = await readLocalPackageZip(fileList[0]!); setLocalPackage(pkg); setImportPreview(null); } catch (err) { @@ -764,16 +764,15 @@ export function CompanyImport() {

Import source

- Choose a GitHub repo, direct URL, or local folder to import from. + Choose a GitHub repo or upload a local Paperclip zip package.

-
+
{( [ { key: "github", icon: Github, label: "GitHub repo" }, - { key: "url", icon: Link2, label: "Direct URL" }, - { key: "local", icon: Upload, label: "Local folder" }, + { key: "local", icon: Upload, label: "Local zip" }, ] as const ).map(({ key, icon: Icon, label }) => ( {localPackage && ( - {localPackage.rootPath ?? "package"} with{" "} + {localPackage.name} with{" "} {Object.keys(localPackage.files).length} file {Object.keys(localPackage.files).length === 1 ? "" : "s"} @@ -824,28 +824,20 @@ export function CompanyImport() {
{!localPackage && (

- Select a folder that contains COMPANY.md and any referenced AGENTS.md files. + Upload a `.zip` exported from Paperclip that contains COMPANY.md and the related package files.

)}
) : ( { setImportUrl(e.target.value); setImportPreview(null); @@ -934,7 +926,7 @@ export function CompanyImport() { /> {/* Import button — below renames */} -
+