merge: wave 2 executor (01-02) into main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-25 23:46:16 -04:00
1 parent a5b8fcf9d3
commit aa1a78d7d9
1081 files changed
+445341

No files matched your search

@@ -0,0 +1,79 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
ensureAgentJwtSecret,
mergePaperclipEnvEntries,
readAgentJwtSecretFromEnv,
readPaperclipEnvEntries,
resolveAgentJwtEnvFile,
} from "../config/env.js";
import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js";
const ORIGINAL_ENV = { ...process.env };
function tempConfigPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-jwt-env-"));
const configDir = path.join(dir, "custom");
fs.mkdirSync(configDir, { recursive: true });
return path.join(configDir, "config.json");
}
describe("agent jwt env helpers", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("writes .env next to explicit config path", () => {
const configPath = tempConfigPath();
const result = ensureAgentJwtSecret(configPath);
expect(result.created).toBe(true);
const envPath = resolveAgentJwtEnvFile(configPath);
expect(fs.existsSync(envPath)).toBe(true);
const contents = fs.readFileSync(envPath, "utf-8");
expect(contents).toContain("PAPERCLIP_AGENT_JWT_SECRET=");
});
it("loads secret from .env next to explicit config path", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=test-secret\n", { mode: 0o600 });
const loaded = readAgentJwtSecretFromEnv(configPath);
expect(loaded).toBe("test-secret");
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBe("test-secret");
});
it("doctor check passes when secret exists in adjacent .env", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=check-secret\n", { mode: 0o600 });
const result = agentJwtSecretCheck(configPath);
expect(result.status).toBe("pass");
});
it("quotes hash-prefixed env values so dotenv round-trips them", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
mergePaperclipEnvEntries(
{
PAPERCLIP_WORKTREE_COLOR: "#439edb",
},
envPath,
);
const contents = fs.readFileSync(envPath, "utf-8");
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
});
});
@@ -0,0 +1,77 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { PaperclipConfig } from "../config/schema.js";
import { addAllowedHostname } from "../commands/allowed-hostname.js";
function createTempConfigPath() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-allowed-hostname-"));
return path.join(dir, "config.json");
}
function writeBaseConfig(configPath: string) {
const base: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/tmp/paperclip-db",
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: "/tmp/paperclip-backups",
},
},
logging: {
mode: "file",
logDir: "/tmp/paperclip-logs",
},
server: {
deploymentMode: "authenticated",
exposure: "private",
host: "0.0.0.0",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: { baseDir: "/tmp/paperclip-storage" },
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: { keyFilePath: "/tmp/paperclip-secrets/master.key" },
},
};
fs.writeFileSync(configPath, JSON.stringify(base, null, 2));
}
describe("allowed-hostname command", () => {
it("adds and normalizes hostnames", async () => {
const configPath = createTempConfigPath();
writeBaseConfig(configPath);
await addAllowedHostname("https://Dotta-MacBook-Pro:3100", { config: configPath });
await addAllowedHostname("dotta-macbook-pro", { config: configPath });
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")) as PaperclipConfig;
expect(raw.server.allowedHostnames).toEqual(["dotta-macbook-pro"]);
});
});
@@ -0,0 +1,16 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { registerClientAuthCommands } from "../commands/client/auth.js";
describe("registerClientAuthCommands", () => {
it("registers auth commands without duplicate company-id flags", () => {
const program = new Command();
const auth = program.command("auth");
expect(() => registerClientAuthCommands(auth)).not.toThrow();
const login = auth.commands.find((command) => command.name() === "login");
expect(login).toBeDefined();
expect(login?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
});
});
@@ -0,0 +1,53 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
getStoredBoardCredential,
readBoardAuthStore,
removeStoredBoardCredential,
setStoredBoardCredential,
} from "../client/board-auth.js";
function createTempAuthPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-auth-"));
return path.join(dir, "auth.json");
}
describe("board auth store", () => {
it("returns an empty store when the file does not exist", () => {
const authPath = createTempAuthPath();
expect(readBoardAuthStore(authPath)).toEqual({
version: 1,
credentials: {},
});
});
it("stores and retrieves credentials by normalized api base", () => {
const authPath = createTempAuthPath();
setStoredBoardCredential({
apiBase: "http://localhost:3100/",
token: "token-123",
userId: "user-1",
storePath: authPath,
});
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toMatchObject({
apiBase: "http://localhost:3100",
token: "token-123",
userId: "user-1",
});
});
it("removes stored credentials", () => {
const authPath = createTempAuthPath();
setStoredBoardCredential({
apiBase: "http://localhost:3100",
token: "token-123",
storePath: authPath,
});
expect(removeStoredBoardCredential("http://localhost:3100", authPath)).toBe(true);
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toBeNull();
});
});
@@ -0,0 +1,98 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { writeContext } from "../client/context.js";
import { resolveCommandContext } from "../commands/client/common.js";
const ORIGINAL_ENV = { ...process.env };
function createTempPath(name: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-common-"));
return path.join(dir, name);
}
describe("resolveCommandContext", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("uses profile defaults when options/env are not provided", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 1,
currentProfile: "ops",
profiles: {
ops: {
apiBase: "http://127.0.0.1:9999",
companyId: "company-profile",
apiKeyEnvVarName: "AGENT_KEY",
},
},
},
contextPath,
);
process.env.AGENT_KEY = "key-from-env";
const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true });
expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999");
expect(resolved.companyId).toBe("company-profile");
expect(resolved.api.apiKey).toBe("key-from-env");
});
it("prefers explicit options over profile values", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 1,
currentProfile: "default",
profiles: {
default: {
apiBase: "http://profile:3100",
companyId: "company-profile",
},
},
},
contextPath,
);
const resolved = resolveCommandContext(
{
context: contextPath,
apiBase: "http://override:3200",
apiKey: "direct-token",
companyId: "company-override",
},
{ requireCompany: true },
);
expect(resolved.api.apiBase).toBe("http://override:3200");
expect(resolved.companyId).toBe("company-override");
expect(resolved.api.apiKey).toBe("direct-token");
});
it("throws when company is required but unresolved", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 1,
currentProfile: "default",
profiles: { default: {} },
},
contextPath,
);
expect(() =>
resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }),
).toThrow(/Company ID is required/);
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import type { Company } from "@paperclipai/shared";
import { assertDeleteConfirmation, resolveCompanyForDeletion } from "../commands/client/company.js";
function makeCompany(overrides: Partial<Company>): Company {
return {
id: "11111111-1111-1111-1111-111111111111",
name: "Alpha",
description: null,
status: "active",
pauseReason: null,
pausedAt: null,
issuePrefix: "ALP",
issueCounter: 1,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
requireBoardApprovalForNewAgents: false,
brandColor: null,
logoAssetId: null,
logoUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe("resolveCompanyForDeletion", () => {
const companies: Company[] = [
makeCompany({
id: "11111111-1111-1111-1111-111111111111",
name: "Alpha",
issuePrefix: "ALP",
}),
makeCompany({
id: "22222222-2222-2222-2222-222222222222",
name: "Paperclip",
issuePrefix: "PAP",
}),
];
it("resolves by ID in auto mode", () => {
const result = resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "auto");
expect(result.issuePrefix).toBe("PAP");
});
it("resolves by prefix in auto mode", () => {
const result = resolveCompanyForDeletion(companies, "pap", "auto");
expect(result.id).toBe("22222222-2222-2222-2222-222222222222");
});
it("throws when selector is not found", () => {
expect(() => resolveCompanyForDeletion(companies, "MISSING", "auto")).toThrow(/No company found/);
});
it("respects explicit id mode", () => {
expect(() => resolveCompanyForDeletion(companies, "PAP", "id")).toThrow(/No company found by ID/);
});
it("respects explicit prefix mode", () => {
expect(() => resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "prefix"))
.toThrow(/No company found by shortname/);
});
});
describe("assertDeleteConfirmation", () => {
const company = makeCompany({
id: "22222222-2222-2222-2222-222222222222",
issuePrefix: "PAP",
});
it("requires --yes", () => {
expect(() => assertDeleteConfirmation(company, { confirm: "PAP" })).toThrow(/requires --yes/);
});
it("accepts matching prefix confirmation", () => {
expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "pap" })).not.toThrow();
});
it("accepts matching id confirmation", () => {
expect(() =>
assertDeleteConfirmation(company, {
yes: true,
confirm: "22222222-2222-2222-2222-222222222222",
})).not.toThrow();
});
it("rejects mismatched confirmation", () => {
expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "nope" }))
.toThrow(/does not match target company/);
});
});
@@ -0,0 +1,543 @@
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 { createStoredZipArchive } from "./helpers/zip.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
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<typeof spawn>;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
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", "--lc-messages=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;
}
function collectTextFiles(root: string, current: string, files: Record<string, string>) {
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<void>((resolve) => {
child.once("exit", () => resolve());
setTimeout(() => {
if (child.exitCode === null) {
child.kill("SIGKILL");
}
}, 5_000);
});
}
async function api<T>(baseUrl: string, pathname: string, init?: RequestInit): Promise<T> {
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<T>(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 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<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/agents`,
);
const importedProjects = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/projects`,
);
const importedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
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<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/agents`,
);
const twiceImportedProjects = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/projects`,
);
const twiceImportedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
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<string, string> = {};
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);
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
isGithubShorthand,
isGithubUrl,
isHttpUrl,
normalizeGithubImportSource,
} from "../commands/client/company.js";
describe("isHttpUrl", () => {
it("matches http URLs", () => {
expect(isHttpUrl("http://example.com/foo")).toBe(true);
});
it("matches https URLs", () => {
expect(isHttpUrl("https://example.com/foo")).toBe(true);
});
it("rejects local paths", () => {
expect(isHttpUrl("/tmp/my-company")).toBe(false);
expect(isHttpUrl("./relative")).toBe(false);
});
});
describe("isGithubUrl", () => {
it("matches GitHub URLs", () => {
expect(isGithubUrl("https://github.com/org/repo")).toBe(true);
});
it("rejects non-GitHub HTTP URLs", () => {
expect(isGithubUrl("https://example.com/foo")).toBe(false);
});
it("rejects local paths", () => {
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",
);
});
});
@@ -0,0 +1,44 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveInlineSourceFromPath } from "../commands/client/company.js";
import { createStoredZipArchive } from "./helpers/zip.js";
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});
describe("resolveInlineSourceFromPath", () => {
it("imports portable files from a zip archive instead of scanning the parent directory", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-zip-"));
tempDirs.push(tempDir);
const archivePath = path.join(tempDir, "paperclip-demo.zip");
const archive = createStoredZipArchive(
{
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
"notes/todo.txt": "ignore me\n",
},
"paperclip-demo",
);
await writeFile(archivePath, archive);
const resolved = await resolveInlineSourceFromPath(archivePath);
expect(resolved).toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
},
});
});
});
@@ -0,0 +1,587 @@
import { describe, expect, it } from "vitest";
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
import {
buildCompanyDashboardUrl,
buildDefaultImportAdapterOverrides,
buildDefaultImportSelectionState,
buildImportSelectionCatalog,
buildSelectedFilesFromImportSelection,
renderCompanyImportPreview,
renderCompanyImportResult,
resolveCompanyImportApplyConfirmationMode,
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);
});
});
describe("resolveCompanyImportApplyConfirmationMode", () => {
it("skips confirmation when --yes is set", () => {
expect(
resolveCompanyImportApplyConfirmationMode({
yes: true,
interactive: false,
json: false,
}),
).toBe("skip");
});
it("prompts in interactive text mode when --yes is not set", () => {
expect(
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: true,
json: false,
}),
).toBe("prompt");
});
it("requires --yes for non-interactive apply", () => {
expect(() =>
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: false,
json: false,
})
).toThrow(/non-interactive terminal requires --yes/i);
});
it("requires --yes for json apply", () => {
expect(() =>
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: false,
json: true,
})
).toThrow(/with --json requires --yes/i);
});
});
describe("buildCompanyDashboardUrl", () => {
it("preserves the configured base path when building a dashboard URL", () => {
expect(buildCompanyDashboardUrl("https://paperclip.example/app/", "PAP")).toBe(
"https://paperclip.example/app/PAP/dashboard",
);
});
});
describe("renderCompanyImportPreview", () => {
it("summarizes the preview with counts, selection info, and truncated examples", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo", "cto", "eng-1", "eng-2", "eng-3", "eng-4", "eng-5"],
plan: {
companyAction: "update",
agentPlans: [
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
{ slug: "cto", action: "update", plannedName: "CTO", existingAgentId: "agent-2", reason: "replace strategy" },
{ slug: "eng-1", action: "skip", plannedName: "Engineer 1", existingAgentId: "agent-3", reason: "skip strategy" },
{ slug: "eng-2", action: "create", plannedName: "Engineer 2", existingAgentId: null, reason: null },
{ slug: "eng-3", action: "create", plannedName: "Engineer 3", existingAgentId: null, reason: null },
{ slug: "eng-4", action: "create", plannedName: "Engineer 4", existingAgentId: null, reason: null },
{ slug: "eng-5", action: "create", plannedName: "Engineer 5", existingAgentId: null, reason: null },
],
projectPlans: [
{ slug: "alpha", action: "create", plannedName: "Alpha", existingProjectId: null, reason: null },
],
issuePlans: [
{ slug: "kickoff", action: "create", plannedTitle: "Kickoff", reason: null },
],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T17:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
brandColor: null,
logoPath: null,
requireBoardApprovalForNewAgents: false,
},
sidebar: {
agents: ["ceo"],
projects: ["alpha"],
},
agents: [
{
slug: "ceo",
name: "CEO",
path: "agents/ceo/AGENT.md",
skills: [],
role: "ceo",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [
{
key: "skill-a",
slug: "skill-a",
name: "Skill A",
path: "skills/skill-a/SKILL.md",
description: null,
sourceType: "inline",
sourceLocator: null,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [],
},
],
projects: [
{
slug: "alpha",
name: "Alpha",
path: "projects/alpha/PROJECT.md",
description: null,
ownerAgentSlug: null,
leadAgentSlug: null,
targetDate: null,
color: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
],
issues: [
{
slug: "kickoff",
identifier: null,
title: "Kickoff",
path: "projects/alpha/issues/kickoff/TASK.md",
projectSlug: "alpha",
projectWorkspaceKey: null,
assigneeAgentSlug: "ceo",
description: null,
recurring: false,
routine: null,
legacyRecurrence: null,
status: null,
priority: null,
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
metadata: null,
},
],
envInputs: [
{
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
],
},
files: {
"COMPANY.md": "# Source Co",
},
envInputs: [
{
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
],
warnings: ["One warning"],
errors: ["One error"],
};
const rendered = renderCompanyImportPreview(preview, {
sourceLabel: "GitHub: https://github.com/paperclipai/companies/demo",
targetLabel: "Imported Co (company-123)",
infoMessages: ["Using claude-local adapter"],
});
expect(rendered).toContain("Include");
expect(rendered).toContain("company, projects, tasks, agents, skills");
expect(rendered).toContain("7 agents total");
expect(rendered).toContain("1 project total");
expect(rendered).toContain("1 task total");
expect(rendered).toContain("skills: 1 skill packaged");
expect(rendered).toContain("+1 more");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Warnings");
expect(rendered).toContain("Errors");
});
});
describe("renderCompanyImportResult", () => {
it("summarizes import results with created, updated, and skipped counts", () => {
const rendered = renderCompanyImportResult(
{
company: {
id: "company-123",
name: "Imported Co",
action: "updated",
},
agents: [
{ slug: "ceo", id: "agent-1", action: "created", name: "CEO", reason: null },
{ slug: "cto", id: "agent-2", action: "updated", name: "CTO", reason: "replace strategy" },
{ slug: "ops", id: null, action: "skipped", name: "Ops", reason: "skip strategy" },
],
projects: [
{ slug: "app", id: "project-1", action: "created", name: "App", reason: null },
{ slug: "ops", id: "project-2", action: "updated", name: "Operations", reason: "replace strategy" },
{ slug: "archive", id: null, action: "skipped", name: "Archive", reason: "skip strategy" },
],
envInputs: [],
warnings: ["Review API keys"],
},
{
targetLabel: "Imported Co (company-123)",
companyUrl: "https://paperclip.example/PAP/dashboard",
infoMessages: ["Using claude-local adapter"],
},
);
expect(rendered).toContain("Company");
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("Agent results");
expect(rendered).toContain("Project results");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Review API keys");
});
});
describe("import selection catalog", () => {
it("defaults to everything and keeps project selection separate from task selection", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo"],
plan: {
companyAction: "create",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
brandColor: null,
logoPath: "images/company-logo.png",
requireBoardApprovalForNewAgents: false,
},
sidebar: {
agents: ["ceo"],
projects: ["alpha"],
},
agents: [
{
slug: "ceo",
name: "CEO",
path: "agents/ceo/AGENT.md",
skills: [],
role: "ceo",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [
{
key: "skill-a",
slug: "skill-a",
name: "Skill A",
path: "skills/skill-a/SKILL.md",
description: null,
sourceType: "inline",
sourceLocator: null,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [{ path: "skills/skill-a/helper.md", kind: "doc" }],
},
],
projects: [
{
slug: "alpha",
name: "Alpha",
path: "projects/alpha/PROJECT.md",
description: null,
ownerAgentSlug: null,
leadAgentSlug: null,
targetDate: null,
color: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
],
issues: [
{
slug: "kickoff",
identifier: null,
title: "Kickoff",
path: "projects/alpha/issues/kickoff/TASK.md",
projectSlug: "alpha",
projectWorkspaceKey: null,
assigneeAgentSlug: "ceo",
description: null,
recurring: false,
routine: null,
legacyRecurrence: null,
status: null,
priority: null,
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
metadata: null,
},
],
envInputs: [],
},
files: {
"COMPANY.md": "# Source Co",
"README.md": "# Readme",
".paperclip.yaml": "schema: paperclip/v1\n",
"images/company-logo.png": {
encoding: "base64",
data: "",
contentType: "image/png",
},
"projects/alpha/PROJECT.md": "# Alpha",
"projects/alpha/notes.md": "project notes",
"projects/alpha/issues/kickoff/TASK.md": "# Kickoff",
"projects/alpha/issues/kickoff/details.md": "task details",
"agents/ceo/AGENT.md": "# CEO",
"agents/ceo/prompt.md": "prompt",
"skills/skill-a/SKILL.md": "# Skill A",
"skills/skill-a/helper.md": "helper",
},
envInputs: [],
warnings: [],
errors: [],
};
const catalog = buildImportSelectionCatalog(preview);
const state = buildDefaultImportSelectionState(catalog);
expect(state.company).toBe(true);
expect(state.projects.has("alpha")).toBe(true);
expect(state.issues.has("kickoff")).toBe(true);
expect(state.agents.has("ceo")).toBe(true);
expect(state.skills.has("skill-a")).toBe(true);
state.company = false;
state.issues.clear();
state.agents.clear();
state.skills.clear();
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
expect(selectedFiles).toContain(".paperclip.yaml");
expect(selectedFiles).toContain("projects/alpha/PROJECT.md");
expect(selectedFiles).toContain("projects/alpha/notes.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md");
});
});
describe("default adapter overrides", () => {
it("maps process-only imported agents to claude_local", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: false,
agents: true,
projects: false,
issues: false,
skills: false,
},
targetCompanyId: null,
targetCompanyName: null,
collisionStrategy: "rename",
selectedAgentSlugs: ["legacy-agent", "explicit-agent"],
plan: {
companyAction: "none",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:20:00.000Z",
source: null,
includes: {
company: false,
agents: true,
projects: false,
issues: false,
skills: false,
},
company: null,
sidebar: null,
agents: [
{
slug: "legacy-agent",
name: "Legacy Agent",
path: "agents/legacy-agent/AGENT.md",
skills: [],
role: "agent",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
{
slug: "explicit-agent",
name: "Explicit Agent",
path: "agents/explicit-agent/AGENT.md",
skills: [],
role: "agent",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {},
envInputs: [],
warnings: [],
errors: [],
};
expect(buildDefaultImportAdapterOverrides(preview)).toEqual({
"legacy-agent": {
adapterType: "claude_local",
},
});
});
});
@@ -0,0 +1,70 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
defaultClientContext,
readContext,
setCurrentProfile,
upsertProfile,
writeContext,
} from "../client/context.js";
function createTempContextPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-context-"));
return path.join(dir, "context.json");
}
describe("client context store", () => {
it("returns default context when file does not exist", () => {
const contextPath = createTempContextPath();
const context = readContext(contextPath);
expect(context).toEqual(defaultClientContext());
});
it("upserts profile values and switches current profile", () => {
const contextPath = createTempContextPath();
upsertProfile(
"work",
{
apiBase: "http://localhost:3100",
companyId: "company-123",
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
},
contextPath,
);
setCurrentProfile("work", contextPath);
const context = readContext(contextPath);
expect(context.currentProfile).toBe("work");
expect(context.profiles.work).toEqual({
apiBase: "http://localhost:3100",
companyId: "company-123",
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
});
});
it("normalizes invalid file content to safe defaults", () => {
const contextPath = createTempContextPath();
writeContext(
{
version: 1,
currentProfile: "x",
profiles: {
x: {
apiBase: " ",
companyId: " ",
apiKeyEnvVarName: " ",
},
},
},
contextPath,
);
const context = readContext(contextPath);
expect(context.currentProfile).toBe("x");
expect(context.profiles.x).toEqual({});
});
});
@@ -0,0 +1,79 @@
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { applyDataDirOverride } from "../config/data-dir.js";
const ORIGINAL_ENV = { ...process.env };
describe("applyDataDirOverride", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_CONFIG;
delete process.env.PAPERCLIP_CONTEXT;
delete process.env.PAPERCLIP_INSTANCE_ID;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("sets PAPERCLIP_HOME and isolated default config/context paths", () => {
const home = applyDataDirOverride({
dataDir: "~/paperclip-data",
config: undefined,
context: undefined,
}, { hasConfigOption: true, hasContextOption: true });
const expectedHome = path.resolve(os.homedir(), "paperclip-data");
expect(home).toBe(expectedHome);
expect(process.env.PAPERCLIP_HOME).toBe(expectedHome);
expect(process.env.PAPERCLIP_CONFIG).toBe(
path.resolve(expectedHome, "instances", "default", "config.json"),
);
expect(process.env.PAPERCLIP_CONTEXT).toBe(path.resolve(expectedHome, "context.json"));
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("default");
});
it("uses the provided instance id when deriving default config path", () => {
const home = applyDataDirOverride({
dataDir: "/tmp/paperclip-alt",
instance: "dev_1",
config: undefined,
context: undefined,
}, { hasConfigOption: true, hasContextOption: true });
expect(home).toBe(path.resolve("/tmp/paperclip-alt"));
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("dev_1");
expect(process.env.PAPERCLIP_CONFIG).toBe(
path.resolve("/tmp/paperclip-alt", "instances", "dev_1", "config.json"),
);
});
it("does not override explicit config/context settings", () => {
process.env.PAPERCLIP_CONFIG = "/env/config.json";
process.env.PAPERCLIP_CONTEXT = "/env/context.json";
applyDataDirOverride({
dataDir: "/tmp/paperclip-alt",
config: "/flag/config.json",
context: "/flag/context.json",
}, { hasConfigOption: true, hasContextOption: true });
expect(process.env.PAPERCLIP_CONFIG).toBe("/env/config.json");
expect(process.env.PAPERCLIP_CONTEXT).toBe("/env/context.json");
});
it("only applies defaults for options supported by the command", () => {
applyDataDirOverride(
{
dataDir: "/tmp/paperclip-alt",
},
{ hasConfigOption: false, hasContextOption: false },
);
expect(process.env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-alt"));
expect(process.env.PAPERCLIP_CONFIG).toBeUndefined();
expect(process.env.PAPERCLIP_CONTEXT).toBeUndefined();
});
});
@@ -0,0 +1,99 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { doctor } from "../commands/doctor.js";
import { writeConfig } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
function createTempConfig(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
const configPath = path.join(root, ".paperclip", "config.json");
const runtimeRoot = path.join(root, "runtime");
const config: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: "2026-03-10T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
embeddedPostgresPort: 55432,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(runtimeRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(runtimeRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3199,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(runtimeRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
},
},
};
writeConfig(config, configPath);
return configPath;
}
describe("doctor", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
const configPath = createTempConfig();
const summary = await doctor({
config: configPath,
repair: true,
yes: true,
});
expect(summary.failed).toBe(0);
expect(summary.warned).toBe(0);
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
});
});
@@ -0,0 +1,87 @@
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
export function createStoredZipArchive(files: Record<string, string>, rootPath: string) {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const body = encoder.encode(content);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 0);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
entryCount += 1;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entryCount);
writeUint16(archive, offset + 10, entryCount);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}
@@ -0,0 +1,44 @@
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
describeLocalInstancePaths,
expandHomePrefix,
resolvePaperclipHomeDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
const ORIGINAL_ENV = { ...process.env };
describe("home path resolution", () => {
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("defaults to ~/.paperclip and default instance", () => {
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_INSTANCE_ID;
const paths = describeLocalInstancePaths();
expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip"));
expect(paths.instanceId).toBe("default");
expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json"));
});
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
process.env.PAPERCLIP_HOME = "~/paperclip-home";
const home = resolvePaperclipHomeDir();
expect(home).toBe(path.resolve(os.homedir(), "paperclip-home"));
expect(resolvePaperclipInstanceId("dev_1")).toBe("dev_1");
});
it("rejects invalid instance ids", () => {
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/);
});
it("expands ~ prefixes", () => {
expect(expandHomePrefix("~")).toBe(os.homedir());
expect(expandHomePrefix("~/x/y")).toBe(path.resolve(os.homedir(), "x/y"));
});
});
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js";
describe("PaperclipApiClient", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("adds authorization and run-id headers", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({
apiBase: "http://localhost:3100",
apiKey: "token-123",
runId: "run-abc",
});
await client.post("/api/test", { hello: "world" });
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [string, RequestInit];
expect(call[0]).toContain("/api/test");
const headers = call[1].headers as Record<string, string>;
expect(headers.authorization).toBe("Bearer token-123");
expect(headers["x-paperclip-run-id"]).toBe("run-abc");
expect(headers["content-type"]).toBe("application/json");
});
it("returns null on ignoreNotFound", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
);
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
const result = await client.get("/api/missing", { ignoreNotFound: true });
expect(result).toBeNull();
});
it("throws ApiRequestError with details", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ error: "Issue checkout conflict", details: { issueId: "1" } }),
{ status: 409 },
),
);
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
await expect(client.post("/api/issues/1/checkout", {})).rejects.toMatchObject({
status: 409,
message: "Issue checkout conflict",
details: { issueId: "1" },
} satisfies Partial<ApiRequestError>);
});
it("throws ApiConnectionError with recovery guidance when fetch fails", async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError);
await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({
url: "http://localhost:3100/api/companies/import/preview",
method: "POST",
causeMessage: "fetch failed",
} satisfies Partial<ApiConnectionError>);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/Could not reach the Paperclip API\./,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/curl http:\/\/localhost:3100\/api\/health/,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/pnpm dev|pnpm paperclipai run/,
);
});
it("retries once after interactive auth recovery", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Board access required" }), { status: 403 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const recoverAuth = vi.fn().mockResolvedValue("board-token-123");
const client = new PaperclipApiClient({
apiBase: "http://localhost:3100",
recoverAuth,
});
const result = await client.post<{ ok: boolean }>("/api/test", { hello: "world" });
expect(result).toEqual({ ok: true });
expect(recoverAuth).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
const retryHeaders = fetchMock.mock.calls[1]?.[1]?.headers as Record<string, string>;
expect(retryHeaders.authorization).toBe("Bearer board-token-123");
});
});
@@ -0,0 +1,492 @@
import { describe, expect, it } from "vitest";
import { buildWorktreeMergePlan, parseWorktreeMergeScopes } from "../commands/worktree-merge-history-lib.js";
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: "goal-1",
parentId: null,
title: "Issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "local-board",
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeComment(overrides: Record<string, unknown> = {}) {
return {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "local-board",
body: "hello",
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeIssueDocument(overrides: Record<string, unknown> = {}) {
return {
id: "issue-document-1",
companyId: "company-1",
issueId: "issue-1",
documentId: "document-1",
key: "plan",
linkCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
linkUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
title: "Plan",
format: "markdown",
latestBody: "# Plan",
latestRevisionId: "revision-1",
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: "local-board",
updatedByAgentId: null,
updatedByUserId: "local-board",
documentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
documentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeDocumentRevision(overrides: Record<string, unknown> = {}) {
return {
id: "revision-1",
companyId: "company-1",
documentId: "document-1",
revisionNumber: 1,
body: "# Plan",
changeSummary: null,
createdByAgentId: null,
createdByUserId: "local-board",
createdAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeAttachment(overrides: Record<string, unknown> = {}) {
return {
id: "attachment-1",
companyId: "company-1",
issueId: "issue-1",
issueCommentId: null,
assetId: "asset-1",
provider: "local_disk",
objectKey: "company-1/issues/issue-1/2026/03/20/asset.png",
contentType: "image/png",
byteSize: 12,
sha256: "deadbeef",
originalFilename: "asset.png",
createdByAgentId: null,
createdByUserId: "local-board",
assetCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
assetUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
attachmentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
attachmentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeProject(overrides: Record<string, unknown> = {}) {
return {
id: "project-1",
companyId: "company-1",
goalId: null,
name: "Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#22c55e",
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
archivedAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeProjectWorkspace(overrides: Record<string, unknown> = {}) {
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: "https://github.com/example/project.git",
repoRef: "main",
defaultRef: "main",
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: true,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
describe("worktree merge history planner", () => {
it("parses default scopes", () => {
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
expect(parseWorktreeMergeScopes("issues")).toEqual(["issues"]);
});
it("dedupes nested worktree issues by preserved source uuid", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10", title: "Shared" });
const branchOneIssue = makeIssue({
id: "issue-b",
identifier: "PAP-22",
title: "Branch one issue",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const branchTwoIssue = makeIssue({
id: "issue-c",
identifier: "PAP-23",
title: "Branch two issue",
createdAt: new Date("2026-03-20T02:00:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 500,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue, branchOneIssue, branchTwoIssue],
targetIssues: [sharedIssue, branchOneIssue],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.issuesToInsert).toBe(1);
expect(plan.issuePlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual(["issue-c"]);
expect(plan.issuePlans.find((item) => item.source.id === "issue-c" && item.action === "insert")).toMatchObject({
previewIdentifier: "PAP-501",
});
});
it("clears missing references and coerces in_progress without an assignee", () => {
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-x",
identifier: "PAP-99",
status: "in_progress",
assigneeAgentId: "agent-missing",
projectId: "project-missing",
projectWorkspaceId: "workspace-missing",
goalId: "goal-missing",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [],
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetStatus).toBe("todo");
expect(insert.targetAssigneeAgentId).toBeNull();
expect(insert.targetProjectId).toBeNull();
expect(insert.targetProjectWorkspaceId).toBeNull();
expect(insert.targetGoalId).toBeNull();
expect(insert.adjustments).toEqual([
"clear_assignee_agent",
"clear_project",
"clear_project_workspace",
"clear_goal",
"coerce_in_progress_to_todo",
]);
});
it("applies an explicit project mapping override instead of clearing the project", () => {
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-project-map",
identifier: "PAP-77",
projectId: "source-project-1",
projectWorkspaceId: "source-workspace-1",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [{ id: "target-project-1", name: "Mapped project", status: "in_progress" }] as any,
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
projectIdOverrides: {
"source-project-1": "target-project-1",
},
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetProjectId).toBe("target-project-1");
expect(insert.projectResolution).toBe("mapped");
expect(insert.mappedProjectName).toBe("Mapped project");
expect(insert.targetProjectWorkspaceId).toBeNull();
expect(insert.adjustments).toEqual(["clear_project_workspace"]);
});
it("plans selected project imports and preserves project workspace links", () => {
const sourceProject = makeProject({
id: "source-project-1",
name: "Paperclip Evals",
goalId: "goal-1",
});
const sourceWorkspace = makeProjectWorkspace({
id: "source-workspace-1",
projectId: "source-project-1",
cwd: "/Users/dotta/paperclip-evals",
repoUrl: "https://github.com/paperclipai/paperclip-evals.git",
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-project-import",
identifier: "PAP-88",
projectId: "source-project-1",
projectWorkspaceId: "source-workspace-1",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
sourceProjects: [sourceProject],
sourceProjectWorkspaces: [sourceWorkspace],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
importProjectIds: ["source-project-1"],
});
expect(plan.counts.projectsToImport).toBe(1);
expect(plan.projectImports[0]).toMatchObject({
source: { id: "source-project-1", name: "Paperclip Evals" },
targetGoalId: "goal-1",
workspaces: [{ id: "source-workspace-1" }],
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetProjectId).toBe("source-project-1");
expect(insert.targetProjectWorkspaceId).toBe("source-workspace-1");
expect(insert.projectResolution).toBe("imported");
expect(insert.mappedProjectName).toBe("Paperclip Evals");
expect(insert.adjustments).toEqual([]);
});
it("imports comments onto shared or newly imported issues while skipping existing comments", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const newIssue = makeIssue({
id: "issue-b",
identifier: "PAP-11",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const existingComment = makeComment({ id: "comment-existing", issueId: "issue-a" });
const sharedIssueComment = makeComment({ id: "comment-shared", issueId: "issue-a" });
const newIssueComment = makeComment({
id: "comment-new-issue",
issueId: "issue-b",
authorAgentId: "missing-agent",
createdAt: new Date("2026-03-20T01:05:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue, newIssue],
targetIssues: [sharedIssue],
sourceComments: [existingComment, sharedIssueComment, newIssueComment],
targetComments: [existingComment],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.commentsToInsert).toBe(2);
expect(plan.counts.commentsExisting).toBe(1);
expect(plan.commentPlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual([
"comment-shared",
"comment-new-issue",
]);
expect(plan.adjustments.clear_author_agent).toBe(1);
});
it("merges document revisions onto an existing shared document and renumbers conflicts", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const sourceDocument = makeIssueDocument({
issueId: "issue-a",
documentId: "document-a",
latestBody: "# Branch plan",
latestRevisionId: "revision-branch-2",
latestRevisionNumber: 2,
documentUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
linkUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
});
const targetDocument = makeIssueDocument({
issueId: "issue-a",
documentId: "document-a",
latestBody: "# Main plan",
latestRevisionId: "revision-main-2",
latestRevisionNumber: 2,
documentUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
linkUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
});
const sourceRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
const sourceRevisionTwo = makeDocumentRevision({
documentId: "document-a",
id: "revision-branch-2",
revisionNumber: 2,
body: "# Branch plan",
createdAt: new Date("2026-03-20T02:00:00.000Z"),
});
const targetRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
const targetRevisionTwo = makeDocumentRevision({
documentId: "document-a",
id: "revision-main-2",
revisionNumber: 2,
body: "# Main plan",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue],
targetIssues: [sharedIssue],
sourceComments: [],
targetComments: [],
sourceDocuments: [sourceDocument],
targetDocuments: [targetDocument],
sourceDocumentRevisions: [sourceRevisionOne, sourceRevisionTwo],
targetDocumentRevisions: [targetRevisionOne, targetRevisionTwo],
sourceAttachments: [],
targetAttachments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.documentsToMerge).toBe(1);
expect(plan.counts.documentRevisionsToInsert).toBe(1);
expect(plan.documentPlans[0]).toMatchObject({
action: "merge_existing",
latestRevisionId: "revision-branch-2",
latestRevisionNumber: 3,
});
const mergePlan = plan.documentPlans[0] as any;
expect(mergePlan.revisionsToInsert).toHaveLength(1);
expect(mergePlan.revisionsToInsert[0]).toMatchObject({
source: { id: "revision-branch-2" },
targetRevisionNumber: 3,
});
});
it("imports attachments while clearing missing comment and author references", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const attachment = makeAttachment({
issueId: "issue-a",
issueCommentId: "comment-missing",
createdByAgentId: "agent-missing",
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [sharedIssue],
targetIssues: [sharedIssue],
sourceComments: [],
targetComments: [],
sourceDocuments: [],
targetDocuments: [],
sourceDocumentRevisions: [],
targetDocumentRevisions: [],
sourceAttachments: [attachment],
targetAttachments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.attachmentsToInsert).toBe(1);
expect(plan.adjustments.clear_attachment_agent).toBe(1);
expect(plan.attachmentPlans[0]).toMatchObject({
action: "insert",
targetIssueCommentId: null,
targetCreatedByAgentId: null,
});
});
});
@@ -0,0 +1,510 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
copyGitHooksToWorktreeGitDir,
copySeededSecretsKey,
readSourceAttachmentBody,
rebindWorkspaceCwd,
resolveSourceConfigPath,
resolveGitWorktreeAddArgs,
resolveWorktreeMakeTargetPath,
worktreeInitCommand,
worktreeMakeCommand,
} from "../commands/worktree.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
formatShellExports,
generateWorktreeColor,
resolveWorktreeSeedPlan,
resolveWorktreeLocalPaths,
rewriteLocalUrlPort,
sanitizeWorktreeInstanceId,
} from "../commands/worktree-lib.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_ENV = { ...process.env };
afterEach(() => {
process.chdir(ORIGINAL_CWD);
for (const key of Object.keys(process.env)) {
if (!(key in ORIGINAL_ENV)) delete process.env[key];
}
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function buildSourceConfig(): PaperclipConfig {
return {
$meta: {
version: 1,
updatedAt: "2026-03-09T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/tmp/main/db",
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: "/tmp/main/backups",
},
},
logging: {
mode: "file",
logDir: "/tmp/main/logs",
},
server: {
deploymentMode: "authenticated",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: ["localhost"],
serveUi: true,
},
auth: {
baseUrlMode: "explicit",
publicBaseUrl: "http://127.0.0.1:3100",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: "/tmp/main/storage",
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: "/tmp/main/secrets/master.key",
},
},
};
}
describe("worktree helpers", () => {
it("sanitizes instance ids", () => {
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
});
it("resolves worktree:make target paths under the user home directory", () => {
expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe(
path.resolve(os.homedir(), "paperclip-pr-432"),
);
});
it("rejects worktree:make names that are not safe directory/branch names", () => {
expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow(
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
);
});
it("builds git worktree add args for new and existing branches", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "feature-branch",
targetPath: "/tmp/feature-branch",
branchExists: false,
}),
).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]);
expect(
resolveGitWorktreeAddArgs({
branchName: "feature-branch",
targetPath: "/tmp/feature-branch",
branchExists: true,
}),
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
});
it("builds git worktree add args with a start point", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "my-worktree",
targetPath: "/tmp/my-worktree",
branchExists: false,
startPoint: "public-gh/master",
}),
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
});
it("uses start point even when a local branch with the same name exists", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "my-worktree",
targetPath: "/tmp/my-worktree",
branchExists: true,
startPoint: "origin/main",
}),
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
});
it("rewrites loopback auth URLs to the new port only", () => {
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
});
it("builds isolated config and env paths for a worktree", () => {
const paths = resolveWorktreeLocalPaths({
cwd: "/tmp/paperclip-feature",
homeDir: "/tmp/paperclip-worktrees",
instanceId: "feature-worktree-support",
});
const config = buildWorktreeConfig({
sourceConfig: buildSourceConfig(),
paths,
serverPort: 3110,
databasePort: 54339,
now: new Date("2026-03-09T12:00:00.000Z"),
});
expect(config.database.embeddedPostgresDataDir).toBe(
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
);
expect(config.database.embeddedPostgresPort).toBe(54339);
expect(config.server.port).toBe(3110);
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
expect(config.storage.localDisk.baseDir).toBe(
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
);
const env = buildWorktreeEnvEntries(paths, {
name: "feature-worktree-support",
color: "#3abf7a",
});
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
});
it("falls back across storage roots before skipping a missing attachment object", async () => {
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
const expected = Buffer.from("image-bytes");
await expect(
readSourceAttachmentBody(
[
{
getObject: vi.fn().mockRejectedValue(missingErr),
},
{
getObject: vi.fn().mockResolvedValue(expected),
},
],
"company-1",
"company-1/issues/issue-1/missing.png",
),
).resolves.toEqual(expected);
});
it("returns null when an attachment object is missing from every lookup storage", async () => {
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
await expect(
readSourceAttachmentBody(
[
{
getObject: vi.fn().mockRejectedValue(missingErr),
},
{
getObject: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { status: 404 })),
},
],
"company-1",
"company-1/issues/issue-1/missing.png",
),
).resolves.toBeNull();
});
it("generates vivid worktree colors as hex", () => {
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
});
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
const minimal = resolveWorktreeSeedPlan("minimal");
const full = resolveWorktreeSeedPlan("full");
expect(minimal.excludedTables).toContain("heartbeat_runs");
expect(minimal.excludedTables).toContain("heartbeat_run_events");
expect(minimal.excludedTables).toContain("workspace_runtime_services");
expect(minimal.excludedTables).toContain("agent_task_sessions");
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
expect(full.excludedTables).toEqual([]);
expect(full.nullifyColumns).toEqual({});
});
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
try {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
const sourceConfig = buildSourceConfig();
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
copySeededSecretsKey({
sourceConfigPath,
sourceConfig,
sourceEnvEntries: {},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
} finally {
if (originalInlineMasterKey === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey;
}
if (originalKeyFile === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("writes the source inline secrets master key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
try {
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
copySeededSecretsKey({
sourceConfigPath,
sourceConfig: buildSourceConfig(),
sourceEnvEntries: {
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("persists the current agent jwt secret into the worktree env file", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
const repoRoot = path.join(tempRoot, "repo");
const originalCwd = process.cwd();
const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
try {
fs.mkdirSync(repoRoot, { recursive: true });
process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret";
process.chdir(repoRoot);
await worktreeInitCommand({
seed: false,
fromConfig: path.join(tempRoot, "missing", "config.json"),
home: path.join(tempRoot, ".paperclip-worktrees"),
});
const envPath = path.join(repoRoot, ".paperclip", ".env");
const envContents = fs.readFileSync(envPath, "utf8");
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/);
} finally {
process.chdir(originalCwd);
if (originalJwtSecret === undefined) {
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
} else {
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("defaults the seed source config to the current repo-local Paperclip config", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
const repoRoot = path.join(tempRoot, "repo");
const localConfigPath = path.join(repoRoot, ".paperclip", "config.json");
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(path.dirname(localConfigPath), { recursive: true });
fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
delete process.env.PAPERCLIP_CONFIG;
process.chdir(repoRoot);
expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath));
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("preserves the source config path across worktree:make cwd changes", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-"));
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const targetRoot = path.join(tempRoot, "target");
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
delete process.env.PAPERCLIP_CONFIG;
process.chdir(targetRoot);
expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe(
path.resolve(sourceConfigPath),
);
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rebinds same-repo workspace paths onto the current worktree root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip",
}),
).toBe("/Users/example/paperclip-pr-432");
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip/packages/db",
}),
).toBe("/Users/example/paperclip-pr-432/packages/db");
});
it("does not rebind paths outside the source repo root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/other-project",
}),
).toBeNull();
});
it("copies shared git hooks into a linked worktree git dir", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-"));
const repoRoot = path.join(tempRoot, "repo");
const worktreePath = path.join(tempRoot, "repo-feature");
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
const sourceHooksDir = path.join(repoRoot, ".git", "hooks");
const sourceHookPath = path.join(sourceHooksDir, "pre-commit");
const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt");
fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
fs.chmodSync(sourceHookPath, 0o755);
fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8");
execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" });
const copied = copyGitHooksToWorktreeGitDir(worktreePath);
const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
cwd: worktreePath,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir);
const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks"));
const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit");
const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt");
expect(copied).toMatchObject({
sourceHooksPath: resolvedSourceHooksDir,
targetHooksPath: resolvedTargetHooksDir,
copied: true,
});
expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n");
expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0);
expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n");
} finally {
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
const repoRoot = path.join(tempRoot, "repo");
const fakeHome = path.join(tempRoot, "home");
const worktreePath = path.join(fakeHome, "paperclip-make-test");
const originalCwd = process.cwd();
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
try {
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(fakeHome, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
process.chdir(repoRoot);
await worktreeMakeCommand("paperclip-make-test", {
seed: false,
home: path.join(tempRoot, ".paperclip-worktrees"),
});
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
} finally {
process.chdir(originalCwd);
homedirSpy.mockRestore();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
});