Support binary portability files in UI and CLI

This commit is contained in:
dotta 2026-03-19 07:23:36 -05:00
parent dbc9375256
commit 6d564e0539
5 changed files with 155 additions and 36 deletions

View file

@ -4,6 +4,7 @@ import path from "node:path";
import * as p from "@clack/prompts"; import * as p from "@clack/prompts";
import type { import type {
Company, Company,
CompanyPortabilityFileEntry,
CompanyPortabilityExportResult, CompanyPortabilityExportResult,
CompanyPortabilityInclude, CompanyPortabilityInclude,
CompanyPortabilityPreviewResult, CompanyPortabilityPreviewResult,
@ -50,6 +51,30 @@ interface CompanyImportOptions extends BaseClientOptions {
dryRun?: boolean; dryRun?: boolean;
} }
const binaryContentTypeByExtension: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry {
const contentType = binaryContentTypeByExtension[path.extname(filePath).toLowerCase()];
if (!contentType) return contents.toString("utf8");
return {
encoding: "base64",
data: contents.toString("base64"),
contentType,
};
}
function portableFileEntryToWriteValue(entry: CompanyPortabilityFileEntry): string | Uint8Array {
if (typeof entry === "string") return entry;
return Buffer.from(entry.data, "base64");
}
function isUuidLike(value: string): boolean { function isUuidLike(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
} }
@ -95,7 +120,11 @@ function isGithubUrl(input: string): boolean {
return /^https?:\/\/github\.com\//i.test(input.trim()); return /^https?:\/\/github\.com\//i.test(input.trim());
} }
async function collectPackageFiles(root: string, current: string, files: Record<string, string>): Promise<void> { async function collectPackageFiles(
root: string,
current: string,
files: Record<string, CompanyPortabilityFileEntry>,
): Promise<void> {
const entries = await readdir(current, { withFileTypes: true }); const entries = await readdir(current, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
if (entry.name.startsWith(".git")) continue; if (entry.name.startsWith(".git")) continue;
@ -107,20 +136,21 @@ async function collectPackageFiles(root: string, current: string, files: Record<
if (!entry.isFile()) continue; if (!entry.isFile()) continue;
const isMarkdown = entry.name.endsWith(".md"); const isMarkdown = entry.name.endsWith(".md");
const isPaperclipYaml = entry.name === ".paperclip.yaml" || entry.name === ".paperclip.yml"; const isPaperclipYaml = entry.name === ".paperclip.yaml" || entry.name === ".paperclip.yml";
if (!isMarkdown && !isPaperclipYaml) continue; const contentType = binaryContentTypeByExtension[path.extname(entry.name).toLowerCase()];
if (!isMarkdown && !isPaperclipYaml && !contentType) continue;
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/"); const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
files[relativePath] = await readFile(absolutePath, "utf8"); files[relativePath] = readPortableFileEntry(relativePath, await readFile(absolutePath));
} }
} }
async function resolveInlineSourceFromPath(inputPath: string): Promise<{ async function resolveInlineSourceFromPath(inputPath: string): Promise<{
rootPath: string; rootPath: string;
files: Record<string, string>; files: Record<string, CompanyPortabilityFileEntry>;
}> { }> {
const resolved = path.resolve(inputPath); const resolved = path.resolve(inputPath);
const resolvedStat = await stat(resolved); const resolvedStat = await stat(resolved);
const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved); const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved);
const files: Record<string, string> = {}; const files: Record<string, CompanyPortabilityFileEntry> = {};
await collectPackageFiles(rootDir, rootDir, files); await collectPackageFiles(rootDir, rootDir, files);
return { return {
rootPath: path.basename(rootDir), rootPath: path.basename(rootDir),
@ -135,7 +165,12 @@ async function writeExportToFolder(outDir: string, exported: CompanyPortabilityE
const normalized = relativePath.replace(/\\/g, "/"); const normalized = relativePath.replace(/\\/g, "/");
const filePath = path.join(root, normalized); const filePath = path.join(root, normalized);
await mkdir(path.dirname(filePath), { recursive: true }); await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, content, "utf8"); const writeValue = portableFileEntryToWriteValue(content);
if (typeof writeValue === "string") {
await writeFile(filePath, writeValue, "utf8");
} else {
await writeFile(filePath, writeValue);
}
} }
} }
@ -397,7 +432,7 @@ export function registerCompanyCommands(program: Command): void {
} }
let sourcePayload: let sourcePayload:
| { type: "inline"; rootPath?: string | null; files: Record<string, string> } | { type: "inline"; rootPath?: string | null; files: Record<string, CompanyPortabilityFileEntry> }
| { type: "url"; url: string } | { type: "url"; url: string }
| { type: "github"; url: string }; | { type: "github"; url: string };

View file

@ -27,7 +27,7 @@ const TREE_ROW_HEIGHT_CLASS = "min-h-9";
// ── Helpers ─────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────
export function buildFileTree( export function buildFileTree(
files: Record<string, string>, files: Record<string, unknown>,
actionMap?: Map<string, string>, actionMap?: Map<string, string>,
): FileTreeNode[] { ): FileTreeNode[] {
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] }; const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };

View file

@ -1,3 +1,5 @@
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
const textEncoder = new TextEncoder(); const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder(); const textDecoder = new TextDecoder();
@ -88,12 +90,58 @@ function sharedArchiveRoot(paths: string[]) {
: null; : null;
} }
const binaryContentTypeByExtension: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
function inferBinaryContentType(pathValue: string) {
const normalized = normalizeArchivePath(pathValue);
const extensionIndex = normalized.lastIndexOf(".");
if (extensionIndex === -1) return null;
return binaryContentTypeByExtension[normalized.slice(extensionIndex).toLowerCase()] ?? null;
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(base64: string) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry {
const contentType = inferBinaryContentType(pathValue);
if (!contentType) return textDecoder.decode(bytes);
return {
encoding: "base64",
data: bytesToBase64(bytes),
contentType,
};
}
function portableFileEntryToBytes(entry: CompanyPortabilityFileEntry): Uint8Array {
if (typeof entry === "string") return textEncoder.encode(entry);
return base64ToBytes(entry.data);
}
export function readZipArchive(source: ArrayBuffer | Uint8Array): { export function readZipArchive(source: ArrayBuffer | Uint8Array): {
rootPath: string | null; rootPath: string | null;
files: Record<string, string>; files: Record<string, CompanyPortabilityFileEntry>;
} { } {
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source); const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
const entries: Array<{ path: string; body: string }> = []; const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = [];
let offset = 0; let offset = 0;
while (offset + 4 <= bytes.length) { while (offset + 4 <= bytes.length) {
@ -133,7 +181,7 @@ export function readZipArchive(source: ArrayBuffer | Uint8Array): {
if (archivePath && !archivePath.endsWith("/")) { if (archivePath && !archivePath.endsWith("/")) {
entries.push({ entries.push({
path: archivePath, path: archivePath,
body: textDecoder.decode(bytes.slice(bodyOffset, bodyEnd)), body: bytesToPortableFileEntry(archivePath, bytes.slice(bodyOffset, bodyEnd)),
}); });
} }
@ -141,7 +189,7 @@ export function readZipArchive(source: ArrayBuffer | Uint8Array): {
} }
const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path)); const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
const files: Record<string, string> = {}; const files: Record<string, CompanyPortabilityFileEntry> = {};
for (const entry of entries) { for (const entry of entries) {
const normalizedPath = const normalizedPath =
rootPath && entry.path.startsWith(`${rootPath}/`) rootPath && entry.path.startsWith(`${rootPath}/`)
@ -154,7 +202,7 @@ export function readZipArchive(source: ArrayBuffer | Uint8Array): {
return { rootPath, files }; return { rootPath, files };
} }
export function createZipArchive(files: Record<string, string>, rootPath: string): Uint8Array { export function createZipArchive(files: Record<string, CompanyPortabilityFileEntry>, rootPath: string): Uint8Array {
const normalizedRoot = normalizeArchivePath(rootPath); const normalizedRoot = normalizeArchivePath(rootPath);
const localChunks: Uint8Array[] = []; const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = []; const centralChunks: Uint8Array[] = [];
@ -165,7 +213,7 @@ export function createZipArchive(files: Record<string, string>, rootPath: string
for (const [relativePath, contents] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) { for (const [relativePath, contents] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const archivePath = normalizeArchivePath(`${normalizedRoot}/${relativePath}`); const archivePath = normalizeArchivePath(`${normalizedRoot}/${relativePath}`);
const fileName = textEncoder.encode(archivePath); const fileName = textEncoder.encode(archivePath);
const body = textEncoder.encode(contents); const body = portableFileEntryToBytes(contents);
const checksum = crc32(body); const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length); const localHeader = new Uint8Array(30 + fileName.length);

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import type { import type {
CompanyPortabilityFileEntry,
CompanyPortabilityExportPreviewResult, CompanyPortabilityExportPreviewResult,
CompanyPortabilityExportResult, CompanyPortabilityExportResult,
CompanyPortabilityManifest, CompanyPortabilityManifest,
@ -16,6 +17,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { MarkdownBody } from "../components/MarkdownBody"; import { MarkdownBody } from "../components/MarkdownBody";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { createZipArchive } from "../lib/zip"; import { createZipArchive } from "../lib/zip";
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
import { import {
Download, Download,
Package, Package,
@ -145,7 +147,13 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set<string>): string {
// Flush last section // Flush last section
flushSection(); flushSection();
return out.join("\n"); let filtered = out.join("\n");
const logoPathMatch = filtered.match(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*$/m);
if (logoPathMatch && !checkedFiles.has(logoPathMatch[1]!)) {
filtered = filtered.replace(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*\n?/m, "");
}
return filtered;
} }
/** Filter tree nodes whose path (or descendant paths) match a search string */ /** Filter tree nodes whose path (or descendant paths) match a search string */
@ -263,9 +271,9 @@ function paginateTaskNodes(
function downloadZip( function downloadZip(
exported: CompanyPortabilityExportResult, exported: CompanyPortabilityExportResult,
selectedFiles: Set<string>, selectedFiles: Set<string>,
effectiveFiles: Record<string, string>, effectiveFiles: Record<string, CompanyPortabilityFileEntry>,
) { ) {
const filteredFiles: Record<string, string> = {}; const filteredFiles: Record<string, CompanyPortabilityFileEntry> = {};
for (const [path] of Object.entries(exported.files)) { for (const [path] of Object.entries(exported.files)) {
if (selectedFiles.has(path)) filteredFiles[path] = effectiveFiles[path] ?? exported.files[path]; if (selectedFiles.has(path)) filteredFiles[path] = effectiveFiles[path] ?? exported.files[path];
} }
@ -465,7 +473,7 @@ function ExportPreviewPane({
onSkillClick, onSkillClick,
}: { }: {
selectedFile: string | null; selectedFile: string | null;
content: string | null; content: CompanyPortabilityFileEntry | null;
onSkillClick?: (skill: string) => void; onSkillClick?: (skill: string) => void;
}) { }) {
if (!selectedFile || content === null) { if (!selectedFile || content === null) {
@ -474,8 +482,10 @@ function ExportPreviewPane({
); );
} }
const isMarkdown = selectedFile.endsWith(".md"); const textContent = getPortableFileText(content);
const parsed = isMarkdown ? parseFrontmatter(content) : null; const isMarkdown = selectedFile.endsWith(".md") && textContent !== null;
const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null;
const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null;
return ( return (
<div className="min-w-0"> <div className="min-w-0">
@ -489,11 +499,19 @@ function ExportPreviewPane({
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>} {parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>}
</> </>
) : isMarkdown ? ( ) : isMarkdown ? (
<MarkdownBody>{content}</MarkdownBody> <MarkdownBody>{textContent ?? ""}</MarkdownBody>
) : ( ) : imageSrc ? (
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
</div>
) : textContent !== null ? (
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground"> <pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
<code>{content}</code> <code>{textContent}</code>
</pre> </pre>
) : (
<div className="rounded-lg border border-border bg-accent/10 px-4 py-3 text-sm text-muted-foreground">
Binary asset preview is not available for this file type.
</div>
)} )}
</div> </div>
</div> </div>
@ -674,17 +692,17 @@ export function CompanyExport() {
// Recompute .paperclip.yaml and README.md content whenever checked files // Recompute .paperclip.yaml and README.md content whenever checked files
// change so the preview & download always reflect the current selection. // change so the preview & download always reflect the current selection.
const effectiveFiles = useMemo(() => { const effectiveFiles = useMemo(() => {
if (!exportData) return {} as Record<string, string>; if (!exportData) return {} as Record<string, CompanyPortabilityFileEntry>;
const filtered = { ...exportData.files }; const filtered = { ...exportData.files };
// Filter .paperclip.yaml // Filter .paperclip.yaml
const yamlPath = exportData.paperclipExtensionPath; const yamlPath = exportData.paperclipExtensionPath;
if (yamlPath && exportData.files[yamlPath]) { if (yamlPath && typeof exportData.files[yamlPath] === "string") {
filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles); filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles);
} }
// Regenerate README.md based on checked selection // Regenerate README.md based on checked selection
if (exportData.files["README.md"]) { if (typeof exportData.files["README.md"] === "string") {
const companyName = exportData.manifest.company?.name ?? selectedCompany?.name ?? "Company"; const companyName = exportData.manifest.company?.name ?? selectedCompany?.name ?? "Company";
const companyDescription = exportData.manifest.company?.description ?? null; const companyDescription = exportData.manifest.company?.description ?? null;
filtered["README.md"] = generateReadmeFromSelection( filtered["README.md"] = generateReadmeFromSelection(
@ -818,7 +836,11 @@ export function CompanyExport() {
return <EmptyState icon={Package} message="Loading export data..." />; return <EmptyState icon={Package} message="Loading export data..." />;
} }
const previewContent = selectedFile ? (effectiveFiles[selectedFile] ?? null) : null; const previewContent = selectedFile
? (() => {
return effectiveFiles[selectedFile] ?? null;
})()
: null;
return ( return (
<div> <div>

View file

@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { import type {
CompanyPortabilityCollisionStrategy, CompanyPortabilityCollisionStrategy,
CompanyPortabilityFileEntry,
CompanyPortabilityPreviewResult, CompanyPortabilityPreviewResult,
CompanyPortabilitySource, CompanyPortabilitySource,
CompanyPortabilityAdapterOverride, CompanyPortabilityAdapterOverride,
@ -41,6 +42,7 @@ import {
PackageFileTree, PackageFileTree,
} from "../components/PackageFileTree"; } from "../components/PackageFileTree";
import { readZipArchive } from "../lib/zip"; import { readZipArchive } from "../lib/zip";
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
// ── Import-specific helpers ─────────────────────────────────────────── // ── Import-specific helpers ───────────────────────────────────────────
@ -179,7 +181,7 @@ function ImportPreviewPane({
renamedTo, renamedTo,
}: { }: {
selectedFile: string | null; selectedFile: string | null;
content: string | null; content: CompanyPortabilityFileEntry | null;
action: string | null; action: string | null;
renamedTo: string | null; renamedTo: string | null;
}) { }) {
@ -189,8 +191,10 @@ function ImportPreviewPane({
); );
} }
const isMarkdown = selectedFile.endsWith(".md"); const textContent = getPortableFileText(content);
const parsed = isMarkdown ? parseFrontmatter(content) : null; const isMarkdown = selectedFile.endsWith(".md") && textContent !== null;
const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null;
const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null;
const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : ""; const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : "";
return ( return (
@ -222,11 +226,19 @@ function ImportPreviewPane({
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>} {parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>}
</> </>
) : isMarkdown ? ( ) : isMarkdown ? (
<MarkdownBody>{content}</MarkdownBody> <MarkdownBody>{textContent ?? ""}</MarkdownBody>
) : ( ) : imageSrc ? (
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
</div>
) : textContent !== null ? (
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground"> <pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
<code>{content}</code> <code>{textContent}</code>
</pre> </pre>
) : (
<div className="rounded-lg border border-border bg-accent/10 px-4 py-3 text-sm text-muted-foreground">
Binary asset preview is not available for this file type.
</div>
)} )}
</div> </div>
</div> </div>
@ -557,7 +569,7 @@ function AdapterPickerList({
async function readLocalPackageZip(file: File): Promise<{ async function readLocalPackageZip(file: File): Promise<{
name: string; name: string;
rootPath: string | null; rootPath: string | null;
files: Record<string, string>; files: Record<string, CompanyPortabilityFileEntry>;
}> { }> {
if (!/\.zip$/i.test(file.name)) { if (!/\.zip$/i.test(file.name)) {
throw new Error("Select a .zip company package."); throw new Error("Select a .zip company package.");
@ -592,7 +604,7 @@ export function CompanyImport() {
const [localPackage, setLocalPackage] = useState<{ const [localPackage, setLocalPackage] = useState<{
name: string; name: string;
rootPath: string | null; rootPath: string | null;
files: Record<string, string>; files: Record<string, CompanyPortabilityFileEntry>;
} | null>(null); } | null>(null);
// Target state // Target state
@ -990,7 +1002,9 @@ export function CompanyImport() {
const hasErrors = importPreview ? importPreview.errors.length > 0 : false; const hasErrors = importPreview ? importPreview.errors.length > 0 : false;
const previewContent = selectedFile && importPreview const previewContent = selectedFile && importPreview
? (importPreview.files[selectedFile] ?? null) ? (() => {
return importPreview.files[selectedFile] ?? null;
})()
: null; : null;
const selectedAction = selectedFile ? (actionMap.get(selectedFile) ?? null) : null; const selectedAction = selectedFile ? (actionMap.get(selectedFile) ?? null) : null;