import { execFile, spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { createStoredZipArchive } from "./helpers/zip.js"; const execFileAsync = promisify(execFile); type ServerProcess = ReturnType; async function getAvailablePort(): Promise { return await new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(() => reject(new Error("Failed to allocate test port"))); return; } const { port } = address; server.close((error) => { if (error) reject(error); else resolve(port); }); }); }); } const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; if (!embeddedPostgresSupport.supported) { console.warn( `Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, ); } function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) { const config = { $meta: { version: 1, updatedAt: new Date().toISOString(), source: "doctor", }, database: { mode: "postgres", connectionString, embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"), embeddedPostgresPort: 54329, backup: { enabled: false, intervalMinutes: 60, retentionDays: 30, dir: path.join(tempRoot, "backups"), }, }, logging: { mode: "file", logDir: path.join(tempRoot, "logs"), }, server: { deploymentMode: "local_trusted", exposure: "private", host: "127.0.0.1", port, allowedHostnames: [], serveUi: false, }, auth: { baseUrlMode: "auto", disableSignUp: false, }, storage: { provider: "local_disk", localDisk: { baseDir: path.join(tempRoot, "storage"), }, s3: { bucket: "paperclip", region: "us-east-1", prefix: "", forcePathStyle: false, }, }, secrets: { provider: "local_encrypted", strictMode: false, localEncrypted: { keyFilePath: path.join(tempRoot, "secrets", "master.key"), }, }, }; mkdirSync(path.dirname(configPath), { recursive: true }); writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); } function createServerEnv(configPath: string, port: number, connectionString: string) { const env = { ...process.env }; for (const key of Object.keys(env)) { if (key.startsWith("PAPERCLIP_")) { delete env[key]; } } delete env.DATABASE_URL; delete env.PORT; delete env.HOST; delete env.SERVE_UI; delete env.HEARTBEAT_SCHEDULER_ENABLED; env.PAPERCLIP_CONFIG = configPath; env.DATABASE_URL = connectionString; env.HOST = "127.0.0.1"; env.PORT = String(port); env.SERVE_UI = "false"; env.PAPERCLIP_DB_BACKUP_ENABLED = "false"; env.HEARTBEAT_SCHEDULER_ENABLED = "false"; env.PAPERCLIP_MIGRATION_AUTO_APPLY = "true"; env.PAPERCLIP_UI_DEV_MIDDLEWARE = "false"; return env; } function createCliEnv() { const env = { ...process.env }; for (const key of Object.keys(env)) { if (key.startsWith("PAPERCLIP_")) { delete env[key]; } } delete env.DATABASE_URL; delete env.PORT; delete env.HOST; delete env.SERVE_UI; delete env.PAPERCLIP_DB_BACKUP_ENABLED; delete env.HEARTBEAT_SCHEDULER_ENABLED; delete env.PAPERCLIP_MIGRATION_AUTO_APPLY; delete env.PAPERCLIP_UI_DEV_MIDDLEWARE; return env; } function collectTextFiles(root: string, current: string, files: Record) { for (const entry of readdirSync(current, { withFileTypes: true })) { const absolutePath = path.join(current, entry.name); if (entry.isDirectory()) { collectTextFiles(root, absolutePath, files); continue; } if (!entry.isFile()) continue; const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/"); files[relativePath] = readFileSync(absolutePath, "utf8"); } } async function stopServerProcess(child: ServerProcess | null) { if (!child || child.exitCode !== null) return; child.kill("SIGTERM"); await new Promise((resolve) => { child.once("exit", () => resolve()); setTimeout(() => { if (child.exitCode === null) { child.kill("SIGKILL"); } }, 5_000); }); } async function api(baseUrl: string, pathname: string, init?: RequestInit): Promise { const res = await fetch(`${baseUrl}${pathname}`, init); const text = await res.text(); if (!res.ok) { throw new Error(`Request failed ${res.status} ${pathname}: ${text}`); } return text ? JSON.parse(text) as T : (null as T); } async function runCliJson(args: string[], opts: { apiBase: string; configPath: string }) { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const result = await execFileAsync( "pnpm", ["--silent", "paperclipai", ...args, "--api-base", opts.apiBase, "--config", opts.configPath, "--json"], { cwd: repoRoot, env: createCliEnv(), maxBuffer: 10 * 1024 * 1024, }, ); const stdout = result.stdout.trim(); const jsonStart = stdout.search(/[\[{]/); if (jsonStart === -1) { throw new Error(`CLI did not emit JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`); } return JSON.parse(stdout.slice(jsonStart)) as T; } async function waitForServer( apiBase: string, child: ServerProcess, output: { stdout: string[]; stderr: string[] }, ) { const startedAt = Date.now(); while (Date.now() - startedAt < 30_000) { if (child.exitCode !== null) { throw new Error( `paperclipai run exited before healthcheck succeeded.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`, ); } try { const res = await fetch(`${apiBase}/api/health`); if (res.ok) return; } catch { // Server is still starting. } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error( `Timed out waiting for ${apiBase}/api/health.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`, ); } describeEmbeddedPostgres("paperclipai company import/export e2e", () => { let tempRoot = ""; let configPath = ""; let exportDir = ""; let apiBase = ""; let serverProcess: ServerProcess | null = null; let tempDb: Awaited> | null = null; beforeAll(async () => { tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-")); configPath = path.join(tempRoot, "config", "config.json"); exportDir = path.join(tempRoot, "exported-company"); tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-"); const port = await getAvailablePort(); writeTestConfig(configPath, tempRoot, port, tempDb.connectionString); apiBase = `http://127.0.0.1:${port}`; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const output = { stdout: [] as string[], stderr: [] as string[] }; const child = spawn( "pnpm", ["paperclipai", "run", "--config", configPath], { cwd: repoRoot, env: createServerEnv(configPath, port, tempDb.connectionString), stdio: ["ignore", "pipe", "pipe"], }, ); serverProcess = child; child.stdout?.on("data", (chunk) => { output.stdout.push(String(chunk)); }); child.stderr?.on("data", (chunk) => { output.stderr.push(String(chunk)); }); await waitForServer(apiBase, child, output); }, 60_000); afterAll(async () => { await stopServerProcess(serverProcess); await tempDb?.cleanup(); if (tempRoot) { rmSync(tempRoot, { recursive: true, force: true }); } }); it("exports a company package and imports it into new and existing companies", async () => { expect(serverProcess).not.toBeNull(); const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }), }); const sourceAgent = await api<{ id: string; name: string }>( apiBase, `/api/companies/${sourceCompany.id}/agents`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Export Engineer", role: "engineer", adapterType: "claude_local", adapterConfig: { promptTemplate: "You verify company portability.", }, }), }, ); const sourceProject = await api<{ id: string; name: string }>( apiBase, `/api/companies/${sourceCompany.id}/projects`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "Portability Verification", status: "in_progress", }), }, ); const largeIssueDescription = `Round-trip the company package through the CLI.\n\n${"portable-data ".repeat(12_000)}`; const sourceIssue = await api<{ id: string; title: string; identifier: string }>( apiBase, `/api/companies/${sourceCompany.id}/issues`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ title: "Validate company import/export", description: largeIssueDescription, status: "todo", projectId: sourceProject.id, assigneeAgentId: sourceAgent.id, }), }, ); const exportResult = await runCliJson<{ ok: boolean; out: string; filesWritten: number; }>( [ "company", "export", sourceCompany.id, "--out", exportDir, "--include", "company,agents,projects,issues", ], { apiBase, configPath }, ); expect(exportResult.ok).toBe(true); expect(exportResult.filesWritten).toBeGreaterThan(0); expect(readFileSync(path.join(exportDir, "COMPANY.md"), "utf8")).toContain(sourceCompany.name); expect(readFileSync(path.join(exportDir, ".paperclip.yaml"), "utf8")).toContain('schema: "paperclip/v1"'); const importedNew = await runCliJson<{ company: { id: string; name: string; action: string }; agents: Array<{ id: string | null; action: string; name: string }>; }>( [ "company", "import", exportDir, "--target", "new", "--new-company-name", `Imported ${sourceCompany.name}`, "--include", "company,agents,projects,issues", "--yes", ], { apiBase, configPath }, ); expect(importedNew.company.action).toBe("created"); expect(importedNew.agents).toHaveLength(1); expect(importedNew.agents[0]?.action).toBe("created"); const importedAgents = await api>( apiBase, `/api/companies/${importedNew.company.id}/agents`, ); const importedProjects = await api>( apiBase, `/api/companies/${importedNew.company.id}/projects`, ); const importedIssues = await api>( apiBase, `/api/companies/${importedNew.company.id}/issues`, ); expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name); expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name); expect(importedIssues.map((issue) => issue.title)).toContain(sourceIssue.title); const previewExisting = await runCliJson<{ errors: string[]; plan: { companyAction: string; agentPlans: Array<{ action: string }>; projectPlans: Array<{ action: string }>; issuePlans: Array<{ action: string }>; }; }>( [ "company", "import", exportDir, "--target", "existing", "--company-id", importedNew.company.id, "--include", "company,agents,projects,issues", "--collision", "rename", "--dry-run", ], { apiBase, configPath }, ); expect(previewExisting.errors).toEqual([]); expect(previewExisting.plan.companyAction).toBe("none"); expect(previewExisting.plan.agentPlans.some((plan) => plan.action === "create")).toBe(true); expect(previewExisting.plan.projectPlans.some((plan) => plan.action === "create")).toBe(true); expect(previewExisting.plan.issuePlans.some((plan) => plan.action === "create")).toBe(true); const importedExisting = await runCliJson<{ company: { id: string; action: string }; agents: Array<{ id: string | null; action: string; name: string }>; }>( [ "company", "import", exportDir, "--target", "existing", "--company-id", importedNew.company.id, "--include", "company,agents,projects,issues", "--collision", "rename", "--yes", ], { apiBase, configPath }, ); expect(importedExisting.company.action).toBe("unchanged"); expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true); const twiceImportedAgents = await api>( apiBase, `/api/companies/${importedNew.company.id}/agents`, ); const twiceImportedProjects = await api>( apiBase, `/api/companies/${importedNew.company.id}/projects`, ); const twiceImportedIssues = await api>( apiBase, `/api/companies/${importedNew.company.id}/issues`, ); expect(twiceImportedAgents).toHaveLength(2); expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2); expect(twiceImportedProjects).toHaveLength(2); expect(twiceImportedIssues).toHaveLength(2); const zipPath = path.join(tempRoot, "exported-company.zip"); const portableFiles: Record = {}; collectTextFiles(exportDir, exportDir, portableFiles); writeFileSync(zipPath, createStoredZipArchive(portableFiles, "paperclip-demo")); const importedFromZip = await runCliJson<{ company: { id: string; name: string; action: string }; agents: Array<{ id: string | null; action: string; name: string }>; }>( [ "company", "import", zipPath, "--target", "new", "--new-company-name", `Zip Imported ${sourceCompany.name}`, "--include", "company,agents,projects,issues", "--yes", ], { apiBase, configPath }, ); expect(importedFromZip.company.action).toBe("created"); expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true); }, 60_000); });