From e204e03fa6084ba87690d6a1e2320ec629d8f565 Mon Sep 17 00:00:00 2001 From: dotta Date: Sun, 22 Mar 2026 06:09:26 -0500 Subject: [PATCH 1/4] Add CLI company import export e2e test Co-Authored-By: Paperclip --- .../company-import-export-e2e.test.ts | 501 ++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 cli/src/__tests__/company-import-export-e2e.test.ts diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts new file mode 100644 index 00000000..094f846b --- /dev/null +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -0,0 +1,501 @@ +import { execFile, spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, 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"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +const execFileAsync = promisify(execFile); +type ServerProcess = ReturnType; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +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); + }); + }); + }); +} + +async function startTempDatabase() { + const dataDir = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-db-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C"], + onLog: () => {}, + onError: () => {}, + }); + await instance.initialise(); + await instance.start(); + + const { applyPendingMigrations, ensurePostgresDatabase } = await import("@paperclipai/db"); + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + + return { connectionString, dataDir, instance }; +} + +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; +} + +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("")}`, + ); +} + +describe("paperclipai company import/export e2e", () => { + let tempRoot = ""; + let configPath = ""; + let exportDir = ""; + let apiBase = ""; + let serverProcess: ServerProcess | null = null; + let dbDataDir = ""; + let dbInstance: EmbeddedPostgresInstance | 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"); + + const db = await startTempDatabase(); + dbDataDir = db.dataDir; + dbInstance = db.instance; + + const port = await getAvailablePort(); + writeTestConfig(configPath, tempRoot, port, db.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, db.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 dbInstance?.stop(); + if (dbDataDir) { + rmSync(dbDataDir, { recursive: true, force: true }); + } + 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 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: "Round-trip the company package through the CLI.", + 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", + "--from", + exportDir, + "--target", + "new", + "--new-company-name", + `Imported ${sourceCompany.name}`, + "--include", + "company,agents,projects,issues", + ], + { 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", + "--from", + 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("update"); + 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", + "--from", + exportDir, + "--target", + "existing", + "--company-id", + importedNew.company.id, + "--include", + "company,agents,projects,issues", + "--collision", + "rename", + ], + { apiBase, configPath }, + ); + + expect(importedExisting.company.action).toBe("updated"); + 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); + }, 60_000); +}); From 5a73556871ab532c7ad876973eb521ac9f704a72 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 06:34:24 -0500 Subject: [PATCH 2/4] Use positional source arg for company import Co-Authored-By: Paperclip --- cli/src/__tests__/company-import-export-e2e.test.ts | 3 --- cli/src/commands/client/company.ts | 8 ++++---- doc/AGENTCOMPANIES_SPEC_INVENTORY.md | 2 +- doc/plans/2026-03-13-company-import-export-v2.md | 4 ++-- docs/cli/control-plane-commands.md | 4 ++-- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 094f846b..a2074658 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -390,7 +390,6 @@ describe("paperclipai company import/export e2e", () => { [ "company", "import", - "--from", exportDir, "--target", "new", @@ -435,7 +434,6 @@ describe("paperclipai company import/export e2e", () => { [ "company", "import", - "--from", exportDir, "--target", "existing", @@ -463,7 +461,6 @@ describe("paperclipai company import/export e2e", () => { [ "company", "import", - "--from", exportDir, "--target", "existing", diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 01de4548..96bfa835 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -390,7 +390,7 @@ export function registerCompanyCommands(program: Command): void { company .command("import") .description("Import a portable markdown company package from local path, URL, or GitHub") - .requiredOption("--from ", "Source path or URL") + .argument("", "Source path or URL") .option("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents") .option("--target ", "Target mode: new | existing") .option("-C, --company-id ", "Existing target company ID") @@ -398,12 +398,12 @@ export function registerCompanyCommands(program: Command): void { .option("--agents ", "Comma-separated agent slugs to import, or all", "all") .option("--collision ", "Collision strategy: rename | skip | replace", "rename") .option("--dry-run", "Run preview only without applying", false) - .action(async (opts: CompanyImportOptions) => { + .action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => { try { const ctx = resolveCommandContext(opts); - const from = (opts.from ?? "").trim(); + const from = fromPathOrUrl.trim(); if (!from) { - throw new Error("--from is required"); + throw new Error("Source path or URL is required."); } const include = parseInclude(opts.include); diff --git a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md index 0d622890..91799bea 100644 --- a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md +++ b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md @@ -60,7 +60,7 @@ Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)` | File | Commands | |---|---| -| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).
`company import` — imports a company package from a file or folder (flags: `--from`, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--dryRun`).
Reads/writes portable file entries and handles `.paperclip.yaml` filtering. | +| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).
`company import ` — imports a company package from a file or folder (flags: positional source path/URL, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--dryRun`).
Reads/writes portable file entries and handles `.paperclip.yaml` filtering. | ## 7. UI — Pages diff --git a/doc/plans/2026-03-13-company-import-export-v2.md b/doc/plans/2026-03-13-company-import-export-v2.md index 89d39d81..bd26890c 100644 --- a/doc/plans/2026-03-13-company-import-export-v2.md +++ b/doc/plans/2026-03-13-company-import-export-v2.md @@ -484,8 +484,8 @@ The CLI should continue to support direct import/export without a registry. Target commands: - `paperclipai company export --out ` -- `paperclipai company import --from --dry-run` -- `paperclipai company import --from --target existing -C ` +- `paperclipai company import --dry-run` +- `paperclipai company import --target existing -C ` Planned additions: diff --git a/docs/cli/control-plane-commands.md b/docs/cli/control-plane-commands.md index c0d2664c..f6eb33d7 100644 --- a/docs/cli/control-plane-commands.md +++ b/docs/cli/control-plane-commands.md @@ -41,7 +41,7 @@ pnpm paperclipai company export --out ./exports/acme --include comp # Preview import (no writes) pnpm paperclipai company import \ - --from https://github.com///tree/main/ \ + https://github.com///tree/main/ \ --target existing \ --company-id \ --collision rename \ @@ -49,7 +49,7 @@ pnpm paperclipai company import \ # Apply import pnpm paperclipai company import \ - --from ./exports/acme \ + ./exports/acme \ --target new \ --new-company-name "Acme Imported" \ --include company,agents From e6df9fa078d50a7a99bbbf5876ab0aec35dcb411 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 06:47:32 -0500 Subject: [PATCH 3/4] Support GitHub shorthand refs for company import Co-Authored-By: Paperclip --- cli/src/__tests__/company-import-url.test.ts | 45 ++++++- cli/src/commands/client/company.ts | 121 +++++++++++++++++- doc/AGENTCOMPANIES_SPEC_INVENTORY.md | 2 +- docs/cli/control-plane-commands.md | 3 +- .../src/__tests__/company-portability.test.ts | 28 +++- server/src/services/company-portability.ts | 25 +++- 6 files changed, 215 insertions(+), 9 deletions(-) diff --git a/cli/src/__tests__/company-import-url.test.ts b/cli/src/__tests__/company-import-url.test.ts index a749d57e..abc96f7d 100644 --- a/cli/src/__tests__/company-import-url.test.ts +++ b/cli/src/__tests__/company-import-url.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { isHttpUrl, isGithubUrl } from "../commands/client/company.js"; +import { + isGithubShorthand, + isGithubUrl, + isHttpUrl, + normalizeGithubImportSource, +} from "../commands/client/company.js"; describe("isHttpUrl", () => { it("matches http URLs", () => { @@ -29,3 +34,41 @@ describe("isGithubUrl", () => { expect(isGithubUrl("/tmp/my-company")).toBe(false); }); }); + +describe("isGithubShorthand", () => { + it("matches owner/repo/path shorthands", () => { + expect(isGithubShorthand("paperclipai/companies/gstack")).toBe(true); + expect(isGithubShorthand("paperclipai/companies")).toBe(true); + }); + + it("rejects local-looking paths", () => { + expect(isGithubShorthand("./exports/acme")).toBe(false); + expect(isGithubShorthand("/tmp/acme")).toBe(false); + expect(isGithubShorthand("C:\\temp\\acme")).toBe(false); + }); +}); + +describe("normalizeGithubImportSource", () => { + it("normalizes shorthand imports to canonical GitHub sources", () => { + expect(normalizeGithubImportSource("paperclipai/companies/gstack")).toBe( + "https://github.com/paperclipai/companies?ref=main&path=gstack", + ); + }); + + it("applies --ref to shorthand imports", () => { + expect(normalizeGithubImportSource("paperclipai/companies/gstack", "feature/demo")).toBe( + "https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack", + ); + }); + + it("applies --ref to existing GitHub tree URLs without losing the package path", () => { + expect( + normalizeGithubImportSource( + "https://github.com/paperclipai/companies/tree/main/gstack", + "release/2026-03-23", + ), + ).toBe( + "https://github.com/paperclipai/companies?ref=release%2F2026-03-23&path=gstack", + ); + }); +}); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 96bfa835..17824dbd 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -42,13 +42,13 @@ interface CompanyExportOptions extends BaseClientOptions { } interface CompanyImportOptions extends BaseClientOptions { - from?: string; include?: string; target?: CompanyImportTargetMode; companyId?: string; newCompanyName?: string; agents?: string; collision?: CompanyCollisionMode; + ref?: string; dryRun?: boolean; } @@ -122,6 +122,112 @@ export function isGithubUrl(input: string): boolean { return /^https?:\/\/github\.com\//i.test(input.trim()); } +function isGithubSegment(input: string): boolean { + return /^[A-Za-z0-9._-]+$/.test(input); +} + +export function isGithubShorthand(input: string): boolean { + const trimmed = input.trim(); + if (!trimmed || isHttpUrl(trimmed)) return false; + if ( + trimmed.startsWith(".") || + trimmed.startsWith("/") || + trimmed.startsWith("~") || + trimmed.includes("\\") || + /^[A-Za-z]:/.test(trimmed) + ) { + return false; + } + + const segments = trimmed.split("/").filter(Boolean); + return segments.length >= 2 && segments.every(isGithubSegment); +} + +function normalizeGithubImportPath(input: string | null | undefined): string | null { + if (!input) return null; + const trimmed = input.trim().replace(/^\/+|\/+$/g, ""); + return trimmed || null; +} + +function buildGithubImportUrl(input: { + owner: string; + repo: string; + ref?: string | null; + path?: string | null; + companyPath?: string | null; +}): string { + const url = new URL(`https://github.com/${input.owner}/${input.repo.replace(/\.git$/i, "")}`); + const ref = input.ref?.trim(); + if (ref) { + url.searchParams.set("ref", ref); + } + const companyPath = normalizeGithubImportPath(input.companyPath); + if (companyPath) { + url.searchParams.set("companyPath", companyPath); + return url.toString(); + } + const sourcePath = normalizeGithubImportPath(input.path); + if (sourcePath) { + url.searchParams.set("path", sourcePath); + } + return url.toString(); +} + +export function normalizeGithubImportSource(input: string, refOverride?: string): string { + const trimmed = input.trim(); + const ref = refOverride?.trim(); + + if (isGithubShorthand(trimmed)) { + const [owner, repo, ...repoPath] = trimmed.split("/").filter(Boolean); + return buildGithubImportUrl({ + owner: owner!, + repo: repo!, + ref: ref || "main", + path: repoPath.join("/"), + }); + } + + if (!isGithubUrl(trimmed)) { + throw new Error("GitHub source must be a github.com URL or owner/repo[/path] shorthand."); + } + if (!ref) { + return trimmed; + } + + const url = new URL(trimmed); + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length < 2) { + throw new Error("Invalid GitHub URL."); + } + + const owner = parts[0]!; + const repo = parts[1]!; + const existingPath = normalizeGithubImportPath(url.searchParams.get("path")); + const existingCompanyPath = normalizeGithubImportPath(url.searchParams.get("companyPath")); + if (existingCompanyPath) { + return buildGithubImportUrl({ owner, repo, ref, companyPath: existingCompanyPath }); + } + if (existingPath) { + return buildGithubImportUrl({ owner, repo, ref, path: existingPath }); + } + if (parts[2] === "tree") { + return buildGithubImportUrl({ owner, repo, ref, path: parts.slice(4).join("/") }); + } + if (parts[2] === "blob") { + return buildGithubImportUrl({ owner, repo, ref, companyPath: parts.slice(4).join("/") }); + } + return buildGithubImportUrl({ owner, repo, ref }); +} + +async function pathExists(inputPath: string): Promise { + try { + await stat(path.resolve(inputPath)); + return true; + } catch { + return false; + } +} + async function collectPackageFiles( root: string, current: string, @@ -397,6 +503,7 @@ export function registerCompanyCommands(program: Command): void { .option("--new-company-name ", "Name override for --target new") .option("--agents ", "Comma-separated agent slugs to import, or all", "all") .option("--collision ", "Collision strategy: rename | skip | replace", "rename") + .option("--ref ", "Git ref to use for GitHub imports (branch, tag, or commit)") .option("--dry-run", "Run preview only without applying", false) .action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => { try { @@ -439,15 +546,21 @@ export function registerCompanyCommands(program: Command): void { | { type: "inline"; rootPath?: string | null; files: Record } | { type: "github"; url: string }; - if (isHttpUrl(from)) { - if (!isGithubUrl(from)) { + const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from); + const isGithubSource = isGithubUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath); + + if (isHttpUrl(from) || isGithubSource) { + if (!isGithubUrl(from) && !isGithubShorthand(from)) { throw new Error( "Only GitHub URLs and local paths are supported for import. " + "Generic HTTP URLs are not supported. Use a GitHub URL (https://github.com/...) or a local directory path.", ); } - sourcePayload = { type: "github", url: from }; + sourcePayload = { type: "github", url: normalizeGithubImportSource(from, opts.ref) }; } else { + if (opts.ref?.trim()) { + throw new Error("--ref is only supported for GitHub import sources."); + } const inline = await resolveInlineSourceFromPath(from); sourcePayload = { type: "inline", diff --git a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md index 91799bea..a3376a89 100644 --- a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md +++ b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md @@ -60,7 +60,7 @@ Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)` | File | Commands | |---|---| -| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).
`company import ` — imports a company package from a file or folder (flags: positional source path/URL, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--dryRun`).
Reads/writes portable file entries and handles `.paperclip.yaml` filtering. | +| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).
`company import ` — imports a company package from a file or folder (flags: positional source path/URL or GitHub shorthand, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--ref`, `--dryRun`).
Reads/writes portable file entries and handles `.paperclip.yaml` filtering. | ## 7. UI — Pages diff --git a/docs/cli/control-plane-commands.md b/docs/cli/control-plane-commands.md index f6eb33d7..80eb0edb 100644 --- a/docs/cli/control-plane-commands.md +++ b/docs/cli/control-plane-commands.md @@ -41,9 +41,10 @@ pnpm paperclipai company export --out ./exports/acme --include comp # Preview import (no writes) pnpm paperclipai company import \ - https://github.com///tree/main/ \ + // \ --target existing \ --company-id \ + --ref main \ --collision rename \ --dry-run diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index ff019530..0e593369 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -87,7 +87,7 @@ vi.mock("../routes/org-chart-svg.js", () => ({ renderOrgChartPng: vi.fn(async () => Buffer.from("png")), })); -const { companyPortabilityService } = await import("../services/company-portability.js"); +const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js"); function asTextFile(entry: CompanyPortabilityFileEntry | undefined) { expect(typeof entry).toBe("string"); @@ -301,6 +301,32 @@ describe("company portability", () => { })); }); + it("parses canonical GitHub import URLs with explicit ref and package path", () => { + expect( + parseGitHubSourceUrl("https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack"), + ).toEqual({ + owner: "paperclipai", + repo: "companies", + ref: "feature/demo", + basePath: "gstack", + companyPath: "gstack/COMPANY.md", + }); + }); + + it("parses canonical GitHub import URLs with explicit companyPath", () => { + expect( + parseGitHubSourceUrl( + "https://github.com/paperclipai/companies?ref=abc123&companyPath=gstack%2FCOMPANY.md", + ), + ).toEqual({ + owner: "paperclipai", + repo: "companies", + ref: "abc123", + basePath: "gstack", + companyPath: "gstack/COMPANY.md", + }); + }); + it("exports referenced skills as stubs by default with sanitized Paperclip extension data", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index d063fd36..704085dd 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1898,7 +1898,12 @@ function buildManifestFromPackageFiles( } -function parseGitHubSourceUrl(rawUrl: string) { +function normalizeGitHubSourcePath(value: string | null | undefined) { + if (!value) return ""; + return value.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); +} + +export function parseGitHubSourceUrl(rawUrl: string) { const url = new URL(rawUrl); if (url.hostname !== "github.com") { throw unprocessable("GitHub source must use github.com URL"); @@ -1909,6 +1914,24 @@ function parseGitHubSourceUrl(rawUrl: string) { } const owner = parts[0]!; const repo = parts[1]!.replace(/\.git$/i, ""); + const queryRef = url.searchParams.get("ref")?.trim(); + const queryPath = normalizeGitHubSourcePath(url.searchParams.get("path")); + const queryCompanyPath = normalizeGitHubSourcePath(url.searchParams.get("companyPath")); + if (queryRef || queryPath || queryCompanyPath) { + const companyPath = queryCompanyPath || [queryPath, "COMPANY.md"].filter(Boolean).join("/") || "COMPANY.md"; + let basePath = queryPath; + if (!basePath && companyPath !== "COMPANY.md") { + basePath = path.posix.dirname(companyPath); + if (basePath === ".") basePath = ""; + } + return { + owner, + repo, + ref: queryRef || "main", + basePath, + companyPath, + }; + } let ref = "main"; let basePath = ""; let companyPath = "COMPANY.md"; From f23d611d0ce06f1712c3a1479a54284e2702ff96 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 07:23:34 -0500 Subject: [PATCH 4/4] Route existing-company CLI imports through safe routes Co-Authored-By: Paperclip --- .../company-import-export-e2e.test.ts | 4 +- cli/src/__tests__/company.test.ts | 50 +++++++++++++++++++ cli/src/commands/client/company.ts | 30 +++++++++-- 3 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 cli/src/__tests__/company.test.ts diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index a2074658..9c141a13 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -449,7 +449,7 @@ describe("paperclipai company import/export e2e", () => { ); expect(previewExisting.errors).toEqual([]); - expect(previewExisting.plan.companyAction).toBe("update"); + 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); @@ -474,7 +474,7 @@ describe("paperclipai company import/export e2e", () => { { apiBase, configPath }, ); - expect(importedExisting.company.action).toBe("updated"); + expect(importedExisting.company.action).toBe("unchanged"); expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true); const twiceImportedAgents = await api>( diff --git a/cli/src/__tests__/company.test.ts b/cli/src/__tests__/company.test.ts new file mode 100644 index 00000000..acb05d86 --- /dev/null +++ b/cli/src/__tests__/company.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { resolveCompanyImportApiPath } from "../commands/client/company.js"; + +describe("resolveCompanyImportApiPath", () => { + it("uses company-scoped preview route for existing-company dry runs", () => { + expect( + resolveCompanyImportApiPath({ + dryRun: true, + targetMode: "existing_company", + companyId: "company-123", + }), + ).toBe("/api/companies/company-123/imports/preview"); + }); + + it("uses company-scoped apply route for existing-company imports", () => { + expect( + resolveCompanyImportApiPath({ + dryRun: false, + targetMode: "existing_company", + companyId: "company-123", + }), + ).toBe("/api/companies/company-123/imports/apply"); + }); + + it("keeps global routes for new-company imports", () => { + expect( + resolveCompanyImportApiPath({ + dryRun: true, + targetMode: "new_company", + }), + ).toBe("/api/companies/import/preview"); + + expect( + resolveCompanyImportApiPath({ + dryRun: false, + targetMode: "new_company", + }), + ).toBe("/api/companies/import"); + }); + + it("throws when an existing-company import is missing a company id", () => { + expect(() => + resolveCompanyImportApiPath({ + dryRun: true, + targetMode: "existing_company", + companyId: " ", + }) + ).toThrow(/require a companyId/i); + }); +}); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 17824dbd..9ad14fd5 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -114,6 +114,24 @@ function parseCsvValues(input: string | undefined): string[] { return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean))); } +export function resolveCompanyImportApiPath(input: { + dryRun: boolean; + targetMode: "new_company" | "existing_company"; + companyId?: string | null; +}): string { + if (input.targetMode === "existing_company") { + const companyId = input.companyId?.trim(); + if (!companyId) { + throw new Error("Existing-company imports require a companyId to resolve the API route."); + } + return input.dryRun + ? `/api/companies/${companyId}/imports/preview` + : `/api/companies/${companyId}/imports/apply`; + } + + return input.dryRun ? "/api/companies/import/preview" : "/api/companies/import"; +} + export function isHttpUrl(input: string): boolean { return /^https?:\/\//i.test(input.trim()); } @@ -576,17 +594,19 @@ export function registerCompanyCommands(program: Command): void { agents, collisionStrategy: collision, }; + const importApiPath = resolveCompanyImportApiPath({ + dryRun: Boolean(opts.dryRun), + targetMode: targetPayload.mode, + companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null, + }); if (opts.dryRun) { - const preview = await ctx.api.post( - "/api/companies/import/preview", - payload, - ); + const preview = await ctx.api.post(importApiPath, payload); printOutput(preview, { json: ctx.json }); return; } - const imported = await ctx.api.post("/api/companies/import", payload); + const imported = await ctx.api.post(importApiPath, payload); printOutput(imported, { json: ctx.json }); } catch (err) { handleCommandError(err);