Implement markdown-first company package import export

This commit is contained in:
Dotta
2026-03-13 22:29:30 -05:00
parent 2975aa950b
commit 271c2b9018
5 changed files with 1230 additions and 157 deletions

View File

@@ -1,12 +1,20 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type {
CompanyPortabilityCollisionStrategy,
CompanyPortabilityExportResult,
CompanyPortabilityPreviewRequest,
CompanyPortabilityPreviewResult,
CompanyPortabilitySource,
} from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Settings, Check } from "lucide-react";
import { Settings, Check, Download, Github, Link2, Upload } from "lucide-react";
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
import {
Field,
@@ -28,7 +36,9 @@ export function CompanySettings() {
setSelectedCompanyId
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { pushToast } = useToast();
const queryClient = useQueryClient();
const packageInputRef = useRef<HTMLInputElement | null>(null);
// General settings local state
const [companyName, setCompanyName] = useState("");
@@ -47,6 +57,18 @@ export function CompanySettings() {
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
const [snippetCopied, setSnippetCopied] = useState(false);
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
const [packageIncludeCompany, setPackageIncludeCompany] = useState(true);
const [packageIncludeAgents, setPackageIncludeAgents] = useState(true);
const [importSourceMode, setImportSourceMode] = useState<"github" | "url" | "local">("github");
const [importUrl, setImportUrl] = useState("");
const [importTargetMode, setImportTargetMode] = useState<"existing" | "new">("existing");
const [newCompanyName, setNewCompanyName] = useState("");
const [collisionStrategy, setCollisionStrategy] = useState<CompanyPortabilityCollisionStrategy>("rename");
const [localPackage, setLocalPackage] = useState<{
rootPath: string | null;
files: Record<string, string>;
} | null>(null);
const [importPreview, setImportPreview] = useState<CompanyPortabilityPreviewResult | null>(null);
const generalDirty =
!!selectedCompany &&
@@ -54,6 +76,57 @@ export function CompanySettings() {
description !== (selectedCompany.description ?? "") ||
brandColor !== (selectedCompany.brandColor ?? ""));
const packageInclude = useMemo(
() => ({
company: packageIncludeCompany,
agents: packageIncludeAgents
}),
[packageIncludeAgents, packageIncludeCompany]
);
const importSource = useMemo<CompanyPortabilitySource | null>(() => {
if (importSourceMode === "local") {
if (!localPackage || Object.keys(localPackage.files).length === 0) return null;
return {
type: "inline",
rootPath: localPackage.rootPath,
files: localPackage.files
};
}
const trimmed = importUrl.trim();
if (!trimmed) return null;
return importSourceMode === "github"
? { type: "github", url: trimmed }
: { type: "url", url: trimmed };
}, [importSourceMode, importUrl, localPackage]);
const importPayload = useMemo<CompanyPortabilityPreviewRequest | null>(() => {
if (!importSource) return null;
return {
source: importSource,
include: packageInclude,
target:
importTargetMode === "new"
? {
mode: "new_company",
newCompanyName: newCompanyName.trim() || null
}
: {
mode: "existing_company",
companyId: selectedCompanyId!
},
agents: "all",
collisionStrategy
};
}, [
collisionStrategy,
importSource,
importTargetMode,
newCompanyName,
packageInclude,
selectedCompanyId
]);
const generalMutation = useMutation({
mutationFn: (data: {
name: string;
@@ -75,6 +148,102 @@ export function CompanySettings() {
}
});
const exportMutation = useMutation({
mutationFn: () =>
companiesApi.exportBundle(selectedCompanyId!, {
include: packageInclude
}),
onSuccess: async (exported) => {
await downloadCompanyPackage(exported);
pushToast({
tone: "success",
title: "Company package exported",
body: `${exported.rootPath}.tar downloaded with ${Object.keys(exported.files).length} file${Object.keys(exported.files).length === 1 ? "" : "s"}.`
});
if (exported.warnings.length > 0) {
pushToast({
tone: "warn",
title: "Export completed with warnings",
body: exported.warnings[0]
});
}
},
onError: (err) => {
pushToast({
tone: "error",
title: "Export failed",
body: err instanceof Error ? err.message : "Failed to export company package"
});
}
});
const previewImportMutation = useMutation({
mutationFn: (payload: CompanyPortabilityPreviewRequest) =>
companiesApi.importPreview(payload),
onSuccess: (preview) => {
setImportPreview(preview);
if (preview.errors.length > 0) {
pushToast({
tone: "warn",
title: "Import preview found issues",
body: preview.errors[0]
});
return;
}
pushToast({
tone: "success",
title: "Import preview ready",
body: `${preview.plan.agentPlans.length} agent action${preview.plan.agentPlans.length === 1 ? "" : "s"} planned.`
});
},
onError: (err) => {
setImportPreview(null);
pushToast({
tone: "error",
title: "Import preview failed",
body: err instanceof Error ? err.message : "Failed to preview company package"
});
}
});
const importPackageMutation = useMutation({
mutationFn: (payload: CompanyPortabilityPreviewRequest) =>
companiesApi.importBundle(payload),
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }),
queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats }),
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(result.company.id) }),
queryClient.invalidateQueries({ queryKey: queryKeys.org(result.company.id) })
]);
if (importTargetMode === "new") {
setSelectedCompanyId(result.company.id);
}
pushToast({
tone: "success",
title: "Company package imported",
body: `${result.agents.filter((agent) => agent.action !== "skipped").length} agent${result.agents.filter((agent) => agent.action !== "skipped").length === 1 ? "" : "s"} applied.`
});
if (result.warnings.length > 0) {
pushToast({
tone: "warn",
title: "Import completed with warnings",
body: result.warnings[0]
});
}
setImportPreview(null);
setLocalPackage(null);
setImportUrl("");
},
onError: (err) => {
pushToast({
tone: "error",
title: "Import failed",
body: err instanceof Error ? err.message : "Failed to import company package"
});
}
});
const inviteMutation = useMutation({
mutationFn: () =>
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
@@ -134,6 +303,21 @@ export function CompanySettings() {
setSnippetCopied(false);
setSnippetCopyDelightId(0);
}, [selectedCompanyId]);
useEffect(() => {
setImportPreview(null);
}, [
collisionStrategy,
importSourceMode,
importTargetMode,
importUrl,
localPackage,
newCompanyName,
packageIncludeAgents,
packageIncludeCompany,
selectedCompanyId
]);
const archiveMutation = useMutation({
mutationFn: ({
companyId,
@@ -178,6 +362,64 @@ export function CompanySettings() {
});
}
async function handleChooseLocalPackage(
event: ChangeEvent<HTMLInputElement>
) {
const selection = event.target.files;
if (!selection || selection.length === 0) {
setLocalPackage(null);
return;
}
try {
const parsed = await readLocalPackageSelection(selection);
setLocalPackage(parsed);
pushToast({
tone: "success",
title: "Local package loaded",
body: `${Object.keys(parsed.files).length} markdown file${Object.keys(parsed.files).length === 1 ? "" : "s"} ready for preview.`
});
} catch (err) {
setLocalPackage(null);
pushToast({
tone: "error",
title: "Failed to read local package",
body: err instanceof Error ? err.message : "Could not read selected files"
});
} finally {
event.target.value = "";
}
}
function handlePreviewImport() {
if (!importPayload) {
pushToast({
tone: "warn",
title: "Source required",
body:
importSourceMode === "local"
? "Choose a local folder with COMPANY.md before previewing."
: "Enter a company package URL before previewing."
});
return;
}
previewImportMutation.mutate(importPayload);
}
function handleApplyImport() {
if (!importPayload) {
pushToast({
tone: "warn",
title: "Source required",
body:
importSourceMode === "local"
? "Choose a local folder with COMPANY.md before importing."
: "Enter a company package URL before importing."
});
return;
}
importPackageMutation.mutate(importPayload);
}
return (
<div className="max-w-2xl space-y-6">
<div className="flex items-center gap-2">
@@ -379,6 +621,355 @@ export function CompanySettings() {
</div>
</div>
{/* Import / Export */}
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Company Packages
</div>
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="text-sm font-medium">Export markdown package</div>
<p className="text-xs text-muted-foreground">
Download a markdown-first company package as a single tar file.
</p>
</div>
<Button
size="sm"
onClick={() => exportMutation.mutate()}
disabled={
exportMutation.isPending ||
(!packageIncludeCompany && !packageIncludeAgents)
}
>
<Download className="mr-1 h-3.5 w-3.5" />
{exportMutation.isPending ? "Exporting..." : "Export package"}
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<input
type="checkbox"
checked={packageIncludeCompany}
onChange={(e) => setPackageIncludeCompany(e.target.checked)}
/>
Include company metadata
</label>
<label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<input
type="checkbox"
checked={packageIncludeAgents}
onChange={(e) => setPackageIncludeAgents(e.target.checked)}
/>
Include agents
</label>
</div>
{exportMutation.data && (
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Last export
</div>
<div className="mt-2 text-sm">
{exportMutation.data.rootPath}.tar with{" "}
{Object.keys(exportMutation.data.files).length} file
{Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}.
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
{Object.keys(exportMutation.data.files).map((filePath) => (
<span
key={filePath}
className="rounded-full border border-border px-2 py-0.5"
>
{filePath}
</span>
))}
</div>
{exportMutation.data.warnings.length > 0 && (
<div className="mt-3 space-y-1 text-xs text-amber-700">
{exportMutation.data.warnings.map((warning) => (
<div key={warning}>{warning}</div>
))}
</div>
)}
</div>
)}
</div>
<div className="space-y-4 rounded-md border border-border px-4 py-4">
<div className="space-y-1">
<div className="text-sm font-medium">Import company package</div>
<p className="text-xs text-muted-foreground">
Preview a GitHub repo, direct COMPANY.md URL, or local folder before applying it.
</p>
</div>
<div className="grid gap-2 md:grid-cols-3">
<button
type="button"
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
importSourceMode === "github"
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
}`}
onClick={() => setImportSourceMode("github")}
>
<div className="flex items-center gap-2">
<Github className="h-4 w-4" />
GitHub repo
</div>
</button>
<button
type="button"
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
importSourceMode === "url"
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
}`}
onClick={() => setImportSourceMode("url")}
>
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
Direct URL
</div>
</button>
<button
type="button"
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
importSourceMode === "local"
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
}`}
onClick={() => setImportSourceMode("local")}
>
<div className="flex items-center gap-2">
<Upload className="h-4 w-4" />
Local folder
</div>
</button>
</div>
{importSourceMode === "local" ? (
<div className="rounded-md border border-dashed border-border px-3 py-3">
<input
ref={packageInputRef}
type="file"
multiple
className="hidden"
// @ts-expect-error webkitdirectory is supported by Chromium-based browsers
webkitdirectory=""
onChange={handleChooseLocalPackage}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => packageInputRef.current?.click()}
>
Choose folder
</Button>
{localPackage && (
<span className="text-xs text-muted-foreground">
{localPackage.rootPath ?? "package"} with{" "}
{Object.keys(localPackage.files).length} markdown file
{Object.keys(localPackage.files).length === 1 ? "" : "s"}
</span>
)}
</div>
{!localPackage && (
<p className="mt-2 text-xs text-muted-foreground">
Select a folder that contains COMPANY.md and any referenced
AGENTS.md files.
</p>
)}
</div>
) : (
<Field
label={importSourceMode === "github" ? "GitHub URL" : "Package URL"}
hint={
importSourceMode === "github"
? "Repo root, tree path, or blob URL to COMPANY.md. Unpinned refs warn but do not block."
: "Point directly at COMPANY.md or a directory that contains it."
}
>
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={importUrl}
placeholder={
importSourceMode === "github"
? "https://github.com/owner/repo/tree/main/company"
: "https://example.com/company/COMPANY.md"
}
onChange={(e) => setImportUrl(e.target.value)}
/>
</Field>
)}
<div className="grid gap-3 md:grid-cols-2">
<Field
label="Target"
hint="Import into this company or create a new one from the package."
>
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={importTargetMode}
onChange={(e) =>
setImportTargetMode(e.target.value as "existing" | "new")
}
>
<option value="existing">
Existing company: {selectedCompany.name}
</option>
<option value="new">Create new company</option>
</select>
</Field>
<Field
label="Collision strategy"
hint="Controls what happens when imported agent slugs already exist."
>
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={collisionStrategy}
onChange={(e) =>
setCollisionStrategy(
e.target.value as CompanyPortabilityCollisionStrategy
)
}
>
<option value="rename">Rename imported agents</option>
<option value="skip">Skip existing agents</option>
<option value="replace">Replace existing agents</option>
</select>
</Field>
</div>
{importTargetMode === "new" && (
<Field
label="New company name"
hint="Optional override. Leave blank to use the package name."
>
<input
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type="text"
value={newCompanyName}
onChange={(e) => setNewCompanyName(e.target.value)}
placeholder="Imported Company"
/>
</Field>
)}
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handlePreviewImport}
disabled={
previewImportMutation.isPending ||
(!packageIncludeCompany && !packageIncludeAgents)
}
>
{previewImportMutation.isPending ? "Previewing..." : "Preview import"}
</Button>
<Button
size="sm"
onClick={handleApplyImport}
disabled={
importPackageMutation.isPending ||
previewImportMutation.isPending ||
!!(importPreview && importPreview.errors.length > 0) ||
(!packageIncludeCompany && !packageIncludeAgents)
}
>
{importPackageMutation.isPending ? "Importing..." : "Apply import"}
</Button>
</div>
{importPreview && (
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-3">
<div className="grid gap-2 md:grid-cols-2">
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Company action
</div>
<div className="mt-1 text-sm font-medium">
{importPreview.plan.companyAction}
</div>
</div>
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Agent actions
</div>
<div className="mt-1 text-sm font-medium">
{importPreview.plan.agentPlans.length}
</div>
</div>
</div>
{importPreview.plan.agentPlans.length > 0 && (
<div className="space-y-2">
{importPreview.plan.agentPlans.map((agentPlan) => (
<div
key={agentPlan.slug}
className="rounded-md border border-border bg-background/70 px-3 py-2"
>
<div className="flex items-center justify-between gap-2 text-sm">
<span className="font-medium">
{agentPlan.slug} {"->"} {agentPlan.plannedName}
</span>
<span className="rounded-full border border-border px-2 py-0.5 text-xs uppercase tracking-wide text-muted-foreground">
{agentPlan.action}
</span>
</div>
{agentPlan.reason && (
<div className="mt-1 text-xs text-muted-foreground">
{agentPlan.reason}
</div>
)}
</div>
))}
</div>
)}
{importPreview.requiredSecrets.length > 0 && (
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Required secrets
</div>
{importPreview.requiredSecrets.map((secret) => (
<div
key={`${secret.agentSlug ?? "company"}:${secret.key}`}
className="text-xs text-muted-foreground"
>
{secret.key}
{secret.agentSlug ? ` for ${secret.agentSlug}` : ""}
</div>
))}
</div>
)}
{importPreview.warnings.length > 0 && (
<div className="space-y-1 rounded-md border border-amber-300/60 bg-amber-50/60 px-3 py-2 text-xs text-amber-700">
{importPreview.warnings.map((warning) => (
<div key={warning}>{warning}</div>
))}
</div>
)}
{importPreview.errors.length > 0 && (
<div className="space-y-1 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
{importPreview.errors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* Danger Zone */}
<div className="space-y-4">
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
@@ -435,6 +1026,131 @@ export function CompanySettings() {
);
}
async function readLocalPackageSelection(fileList: FileList): Promise<{
rootPath: string | null;
files: Record<string, string>;
}> {
const files: Record<string, string> = {};
let rootPath: string | null = null;
for (const file of Array.from(fileList)) {
const relativePath =
(file as File & { webkitRelativePath?: string }).webkitRelativePath?.replace(
/\\/g,
"/"
) || file.name;
if (!relativePath.endsWith(".md")) continue;
const topLevel = relativePath.split("/")[0] ?? null;
if (!rootPath && topLevel) rootPath = topLevel;
files[relativePath] = await file.text();
}
if (Object.keys(files).length === 0) {
throw new Error("No markdown files were found in the selected folder.");
}
return { rootPath, files };
}
async function downloadCompanyPackage(
exported: CompanyPortabilityExportResult
): Promise<void> {
const tarBytes = createTarArchive(exported.files, exported.rootPath);
const tarBuffer = new ArrayBuffer(tarBytes.byteLength);
new Uint8Array(tarBuffer).set(tarBytes);
const blob = new Blob(
[tarBuffer],
{
type: "application/x-tar"
}
);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${exported.rootPath}.tar`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function createTarArchive(
files: Record<string, string>,
rootPath: string
): Uint8Array {
const encoder = new TextEncoder();
const chunks: Uint8Array[] = [];
for (const [relativePath, contents] of Object.entries(files)) {
const tarPath = `${rootPath}/${relativePath}`.replace(/\\/g, "/");
const body = encoder.encode(contents);
chunks.push(buildTarHeader(tarPath, body.length));
chunks.push(body);
const remainder = body.length % 512;
if (remainder > 0) {
chunks.push(new Uint8Array(512 - remainder));
}
}
chunks.push(new Uint8Array(1024));
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
return archive;
}
function buildTarHeader(pathname: string, size: number): Uint8Array {
const header = new Uint8Array(512);
writeTarString(header, 0, 100, pathname);
writeTarOctal(header, 100, 8, 0o644);
writeTarOctal(header, 108, 8, 0);
writeTarOctal(header, 116, 8, 0);
writeTarOctal(header, 124, 12, size);
writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000));
for (let i = 148; i < 156; i += 1) {
header[i] = 32;
}
header[156] = "0".charCodeAt(0);
writeTarString(header, 257, 6, "ustar");
writeTarString(header, 263, 2, "00");
const checksum = header.reduce((sum, byte) => sum + byte, 0);
writeTarChecksum(header, checksum);
return header;
}
function writeTarString(
target: Uint8Array,
offset: number,
length: number,
value: string
) {
const encoded = new TextEncoder().encode(value);
target.set(encoded.slice(0, length), offset);
}
function writeTarOctal(
target: Uint8Array,
offset: number,
length: number,
value: number
) {
const stringValue = value.toString(8).padStart(length - 1, "0");
writeTarString(target, offset, length - 1, stringValue);
target[offset + length - 1] = 0;
}
function writeTarChecksum(target: Uint8Array, checksum: number) {
const stringValue = checksum.toString(8).padStart(6, "0");
writeTarString(target, 148, 6, stringValue);
target[154] = 0;
target[155] = 32;
}
function buildAgentSnippet(input: AgentSnippetInput) {
const candidateUrls = buildCandidateOnboardingUrls(input);
const resolutionTestUrl = buildResolutionTestUrl(input);