feat(cli): add isolated test-drive command (#12894)

Add a foreground-only test-drive workflow with isolated data, provider-backed CEO bootstrap, OpenCode/OpenRouter support, worktree execution setup, reuse safeguards, and delayed browser opening.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-05 09:33:38 -05:00
1 parent 5da6499860
commit 8f0c1d4548
15 files changed
+1470 -129

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
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 } from "vitest";
import { detectGitWorkspaceInfo, isLinkedGitWorktree } from "../commands/git-workspace.js";
const cleanupDirectories: string[] = [];
afterEach(() => {
while (cleanupDirectories.length > 0) {
fs.rmSync(cleanupDirectories.pop()!, { recursive: true, force: true });
}
});
describe("Git worktree detection", () => {
it("distinguishes a linked worktree from the primary checkout and non-Git paths", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-git-workspace-"));
cleanupDirectories.push(root);
const primary = path.join(root, "primary");
const linked = path.join(root, "linked");
fs.mkdirSync(primary);
execFileSync("git", ["init"], { cwd: primary, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: primary });
execFileSync("git", ["config", "user.name", "Paperclip Test"], { cwd: primary });
fs.writeFileSync(path.join(primary, "README.md"), "test\n");
execFileSync("git", ["add", "README.md"], { cwd: primary });
execFileSync("git", ["commit", "-m", "initial"], { cwd: primary, stdio: "ignore" });
execFileSync("git", ["worktree", "add", "-b", "linked-test", linked], {
cwd: primary,
stdio: "ignore",
});
const primaryInfo = detectGitWorkspaceInfo(primary);
const linkedInfo = detectGitWorkspaceInfo(linked);
expect(primaryInfo?.gitDir).toBe(primaryInfo?.commonDir);
expect(linkedInfo?.gitDir).not.toBe(linkedInfo?.commonDir);
expect(isLinkedGitWorktree(primary)).toBe(false);
expect(isLinkedGitWorktree(linked)).toBe(true);
expect(detectGitWorkspaceInfo(root)).toBeNull();
expect(isLinkedGitWorktree(root)).toBe(false);
});
});
+514
View File
@@ -0,0 +1,514 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Agent, Company, InstanceExperimentalSettings } from "@paperclipai/shared";
import {
assertTestDriveDatabaseIsolation,
bootstrapTestDrive,
prepareTestDriveEnvironment,
reconcileTestDriveWorktreeExecution,
redactTestDriveText,
resolveTestDriveBootstrap,
resolveTestDriveDataDir,
testDriveCommand,
type TestDriveApi,
type TestDriveHarness,
} from "../commands/test-drive.js";
import type { RunOptions, StartedServer } from "../commands/run.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
const cleanupDirectories: string[] = [];
function company(id = "company-1", name = "Test Company"): Company {
return { id, name } as Company;
}
function agent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "CEO",
role: "ceo",
adapterType: "claude_local",
adapterConfig: {},
...overrides,
} as Agent;
}
function settings(overrides: Partial<InstanceExperimentalSettings> = {}): InstanceExperimentalSettings {
return {
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
...overrides,
} as InstanceExperimentalSettings;
}
function freshBootstrapApi(input?: { failAgent?: boolean }) {
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
const api = {
get: vi.fn(async <T>(requestPath: string) => {
calls.push({ method: "GET", path: requestPath });
return [] as T;
}),
post: vi.fn(async <T>(requestPath: string, body?: unknown) => {
calls.push({ method: "POST", path: requestPath, body });
if (requestPath === "/api/companies") return company() as T;
if (requestPath.endsWith("/agents")) {
if (input?.failAgent) throw new Error("agent setup failed");
const payload = body as { name: string; adapterType: Agent["adapterType"]; adapterConfig: Record<string, unknown> };
return agent({
name: payload.name,
adapterType: payload.adapterType,
adapterConfig: payload.adapterConfig,
}) as T;
}
return { ok: true } as T;
}),
patch: vi.fn(async <T>() => ({ ok: true }) as T),
delete: vi.fn(async <T>(requestPath: string) => {
calls.push({ method: "DELETE", path: requestPath });
return { ok: true } as T;
}),
} as TestDriveApi;
return { api, calls };
}
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
while (cleanupDirectories.length > 0) {
fs.rmSync(cleanupDirectories.pop()!, { recursive: true, force: true });
}
vi.restoreAllMocks();
});
describe("test-drive data isolation", () => {
it("creates unique retained OS temporary directories and reports absolute paths", () => {
const first = resolveTestDriveDataDir();
const second = resolveTestDriveDataDir();
cleanupDirectories.push(first, second);
expect(path.isAbsolute(first)).toBe(true);
expect(path.dirname(first)).toBe(os.tmpdir());
expect(first).not.toBe(second);
expect(fs.existsSync(first)).toBe(true);
expect(fs.existsSync(second)).toBe(true);
});
it("resolves an explicit reusable directory without resetting it", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-explicit-"));
cleanupDirectories.push(root);
const marker = path.join(root, "keep.txt");
fs.writeFileSync(marker, "keep");
expect(resolveTestDriveDataDir(root)).toBe(path.resolve(root));
expect(fs.readFileSync(marker, "utf8")).toBe("keep");
});
it("discards inherited Paperclip routing while preserving a custom key source", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-env-"));
cleanupDirectories.push(root);
process.env.PAPERCLIP_HOME = "/normal/home";
process.env.PAPERCLIP_CONFIG = "/normal/config.json";
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_TEST_PROVIDER_KEY = "secret-value";
process.env.DATABASE_URL = "postgres://normal-instance";
const prepared = await prepareTestDriveEnvironment(
{ dataDir: root, apiKeyEnv: "PAPERCLIP_TEST_PROVIDER_KEY" },
os.tmpdir(),
);
expect(prepared.dataDir).toBe(path.resolve(root));
expect(prepared.linkedWorktree).toBe(false);
expect(process.env.PAPERCLIP_HOME).toBe(path.resolve(root));
expect(process.env.PAPERCLIP_CONFIG).toBe(
path.join(path.resolve(root), "instances", "default", "config.json"),
);
expect(process.env.PAPERCLIP_IN_WORKTREE).toBe("false");
expect(process.env.PAPERCLIP_DISABLE_CWD_ENV_FILE).toBe("true");
expect(process.env.PAPERCLIP_DEPLOYMENT_MODE).toBe("local_trusted");
expect(process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE).toBe("private");
expect(process.env.PAPERCLIP_BIND).toBe("loopback");
expect(process.env.HOST).toBe("127.0.0.1");
expect(process.env.PAPERCLIP_TEST_PROVIDER_KEY).toBe("secret-value");
expect(process.env.DATABASE_URL).toBeUndefined();
expect(Number(process.env.PORT)).toBeGreaterThanOrEqual(3100);
});
it.each(["DATABASE_URL", "DATABASE_MIGRATION_URL"])(
"rejects %s loaded from the isolated directory",
(variable) => {
const readConfigFile = vi.fn(() => null);
expect(() => assertTestDriveDatabaseIsolation(
undefined,
{ [variable]: "postgres://external-database" },
readConfigFile,
)).toThrow(/requires its isolated embedded database/);
expect(readConfigFile).not.toHaveBeenCalled();
},
);
it("rejects an explicitly reused PostgreSQL configuration", () => {
const externalConfig = {
database: { mode: "postgres" },
} as PaperclipConfig;
expect(() => assertTestDriveDatabaseIsolation(
"/tmp/reused/config.json",
{},
() => externalConfig,
)).toThrow(/cannot reuse.*external PostgreSQL database/);
});
it("accepts an embedded configuration", () => {
const embeddedConfig = {
database: { mode: "embedded-postgres" },
} as PaperclipConfig;
expect(() => assertTestDriveDatabaseIsolation(
"/tmp/reused/config.json",
{},
() => embeddedConfig,
)).not.toThrow();
});
});
describe("test-drive bootstrap validation", () => {
it("uses Claude defaults and the canonical environment credential", () => {
const resolved = resolveTestDriveBootstrap({}, { ANTHROPIC_API_KEY: "anthropic-secret" });
expect(resolved).toMatchObject({
companyName: "Test Company",
agentName: "CEO",
adapterType: "claude_local",
credentialTarget: "ANTHROPIC_API_KEY",
credential: "anthropic-secret",
});
expect(resolved.model).toBeUndefined();
});
it("maps all harnesses and leaves Claude/Codex models optional", () => {
const cases: Array<[TestDriveHarness, string, string]> = [
["claude", "claude_local", "ANTHROPIC_API_KEY"],
["codex", "codex_local", "OPENAI_API_KEY"],
["opencode", "opencode_local", "OPENROUTER_API_KEY"],
];
for (const [harness, adapterType, credentialTarget] of cases) {
const resolved = resolveTestDriveBootstrap(
{
harness,
...(harness === "opencode" ? { model: "openrouter/anthropic/claude-sonnet-4.5" } : {}),
},
{ [credentialTarget]: "provider-secret" },
);
expect(resolved.adapterType).toBe(adapterType);
expect(resolved.credentialTarget).toBe(credentialTarget);
}
});
it("requires an OpenRouter OpenCode model and preserves every model path segment", () => {
expect(() => resolveTestDriveBootstrap(
{ harness: "opencode" },
{ OPENROUTER_API_KEY: "secret" },
)).toThrow(/require --model openrouter/);
for (const model of ["anthropic/claude", "openrouter/", "openrouter//claude", "openrouter/a/"]) {
expect(() => resolveTestDriveBootstrap(
{ harness: "opencode", model },
{ OPENROUTER_API_KEY: "secret" },
)).toThrow(/require --model openrouter/);
}
const model = "openrouter/publisher/family/model";
expect(resolveTestDriveBootstrap(
{ harness: "opencode", model },
{ OPENROUTER_API_KEY: "secret" },
).model).toBe(model);
});
it("supports custom source variables while retaining the canonical target", () => {
const resolved = resolveTestDriveBootstrap(
{
harness: "opencode",
model: "openrouter/anthropic/claude-sonnet-4.5",
apiKeyEnv: "MY_OPENROUTER_KEY",
},
{ MY_OPENROUTER_KEY: "custom-secret" },
);
expect(resolved.credential).toBe("custom-secret");
expect(resolved.credentialSource).toBe("MY_OPENROUTER_KEY");
expect(resolved.credentialTarget).toBe("OPENROUTER_API_KEY");
});
it("rejects invalid key variable names and redacts credentials", () => {
expect(() => resolveTestDriveBootstrap({
apiKeyEnv: "NOT-A-VALID-NAME",
}, { ANTHROPIC_API_KEY: "env-secret" })).toThrow(/valid environment variable/);
expect(redactTestDriveText(
"custom-secret and env-secret must never appear",
["custom-secret", "env-secret"],
)).toBe("[REDACTED] and [REDACTED] must never appear");
});
});
describe("test-drive API bootstrap", () => {
it.each([
["claude", "claude_local", "ANTHROPIC_API_KEY", undefined],
["codex", "codex_local", "OPENAI_API_KEY", undefined],
["opencode", "opencode_local", "OPENROUTER_API_KEY", "openrouter/anthropic/claude-sonnet-4.5"],
] as const)("creates exactly one company and one CEO for %s", async (
harness,
adapterType,
credentialTarget,
model,
) => {
const { api, calls } = freshBootstrapApi();
const result = await bootstrapTestDrive({
api,
options: { harness, ...(model ? { model } : {}) },
linkedWorktree: false,
instanceId: "default",
env: { [credentialTarget]: "secret" },
});
expect(result.reused).toBe(false);
expect(result.agent?.role).toBe("ceo");
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([
"GET /api/companies",
"POST /api/companies",
"POST /api/companies/company-1/user-secret-definitions",
"POST /api/companies/company-1/me/user-secrets",
"POST /api/companies/company-1/agents",
]);
const secretValueCall = calls.find((call) => call.path.endsWith("/me/user-secrets"));
expect(secretValueCall?.body).toEqual({ definitionKey: credentialTarget, value: "secret" });
const agentCall = calls.find((call) => call.path.endsWith("/agents"));
expect(agentCall?.body).toEqual({
name: "CEO",
role: "ceo",
adapterType,
adapterConfig: {
...(model ? { model } : {}),
env: {
[credentialTarget]: {
type: "user_secret_ref",
key: credentialTarget,
version: "latest",
required: true,
},
},
},
});
expect(calls.some((call) => /issues|projects|goals|tasks|heartbeat/.test(call.path))).toBe(false);
});
it("rejects invalid OpenCode configuration before creating a company", async () => {
const { api, calls } = freshBootstrapApi();
await expect(bootstrapTestDrive({
api,
options: { harness: "opencode" },
linkedWorktree: false,
instanceId: "default",
env: { OPENROUTER_API_KEY: "secret" },
})).rejects.toThrow(/require --model openrouter/);
expect(calls).toEqual([{ method: "GET", path: "/api/companies" }]);
});
it("preserves seeded data and ignores every bootstrap flag", async () => {
const get = vi.fn(async <T>(requestPath: string) => {
if (requestPath === "/api/companies") return [company("existing", "Existing Company")] as T;
throw new Error(`Unexpected GET ${requestPath}`);
});
const api = {
get,
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
const result = await bootstrapTestDrive({
api,
options: { harness: "opencode", companyName: "Ignored" },
linkedWorktree: false,
instanceId: "default",
env: {},
});
expect(result).toMatchObject({ reused: true, company: { id: "existing" }, agent: null });
expect(api.post).not.toHaveBeenCalled();
expect(api.patch).not.toHaveBeenCalled();
expect(api.delete).not.toHaveBeenCalled();
});
it("deletes only the newly-created company when fresh bootstrap fails", async () => {
const { api, calls } = freshBootstrapApi({ failAgent: true });
await expect(bootstrapTestDrive({
api,
options: {},
linkedWorktree: false,
instanceId: "default",
env: { ANTHROPIC_API_KEY: "secret" },
})).rejects.toThrow("agent setup failed");
expect(calls.at(-1)).toEqual({ method: "DELETE", path: "/api/companies/company-1" });
});
});
describe("test-drive worktree setting reconciliation", () => {
function worktreeApi(initial: InstanceExperimentalSettings, instanceId = "test-instance") {
let current = initial;
const patchBodies: unknown[] = [];
const api = {
get: vi.fn(async <T>() => current as T),
patch: vi.fn(async <T>(_path: string, body?: unknown) => {
patchBodies.push(body);
const enabled = (body as { enableWorktreeRunExecution: boolean }).enableWorktreeRunExecution;
current = settings({
...current,
enableWorktreeRunExecution: enabled,
worktreeRunExecutionActivatedAt: enabled ? "2026-09-05T12:00:00.000Z" : null,
worktreeRunExecutionActivationInstanceId: enabled ? instanceId : null,
});
return current as T;
}),
post: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
return { api, patchBodies };
}
it("enables a disabled setting", async () => {
const { api, patchBodies } = worktreeApi(settings());
await reconcileTestDriveWorktreeExecution(api, "test-instance");
expect(patchBodies).toEqual([{ enableWorktreeRunExecution: true }]);
});
it("leaves a correctly armed setting unchanged", async () => {
const { api, patchBodies } = worktreeApi(settings({
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-09-05T11:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "test-instance",
}));
await reconcileTestDriveWorktreeExecution(api, "test-instance");
expect(patchBodies).toEqual([]);
});
it.each([
settings({ enableWorktreeRunExecution: true }),
settings({
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-09-05T11:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "another-instance",
}),
])("rearms missing or mismatched activation metadata", async (initial) => {
const { api, patchBodies } = worktreeApi(initial);
await reconcileTestDriveWorktreeExecution(api, "test-instance");
expect(patchBodies).toEqual([
{ enableWorktreeRunExecution: false },
{ enableWorktreeRunExecution: true },
]);
});
it("fails when the setting cannot be armed for this instance", async () => {
const api = {
get: vi.fn(async <T>() => settings() as T),
patch: vi.fn(async <T>() => settings() as T),
post: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
await expect(reconcileTestDriveWorktreeExecution(api, "test-instance"))
.rejects.toThrow(/Could not arm/);
});
});
describe("test-drive foreground lifecycle", () => {
const server: StartedServer = {
apiUrl: "http://127.0.0.1:3100/api",
databaseUrl: "postgres://embedded",
host: "127.0.0.1",
listenPort: 3100,
};
it("skips service-manager integration for an auto-created directory and opens after initialization", async () => {
process.env.PAPERCLIP_HOME = "/tmp/test-drive-lifecycle";
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_IN_WORKTREE = "false";
process.env.ANTHROPIC_API_KEY = "secret";
const events: string[] = [];
let runOptions: RunOptions | undefined;
const api = {
get: vi.fn(async <T>() => {
events.push("initialized");
return [company("existing", "Existing")] as T;
}),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
await testDriveCommand({}, {
run: async (options) => {
runOptions = options;
events.push("listening");
await options.afterStart?.(server);
},
createApi: () => api,
openBrowser: async () => {
events.push("browser");
return true;
},
});
expect(runOptions).toMatchObject({
yes: true,
bind: "loopback",
installService: false,
skipServiceManagerCheck: true,
introLabel: "paperclipai test-drive",
});
expect(events).toEqual(["listening", "initialized", "browser"]);
});
it("retains the managed-instance collision guard for an explicitly reused directory", async () => {
process.env.PAPERCLIP_HOME = "/tmp/test-drive-reused";
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_IN_WORKTREE = "false";
let runOptions: RunOptions | undefined;
const api = {
get: vi.fn(async <T>() => [company("existing", "Existing")] as T),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
await testDriveCommand({ dataDir: "/tmp/test-drive-reused", browser: false }, {
run: async (options) => {
runOptions = options;
await options.afterStart?.(server);
},
createApi: () => api,
openBrowser: vi.fn(async () => true),
});
expect(runOptions?.skipServiceManagerCheck).toBe(false);
});
it("honors --no-browser after successful initialization", async () => {
process.env.PAPERCLIP_HOME = "/tmp/test-drive-no-browser";
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_IN_WORKTREE = "false";
const api = {
get: vi.fn(async <T>() => [company("existing", "Existing")] as T),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
const openBrowser = vi.fn(async () => true);
await testDriveCommand({ browser: false }, {
run: async (options) => options.afterStart?.(server),
createApi: () => api,
openBrowser,
});
expect(openBrowser).not.toHaveBeenCalled();
});
});
+51
View File
@@ -0,0 +1,51 @@
import path from "node:path";
import { execFileSync } from "node:child_process";
export type GitWorkspaceInfo = {
root: string;
commonDir: string;
gitDir: string;
hooksPath: string;
};
/**
* Resolve the repository metadata Git exposes for both primary checkouts and
* linked worktrees. Returns null outside a Git working tree.
*/
export function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
try {
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return {
root: path.resolve(root),
commonDir: path.resolve(root, commonDirRaw),
gitDir: path.resolve(root, gitDirRaw),
hooksPath: path.resolve(root, hooksPathRaw),
};
} catch {
return null;
}
}
export function isLinkedGitWorktree(cwd: string): boolean {
const workspace = detectGitWorkspaceInfo(cwd);
return Boolean(workspace && workspace.gitDir !== workspace.commonDir);
}
+32 -6
View File
@@ -21,26 +21,37 @@ import { removeRuntimeInfoForPid, writeRuntimeInfo } from "../runtime-info.js";
import { printUpdateNotice } from "../update-notice.js";
import { ensureWorktreeSeeded } from "./worktree.js";
interface RunOptions {
export interface RunOptions {
config?: string;
instance?: string;
repair?: boolean;
yes?: boolean;
bind?: "loopback" | "lan" | "tailnet";
force?: boolean;
/** Internal lifecycle option used by foreground-only commands. */
installService?: boolean;
/** Internal lifecycle option for isolated instances that cannot collide with a managed service. */
skipServiceManagerCheck?: boolean;
/** Internal label override for commands that reuse the foreground run path. */
introLabel?: string;
/** Runs after the server is listening and all normal post-start initialization has completed. */
afterStart?: (server: StartedServer) => Promise<void>;
}
interface StartedServer {
export interface StartedServer {
apiUrl: string;
databaseUrl: string;
host: string;
listenPort: number;
shutdown?: (signal?: "SIGINT" | "SIGTERM") => Promise<void>;
}
export async function runCommand(opts: RunOptions): Promise<void> {
const instanceId = resolvePaperclipInstanceId(opts.instance);
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
await assertForegroundRunAllowed(instanceId, opts.force);
if (!opts.skipServiceManagerCheck) {
await assertForegroundRunAllowed(instanceId, opts.force);
}
const homeDir = resolvePaperclipHomeDir();
fs.mkdirSync(homeDir, { recursive: true });
@@ -53,20 +64,26 @@ export async function runCommand(opts: RunOptions): Promise<void> {
loadPaperclipEnvFile(configPath);
await printUpdateNotice(configPath);
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
p.intro(pc.bgCyan(pc.black(` ${opts.introLabel ?? "paperclipai run"} `)));
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
p.log.message(pc.dim(`Instance: ${paths.instanceId}`));
p.log.message(pc.dim(`Config: ${configPath}`));
if (!configExists(configPath)) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
if ((!process.stdin.isTTY || !process.stdout.isTTY) && !opts.yes) {
p.log.error("No config found and terminal is non-interactive.");
p.log.message(`Run ${pc.cyan("paperclipai onboard")} once, then retry ${pc.cyan("paperclipai run")}.`);
process.exit(1);
}
p.log.step("No config found. Starting onboarding...");
await onboard({ config: configPath, invokedByRun: true, bind: opts.bind });
await onboard({
config: configPath,
invokedByRun: true,
bind: opts.bind,
yes: opts.yes,
installService: opts.installService,
});
}
const seedResult = await ensureWorktreeSeeded({ config: configPath });
@@ -113,6 +130,15 @@ export async function runCommand(opts: RunOptions): Promise<void> {
baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer),
});
}
if (opts.afterStart) {
try {
await opts.afterStart(startedServer);
} catch (error) {
await startedServer.shutdown?.("SIGTERM");
throw error;
}
}
}
function resolveBootstrapInviteBaseUrl(
+466
View File
@@ -0,0 +1,466 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createServer } from "node:net";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { Option, type Command } from "commander";
import type { Agent, Company, InstanceExperimentalSettings } from "@paperclipai/shared";
import { PaperclipApiClient } from "../client/http.js";
import { openUrl } from "../client/board-auth.js";
import {
expandHomePrefix,
resolveDefaultConfigPath,
resolveDefaultContextPath,
} from "../config/home.js";
import { readConfig } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
import { runCommand, type StartedServer } from "./run.js";
import { isLinkedGitWorktree } from "./git-workspace.js";
export const TEST_DRIVE_HARNESSES = ["claude", "codex", "opencode"] as const;
export type TestDriveHarness = (typeof TEST_DRIVE_HARNESSES)[number];
export interface TestDriveOptions {
dataDir?: string;
companyName?: string;
agentName?: string;
harness?: TestDriveHarness;
model?: string;
apiKeyEnv?: string;
browser?: boolean;
}
export type TestDriveApi = Pick<PaperclipApiClient, "get" | "post" | "patch" | "delete">;
type HarnessDefinition = {
adapterType: "claude_local" | "codex_local" | "opencode_local";
credentialTarget: "ANTHROPIC_API_KEY" | "OPENAI_API_KEY" | "OPENROUTER_API_KEY";
credentialName: string;
};
export type ResolvedTestDriveBootstrap = HarnessDefinition & {
companyName: string;
agentName: string;
model?: string;
credential: string;
credentialSource: string;
};
export type TestDriveBootstrapResult = {
reused: boolean;
company: Company;
agent: Agent | null;
};
export interface TestDriveDependencies {
run: typeof runCommand;
createApi: (apiBase: string) => TestDriveApi;
openBrowser: (url: string) => Promise<boolean>;
}
const HARNESS_DEFINITIONS: Record<TestDriveHarness, HarnessDefinition> = {
claude: {
adapterType: "claude_local",
credentialTarget: "ANTHROPIC_API_KEY",
credentialName: "Anthropic API Key",
},
codex: {
adapterType: "codex_local",
credentialTarget: "OPENAI_API_KEY",
credentialName: "OpenAI API Key",
},
opencode: {
adapterType: "opencode_local",
credentialTarget: "OPENROUTER_API_KEY",
credentialName: "OpenRouter API Key",
},
};
const NON_PAPERCLIP_ISOLATED_ENV_KEYS = [
"DATABASE_URL",
"DATABASE_MIGRATION_URL",
"HOST",
"PORT",
"SERVE_UI",
"BETTER_AUTH_URL",
"BETTER_AUTH_BASE_URL",
] as const;
function requiredApiResult<T>(value: T | null, action: string): T {
if (value === null) {
throw new Error(`Paperclip returned no result while ${action}.`);
}
return value;
}
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message || error.name;
if (typeof error === "string") return error;
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
export function redactTestDriveText(text: string, credentials: Array<string | undefined>): string {
let redacted = text;
for (const credential of credentials) {
if (!credential) continue;
redacted = redacted.replaceAll(credential, "[REDACTED]");
}
return redacted;
}
export function resolveTestDriveDataDir(dataDir?: string): string {
const explicit = dataDir?.trim();
if (explicit) {
return path.resolve(expandHomePrefix(explicit));
}
return fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-"));
}
async function loopbackPortAvailable(port: number): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const server = createServer();
server.unref();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
export async function resolveTestDriveServerPort(preferredPort = 3100): Promise<number> {
for (let port = preferredPort; port <= 65_535; port += 1) {
if (await loopbackPortAvailable(port)) return port;
}
throw new Error(`No available loopback port found at or above ${preferredPort}.`);
}
/**
* Establish isolation before the CLI's normal config and .env loading hook.
* The selected credential source is preserved in case its name happens to use
* a PAPERCLIP_ prefix; all other Paperclip routing/configuration is discarded.
*/
export async function prepareTestDriveEnvironment(
options: Pick<TestDriveOptions, "dataDir" | "apiKeyEnv">,
cwd = process.cwd(),
): Promise<{ dataDir: string; linkedWorktree: boolean }> {
const sourceEnvName = options.apiKeyEnv?.trim();
const preservedCredential = sourceEnvName ? process.env[sourceEnvName] : undefined;
for (const key of Object.keys(process.env)) {
if (key.startsWith("PAPERCLIP_")) {
delete process.env[key];
}
}
for (const key of NON_PAPERCLIP_ISOLATED_ENV_KEYS) {
delete process.env[key];
}
if (sourceEnvName && preservedCredential !== undefined) {
process.env[sourceEnvName] = preservedCredential;
}
const dataDir = resolveTestDriveDataDir(options.dataDir);
const linkedWorktree = isLinkedGitWorktree(cwd);
process.env.PAPERCLIP_HOME = dataDir;
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_CONFIG = resolveDefaultConfigPath("default");
process.env.PAPERCLIP_CONTEXT = resolveDefaultContextPath();
process.env.PAPERCLIP_IN_WORKTREE = linkedWorktree ? "true" : "false";
process.env.PAPERCLIP_OPEN_ON_LISTEN = "false";
process.env.PAPERCLIP_DISABLE_CWD_ENV_FILE = "true";
process.env.PAPERCLIP_DEPLOYMENT_MODE = "local_trusted";
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private";
process.env.PAPERCLIP_BIND = "loopback";
process.env.HOST = "127.0.0.1";
process.env.PORT = String(await resolveTestDriveServerPort());
return { dataDir, linkedWorktree };
}
export function assertTestDriveDatabaseIsolation(
configPath?: string,
env: NodeJS.ProcessEnv = process.env,
readConfigFile: (path?: string) => PaperclipConfig | null = readConfig,
): void {
if (env.DATABASE_URL?.trim() || env.DATABASE_MIGRATION_URL?.trim()) {
throw new Error(
"test-drive requires its isolated embedded database. Remove DATABASE_URL and " +
"DATABASE_MIGRATION_URL from the selected data directory's .env, or choose a fresh --data-dir.",
);
}
const config = readConfigFile(configPath);
if (config?.database.mode === "postgres") {
throw new Error(
"test-drive cannot reuse a data directory configured for an external PostgreSQL database. " +
"Choose a fresh data directory or change database.mode to embedded-postgres.",
);
}
}
export function resolveTestDriveBootstrap(
options: TestDriveOptions,
env: NodeJS.ProcessEnv = process.env,
): ResolvedTestDriveBootstrap {
const harness = options.harness ?? "claude";
const definition = HARNESS_DEFINITIONS[harness];
if (!definition) {
throw new Error(`Unsupported test-drive harness: ${String(harness)}.`);
}
const companyName = (options.companyName ?? "Test Company").trim();
const agentName = (options.agentName ?? "CEO").trim();
if (!companyName) throw new Error("--company-name cannot be empty.");
if (!agentName) throw new Error("--agent-name cannot be empty.");
const model = options.model;
if (model !== undefined && (!model || model.trim() !== model)) {
throw new Error("--model cannot be empty or have surrounding whitespace.");
}
if (
harness === "opencode" &&
(!model || !/^openrouter\/[^/\s]+(?:\/[^/\s]+)*$/.test(model))
) {
throw new Error(
"OpenCode test drives require --model openrouter/<model>, with no empty path segments.",
);
}
const sourceEnvName = options.apiKeyEnv?.trim() || definition.credentialTarget;
if (options.apiKeyEnv !== undefined && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(sourceEnvName)) {
throw new Error("--api-key-env must name a valid environment variable.");
}
const credential = env[sourceEnvName];
if (!credential || credential.trim().length === 0) {
throw new Error(
`No credential found. Set ${sourceEnvName} or pass --api-key-env <variable>.`,
);
}
return {
...definition,
companyName,
agentName,
...(model ? { model } : {}),
credential,
credentialSource: sourceEnvName,
};
}
function worktreeExecutionArmed(
settings: InstanceExperimentalSettings,
instanceId: string,
): boolean {
return settings.enableWorktreeRunExecution === true
&& Boolean(settings.worktreeRunExecutionActivatedAt)
&& settings.worktreeRunExecutionActivationInstanceId === instanceId;
}
export async function reconcileTestDriveWorktreeExecution(
api: TestDriveApi,
instanceId: string,
): Promise<void> {
const current = requiredApiResult(
await api.get<InstanceExperimentalSettings>("/api/instance/settings/experimental"),
"reading experimental settings",
);
if (!current.enableWorktreeRunExecution) {
await api.patch<InstanceExperimentalSettings>("/api/instance/settings/experimental", {
enableWorktreeRunExecution: true,
});
} else if (!worktreeExecutionArmed(current, instanceId)) {
await api.patch<InstanceExperimentalSettings>("/api/instance/settings/experimental", {
enableWorktreeRunExecution: false,
});
await api.patch<InstanceExperimentalSettings>("/api/instance/settings/experimental", {
enableWorktreeRunExecution: true,
});
}
const verified = requiredApiResult(
await api.get<InstanceExperimentalSettings>("/api/instance/settings/experimental"),
"verifying experimental settings",
);
if (!worktreeExecutionArmed(verified, instanceId)) {
throw new Error(
`Could not arm “Run tasks in this worktree” for Paperclip instance ${instanceId}. ` +
"Check that PAPERCLIP_IN_WORKTREE=true and retry the command.",
);
}
}
export async function bootstrapTestDrive(input: {
api: TestDriveApi;
options: TestDriveOptions;
linkedWorktree: boolean;
instanceId: string;
env?: NodeJS.ProcessEnv;
}): Promise<TestDriveBootstrapResult> {
const companies = requiredApiResult(
await input.api.get<Company[]>("/api/companies"),
"reading companies",
);
const existingCompany = companies[0];
if (existingCompany) {
if (input.linkedWorktree) {
await reconcileTestDriveWorktreeExecution(input.api, input.instanceId);
}
return { reused: true, company: existingCompany, agent: null };
}
// Resolve every bootstrap input before the first mutation. In particular,
// OpenCode model validation and credential lookup happen before company
// creation so an invalid invocation leaves the database untouched.
const resolved = resolveTestDriveBootstrap(input.options, input.env);
let company: Company | null = null;
try {
company = requiredApiResult(
await input.api.post<Company>("/api/companies", { name: resolved.companyName }),
"creating the test company",
);
await input.api.post(`/api/companies/${company.id}/user-secret-definitions`, {
key: resolved.credentialTarget,
name: resolved.credentialName,
});
await input.api.post(`/api/companies/${company.id}/me/user-secrets`, {
definitionKey: resolved.credentialTarget,
value: resolved.credential,
});
const adapterConfig: Record<string, unknown> = {
env: {
[resolved.credentialTarget]: {
type: "user_secret_ref",
key: resolved.credentialTarget,
version: "latest",
required: true,
},
},
};
if (resolved.model) adapterConfig.model = resolved.model;
const agent = requiredApiResult(
await input.api.post<Agent>(`/api/companies/${company.id}/agents`, {
name: resolved.agentName,
role: "ceo",
adapterType: resolved.adapterType,
adapterConfig,
}),
"creating the CEO agent",
);
if (input.linkedWorktree) {
await reconcileTestDriveWorktreeExecution(input.api, input.instanceId);
}
return { reused: false, company, agent };
} catch (error) {
if (company) {
try {
await input.api.delete(`/api/companies/${company.id}`);
} catch (cleanupError) {
throw new Error(
`${errorMessage(error)} Cleanup also failed for newly-created company ${company.id}: ${errorMessage(cleanupError)}`,
{ cause: error },
);
}
}
throw error;
}
}
function dashboardUrl(server: StartedServer): string {
return server.apiUrl.replace(/\/api\/?$/, "");
}
export async function testDriveCommand(
options: TestDriveOptions,
dependencies: TestDriveDependencies = {
run: runCommand,
createApi: (apiBase) => new PaperclipApiClient({ apiBase }),
openBrowser: openUrl,
},
): Promise<void> {
const dataDir = path.resolve(process.env.PAPERCLIP_HOME ?? resolveTestDriveDataDir(options.dataDir));
const linkedWorktree = process.env.PAPERCLIP_IN_WORKTREE === "true";
const instanceId = process.env.PAPERCLIP_INSTANCE_ID ?? "default";
const possibleCredentials = [
options.apiKeyEnv ? process.env[options.apiKeyEnv] : undefined,
process.env[HARNESS_DEFINITIONS[options.harness ?? "claude"].credentialTarget],
];
p.log.message(pc.dim(`Data directory: ${dataDir}`));
p.log.message(pc.dim("The data directory is retained when Paperclip exits."));
try {
await dependencies.run({
repair: true,
yes: true,
bind: "loopback",
installService: false,
// Auto-created directories are private to this process. Explicitly reused
// directories retain the normal guard against an already-managed instance.
skipServiceManagerCheck: !options.dataDir?.trim(),
introLabel: "paperclipai test-drive",
afterStart: async (server) => {
const api = dependencies.createApi(server.apiUrl);
const result = await bootstrapTestDrive({
api,
options,
linkedWorktree,
instanceId,
});
if (result.reused) {
p.log.message(
`Using existing data for ${pc.cyan(result.company.name)}; bootstrap flags were ignored.`,
);
} else {
p.log.success(
`Created ${pc.cyan(result.company.name)} with agent ${pc.cyan(result.agent?.name ?? "CEO")}.`,
);
}
if (linkedWorktree) {
p.log.success("Run tasks in this worktree is enabled for this instance.");
}
const url = dashboardUrl(server);
if (options.browser === false) {
p.log.success(`Paperclip is ready at ${pc.cyan(url)}.`);
return;
}
const opened = await dependencies.openBrowser(url);
if (opened) {
p.log.success(`Paperclip is ready and opened at ${pc.cyan(url)}.`);
} else {
p.log.warn(`Paperclip is ready, but the browser could not be opened. Visit ${url}.`);
}
},
});
} catch (error) {
throw new Error(redactTestDriveText(errorMessage(error), possibleCredentials), { cause: error });
}
}
export function registerTestDriveCommand(program: Command): void {
program
.command("test-drive")
.description("Start an isolated, initialized Paperclip instance for manual testing")
.option("-d, --data-dir <path>", "Paperclip data directory to create or reuse")
.option("--company-name <name>", "Initial company name", "Test Company")
.option("--agent-name <name>", "Initial CEO agent name", "CEO")
.addOption(
new Option("--harness <harness>", "Initial agent harness")
.choices(TEST_DRIVE_HARNESSES)
.default("claude"),
)
.option("--model <model-id>", "Initial agent model")
.option("--api-key-env <variable>", "Read the provider key from an environment variable")
.option("--no-browser", "Do not open the initialized instance in a browser")
.action(async (options: TestDriveOptions) => {
await testDriveCommand(options);
});
}
+1 -40
View File
@@ -104,6 +104,7 @@ import {
type PlannedIssueDocumentMerge,
type PlannedIssueInsert,
} from "./worktree-merge-history-lib.js";
import { detectGitWorkspaceInfo } from "./git-workspace.js";
type WorktreeInitOptions = {
name?: string;
@@ -204,13 +205,6 @@ type EmbeddedPostgresHandle = {
stop: () => Promise<void>;
};
type GitWorkspaceInfo = {
root: string;
commonDir: string;
gitDir: string;
hooksPath: string;
};
type CopiedGitHooksResult = {
sourceHooksPath: string;
targetHooksPath: string;
@@ -717,39 +711,6 @@ function resolveRepairWorktreeDirName(branchName: string): string {
return normalized || "worktree";
}
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
try {
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return {
root: path.resolve(root),
commonDir: path.resolve(root, commonDirRaw),
gitDir: path.resolve(root, gitDirRaw),
hooksPath: path.resolve(root, hooksPathRaw),
};
} catch {
return null;
}
}
function copyDirectoryContents(sourceDir: string, targetDir: string): boolean {
if (!existsSync(sourceDir)) return false;
+22 -3
View File
@@ -50,6 +50,12 @@ import { uninstallCommand } from "./commands/uninstall.js";
import { updateCommand } from "./commands/update.js";
import { registerServiceCommands } from "./commands/service.js";
import { registerConnectionIntentCommands } from "./commands/client/connections.js";
import {
assertTestDriveDatabaseIsolation,
prepareTestDriveEnvironment,
registerTestDriveCommand,
type TestDriveOptions,
} from "./commands/test-drive.js";
const program = new Command();
const DATA_DIR_OPTION_HELP =
@@ -92,17 +98,30 @@ program
.option("--no-backup", "Skip the pre-update database backup")
.action(updateCommand);
program.hook("preAction", (_thisCommand, actionCommand) => {
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
program.hook("preAction", async (_thisCommand, actionCommand) => {
const options = actionCommand.optsWithGlobals() as DataDirOptionLike & TestDriveOptions;
let dataDirOptions: DataDirOptionLike = options;
if (actionCommand.name() === "test-drive") {
const prepared = await prepareTestDriveEnvironment({
dataDir: options.dataDir,
apiKeyEnv: options.apiKeyEnv,
});
dataDirOptions = { ...options, dataDir: prepared.dataDir };
}
const optionNames = new Set(actionCommand.options.map((option) => option.attributeName()));
applyDataDirOverride(options, {
applyDataDirOverride(dataDirOptions, {
hasConfigOption: optionNames.has("config"),
hasContextOption: optionNames.has("context"),
});
loadPaperclipEnvFile(options.config);
if (actionCommand.name() === "test-drive") {
assertTestDriveDatabaseIsolation(options.config);
}
initTelemetryFromConfigFile(options.config);
});
registerTestDriveCommand(program);
program
.command("onboard")
.description("Interactive first-run setup wizard")