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:
parent
5da6499860
commit
8f0c1d4548
19
README.md
19
README.md
|
|
@ -336,6 +336,25 @@ To try Paperclip without installing anything permanently:
|
|||
npx --registry https://registry.npmjs.org paperclipai onboard --yes
|
||||
```
|
||||
|
||||
For an isolated manual test instance that is already initialized with a CEO
|
||||
agent, use `test-drive`. It stays in the foreground, never installs a service
|
||||
or creates a first task, and opens the browser only after setup succeeds:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=... npx paperclipai test-drive
|
||||
OPENAI_API_KEY=... npx paperclipai test-drive --harness codex
|
||||
OPENROUTER_API_KEY=... npx paperclipai test-drive \
|
||||
--harness opencode \
|
||||
--model openrouter/anthropic/claude-sonnet-4.5
|
||||
```
|
||||
|
||||
Each run without `--data-dir` gets a unique, retained temporary directory; its
|
||||
absolute path is printed at startup. Pass `--data-dir` to reuse one, or
|
||||
`--no-browser` to leave the initialized instance unopened. When invoked from a
|
||||
linked Git worktree, `test-drive` also enables task execution in that worktree.
|
||||
See [`doc/CLI.md`](doc/CLI.md#isolated-manual-test-drives) for credential and
|
||||
reuse behavior.
|
||||
|
||||
> **Troubleshooting: private npm registry `.npmrc`**
|
||||
>
|
||||
> If this fails with an `E404` for `paperclipai` (or similar) and you use a private npm registry (for example GitHub Packages) via a global `~/.npmrc`, `npx` may be resolving `paperclipai` against that private registry instead of the public npm registry.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
79
doc/CLI.md
79
doc/CLI.md
|
|
@ -154,6 +154,85 @@ Choose local instance:
|
|||
npx paperclipai run --instance dev
|
||||
```
|
||||
|
||||
## Isolated Manual Test Drives
|
||||
|
||||
`paperclipai test-drive` creates or reuses an isolated local data directory,
|
||||
ensures one usable CEO agent exists in a fresh database, starts Paperclip in the
|
||||
foreground, and opens the browser after initialization succeeds. It never
|
||||
installs a background service and never creates a goal, project, issue, task,
|
||||
or heartbeat.
|
||||
|
||||
```sh
|
||||
npx paperclipai test-drive \
|
||||
[-d, --data-dir <path>] \
|
||||
[--company-name <name>] \
|
||||
[--agent-name <name>] \
|
||||
[--harness <claude|codex|opencode>] \
|
||||
[--model <model-id>] \
|
||||
[--api-key-env <variable>] \
|
||||
[--no-browser]
|
||||
```
|
||||
|
||||
Defaults are `Test Company`, a `CEO` agent with the `ceo` role, and the Claude
|
||||
harness. Without `--data-dir`, every invocation creates a unique OS temporary
|
||||
directory and prints its absolute path. The directory is retained after exit
|
||||
for inspection. An explicit data directory is reused and is never reset. The
|
||||
reused directory must use Paperclip's embedded database; `DATABASE_URL`,
|
||||
`DATABASE_MIGRATION_URL`, and configs with `database.mode: postgres` are
|
||||
rejected so test-drive cannot mutate an external database. The server also
|
||||
ignores the invocation directory's `.env` for test-drive launches, while still
|
||||
loading the selected instance's own environment file. Reused directories also
|
||||
retain the normal guard against colliding with a managed Paperclip service. The
|
||||
server uses the first available loopback port at or above `3100`, so an
|
||||
unrelated local Paperclip process can remain running.
|
||||
|
||||
Harness configuration:
|
||||
|
||||
| Harness | Agent adapter | Agent credential variable | Model |
|
||||
| --- | --- | --- | --- |
|
||||
| `claude` | `claude_local` | `ANTHROPIC_API_KEY` | Optional; omitted uses the adapter default |
|
||||
| `codex` | `codex_local` | `OPENAI_API_KEY` | Optional; omitted uses the adapter default |
|
||||
| `opencode` | `opencode_local` | `OPENROUTER_API_KEY` | Required and must begin with `openrouter/` |
|
||||
|
||||
OpenCode model references retain their complete path, including additional
|
||||
slashes:
|
||||
|
||||
```sh
|
||||
OPENROUTER_API_KEY=... npx paperclipai test-drive \
|
||||
--harness opencode \
|
||||
--model openrouter/anthropic/claude-sonnet-4.5
|
||||
```
|
||||
|
||||
Credentials come from the variable named by `--api-key-env`, or from the
|
||||
harness's canonical environment variable shown in the table. A custom source
|
||||
variable is still stored and projected under the canonical target variable:
|
||||
|
||||
```sh
|
||||
MY_ROUTER_KEY=... npx paperclipai test-drive \
|
||||
--harness opencode \
|
||||
--model openrouter/openai/gpt-5.4 \
|
||||
--api-key-env MY_ROUTER_KEY
|
||||
```
|
||||
|
||||
Credentials are stored through Paperclip's user-secret reference path and are
|
||||
redacted from command output. The command does not accept provider credentials
|
||||
in command-line arguments because process listings and shell history can expose
|
||||
them. Provider connectivity, local harness installation, credential validity,
|
||||
and model availability are intentionally checked only when the agent first
|
||||
runs.
|
||||
|
||||
When invoked inside a linked Git worktree, the command ignores inherited
|
||||
`PAPERCLIP_IN_WORKTREE` state, launches in worktree mode, and verifies **Run
|
||||
tasks in this worktree** is armed for the current instance before opening the
|
||||
browser. In a primary checkout or non-Git directory it launches without
|
||||
worktree mode and does not alter the setting. On reuse, if any company already
|
||||
exists, all bootstrap flags are ignored and companies, agents, and secrets are
|
||||
left untouched; worktree-setting reconciliation is the only permitted
|
||||
mutation.
|
||||
|
||||
Use `--no-browser` for a foreground instance that prints its ready URL without
|
||||
opening it.
|
||||
|
||||
## Install, Update, And Uninstall
|
||||
|
||||
Managed installs keep CLI payloads under `~/.paperclip/cli`, expose a stable
|
||||
|
|
|
|||
|
|
@ -305,6 +305,46 @@ pnpm paperclipai run
|
|||
2. `paperclipai doctor` with repair enabled
|
||||
3. starts the server when checks pass
|
||||
|
||||
### One-command isolated manual test drive
|
||||
|
||||
Use `test-drive` when you want to exercise the UI from a fresh checkout or SHA
|
||||
without completing onboarding by hand:
|
||||
|
||||
```sh
|
||||
ANTHROPIC_API_KEY=... node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts test-drive
|
||||
OPENAI_API_KEY=... node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts test-drive --harness codex --model gpt-5.4
|
||||
OPENROUTER_API_KEY=... node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts test-drive \
|
||||
--harness opencode \
|
||||
--model openrouter/anthropic/claude-sonnet-4.5
|
||||
```
|
||||
|
||||
The command creates a trusted-loopback instance in a unique OS temporary data
|
||||
directory, prints the absolute directory, runs onboarding and doctor
|
||||
non-interactively, creates `Test Company` with a `CEO` agent, then opens the
|
||||
browser. It runs in the foreground, does not install a background service, and
|
||||
does not create a goal, project, issue, task, or first heartbeat. Temporary
|
||||
directories are retained for inspection. Use `--data-dir <path>` to reuse one
|
||||
or `--no-browser` to suppress browser opening. Reused directories must use the
|
||||
embedded database: the command rejects database URL environment overrides and
|
||||
configs with `database.mode: postgres`, suppresses the invocation directory's
|
||||
`.env` when the server starts, and keeps the normal managed-service collision
|
||||
guard. The selected instance's own environment file still loads. The command
|
||||
selects the first available loopback port at or above `3100`.
|
||||
|
||||
Claude uses `ANTHROPIC_API_KEY`; Codex uses `OPENAI_API_KEY`; OpenCode uses
|
||||
`OPENROUTER_API_KEY` and requires an `openrouter/...` model. `--api-key-env`
|
||||
can name a different source variable while the agent still receives the
|
||||
canonical variable. Provider keys must come from environment variables because
|
||||
command-line arguments can appear in process listings and shell history. In a
|
||||
linked Git worktree the command sets worktree runtime mode and safely arms
|
||||
**Run tasks in this worktree** for the current isolated instance. Primary
|
||||
checkouts and non-Git directories leave that experimental setting unchanged.
|
||||
|
||||
If the selected data directory already contains any company, `test-drive`
|
||||
preserves all companies, agents, and secrets and ignores the bootstrap flags.
|
||||
The worktree execution setting is the only value it may reconcile in that
|
||||
case.
|
||||
|
||||
## Docker Quickstart (No local Node install)
|
||||
|
||||
Build and run Paperclip in Docker:
|
||||
|
|
|
|||
|
|
@ -103,6 +103,75 @@ describe("OpenCode local skill injection", () => {
|
|||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("passes an OpenRouter key and complete model to OpenCode without logging the key", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-openrouter-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "opencode");
|
||||
const apiKey = "openrouter-test-secret";
|
||||
const model = "openrouter/anthropic/claude-sonnet-4.5";
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(commandPath, "#!/bin/sh\nexit 0\n", "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
runProcessMock.mockReset();
|
||||
runProcessMock.mockResolvedValueOnce(probeResult({
|
||||
stdout: JSON.stringify({
|
||||
type: "text",
|
||||
sessionID: "session-openrouter",
|
||||
part: { text: "done" },
|
||||
}),
|
||||
}));
|
||||
const logs: string[] = [];
|
||||
const metadata: unknown[] = [];
|
||||
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-openrouter",
|
||||
agent: {
|
||||
id: "agent-openrouter",
|
||||
companyId: "company-1",
|
||||
name: "OpenRouter Coder",
|
||||
adapterType: "opencode_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
model,
|
||||
env: {
|
||||
OPENROUTER_API_KEY: apiKey,
|
||||
OPENCODE_ALLOW_ALL_MODELS: "1",
|
||||
},
|
||||
promptTemplate: "Run the task.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async (_stream, chunk) => {
|
||||
logs.push(chunk);
|
||||
},
|
||||
onMeta: async (value) => {
|
||||
metadata.push(value);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.model).toBe(model);
|
||||
const executionCall = runProcessMock.mock.calls.at(-1)!;
|
||||
expect(executionCall[3]).toContain("--model");
|
||||
expect(executionCall[3]).toContain(model);
|
||||
expect((executionCall[4] as { env: Record<string, string> }).env.OPENROUTER_API_KEY).toBe(apiKey);
|
||||
expect(JSON.stringify({ logs, metadata, result })).not.toContain(apiKey);
|
||||
expect(JSON.stringify(metadata)).toContain('"OPENROUTER_API_KEY":"***REDACTED***"');
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldLoadWorkingDirectoryEnv } from "../env-file-policy.js";
|
||||
|
||||
describe("working-directory environment loading", () => {
|
||||
it("loads a distinct working-directory .env by default", () => {
|
||||
expect(shouldLoadWorkingDirectoryEnv({
|
||||
cwdEnvExists: true,
|
||||
isPaperclipEnvFile: false,
|
||||
env: {},
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("does not load the working-directory .env when explicitly disabled", () => {
|
||||
expect(shouldLoadWorkingDirectoryEnv({
|
||||
cwdEnvExists: true,
|
||||
isPaperclipEnvFile: false,
|
||||
env: { PAPERCLIP_DISABLE_CWD_ENV_FILE: "true" },
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("does not load the same file twice", () => {
|
||||
expect(shouldLoadWorkingDirectoryEnv({
|
||||
cwdEnvExists: true,
|
||||
isPaperclipEnvFile: true,
|
||||
env: {},
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,7 @@ import { resolve } from "node:path";
|
|||
import { config as loadDotenv } from "dotenv";
|
||||
import { resolvePaperclipEnvPath } from "./paths.js";
|
||||
import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js";
|
||||
import { shouldLoadWorkingDirectoryEnv } from "./env-file-policy.js";
|
||||
import {
|
||||
AUTH_BASE_URL_MODES,
|
||||
BIND_MODES,
|
||||
|
|
@ -36,10 +37,14 @@ if (existsSync(PAPERCLIP_ENV_FILE_PATH)) {
|
|||
}
|
||||
|
||||
const CWD_ENV_PATH = resolve(process.cwd(), ".env");
|
||||
const isSameFile = existsSync(CWD_ENV_PATH) && existsSync(PAPERCLIP_ENV_FILE_PATH)
|
||||
const cwdEnvExists = existsSync(CWD_ENV_PATH);
|
||||
const isSameFile = cwdEnvExists && existsSync(PAPERCLIP_ENV_FILE_PATH)
|
||||
? realpathSync(CWD_ENV_PATH) === realpathSync(PAPERCLIP_ENV_FILE_PATH)
|
||||
: CWD_ENV_PATH === PAPERCLIP_ENV_FILE_PATH;
|
||||
if (!isSameFile && existsSync(CWD_ENV_PATH)) {
|
||||
if (shouldLoadWorkingDirectoryEnv({
|
||||
cwdEnvExists,
|
||||
isPaperclipEnvFile: isSameFile,
|
||||
})) {
|
||||
loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
export function shouldLoadWorkingDirectoryEnv(input: {
|
||||
cwdEnvExists: boolean;
|
||||
isPaperclipEnvFile: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
const env = input.env ?? process.env;
|
||||
return env.PAPERCLIP_DISABLE_CWD_ENV_FILE !== "true"
|
||||
&& input.cwdEnvExists
|
||||
&& !input.isPaperclipEnvFile;
|
||||
}
|
||||
|
|
@ -155,6 +155,7 @@ export interface StartedServer {
|
|||
listenPort: number;
|
||||
apiUrl: string;
|
||||
databaseUrl: string;
|
||||
shutdown: (signal?: "SIGINT" | "SIGTERM") => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startServer(): Promise<StartedServer> {
|
||||
|
|
@ -1793,7 +1794,6 @@ export async function startServer(): Promise<StartedServer> {
|
|||
databaseBackupRetentionDays: config.databaseBackupRetentionDays,
|
||||
databaseBackupDir: config.databaseBackupDir,
|
||||
});
|
||||
|
||||
const boardClaimUrl = getBoardClaimWarningUrl(config.host, listenPort);
|
||||
if (boardClaimUrl) {
|
||||
const red = "\x1b[41m\x1b[30m";
|
||||
|
|
@ -1810,96 +1810,106 @@ export async function startServer(): Promise<StartedServer> {
|
|||
);
|
||||
}
|
||||
|
||||
{
|
||||
const shutdown = async (signal: "SIGINT" | "SIGTERM") => {
|
||||
await systemdNotify(["--stopping", `--status=Stopping after ${signal}`]);
|
||||
heartbeatSchedulerStopped = true;
|
||||
if (heartbeatSchedulerInterval) {
|
||||
clearInterval(heartbeatSchedulerInterval);
|
||||
heartbeatSchedulerInterval = null;
|
||||
}
|
||||
const shutdown = async (
|
||||
signal: "SIGINT" | "SIGTERM",
|
||||
exitProcess: boolean,
|
||||
) => {
|
||||
await systemdNotify(["--stopping", `--status=Stopping after ${signal}`]);
|
||||
heartbeatSchedulerStopped = true;
|
||||
if (heartbeatSchedulerInterval) {
|
||||
clearInterval(heartbeatSchedulerInterval);
|
||||
heartbeatSchedulerInterval = null;
|
||||
}
|
||||
|
||||
const heartbeatShutdown = await coordinateHeartbeatSchedulerShutdown({
|
||||
signal,
|
||||
prepareHotRestartShutdown,
|
||||
waitForHeartbeatSchedulerIdle,
|
||||
});
|
||||
const skipHeartbeatDrain = heartbeatShutdown.hotRestart?.skipDrain === true;
|
||||
const selectiveDrainRunIds = heartbeatShutdown.hotRestart?.drainRunIds ?? null;
|
||||
if (skipHeartbeatDrain) {
|
||||
logger.info(
|
||||
{ signal, hotRestart: heartbeatShutdown.hotRestart },
|
||||
"hot-restart shutdown prepared after scheduler quiescence; skipping graceful run drain",
|
||||
);
|
||||
} else if (heartbeatShutdown.preparationError) {
|
||||
logger.error(
|
||||
{ err: heartbeatShutdown.preparationError, signal },
|
||||
"hot-restart shutdown preparation failed; falling back to graceful heartbeat run drain",
|
||||
);
|
||||
}
|
||||
const heartbeatShutdown = await coordinateHeartbeatSchedulerShutdown({
|
||||
signal,
|
||||
prepareHotRestartShutdown,
|
||||
waitForHeartbeatSchedulerIdle,
|
||||
});
|
||||
const skipHeartbeatDrain = heartbeatShutdown.hotRestart?.skipDrain === true;
|
||||
const selectiveDrainRunIds = heartbeatShutdown.hotRestart?.drainRunIds ?? null;
|
||||
if (skipHeartbeatDrain) {
|
||||
logger.info(
|
||||
{ signal, hotRestart: heartbeatShutdown.hotRestart },
|
||||
"hot-restart shutdown prepared after scheduler quiescence; skipping graceful run drain",
|
||||
);
|
||||
} else if (heartbeatShutdown.preparationError) {
|
||||
logger.error(
|
||||
{ err: heartbeatShutdown.preparationError, signal },
|
||||
"hot-restart shutdown preparation failed; falling back to graceful heartbeat run drain",
|
||||
);
|
||||
}
|
||||
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
telemetryClient.stop();
|
||||
await telemetryClient.flush();
|
||||
}
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
telemetryClient.stop();
|
||||
await telemetryClient.flush();
|
||||
}
|
||||
|
||||
if (!skipHeartbeatDrain && drainHeartbeatRunsForShutdown) {
|
||||
try {
|
||||
const drain = await drainHeartbeatRunsForShutdown(signal, selectiveDrainRunIds);
|
||||
logger.info({ signal, drain }, "graceful heartbeat run drain complete");
|
||||
} catch (err) {
|
||||
logger.error({ err, signal }, "graceful heartbeat run drain failed");
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipHeartbeatDrain) {
|
||||
await drainRunExecutionFinalizersForShutdown({
|
||||
signal,
|
||||
drain: drainHeartbeatExecutionFinalizers,
|
||||
log: logger,
|
||||
});
|
||||
}
|
||||
|
||||
// Whatever the drain did not finalize (timed-out runs, the hot-restart
|
||||
// skip path) still has a local-only tail when the in-flight run-log
|
||||
// mirror is enabled; upload those tails now so an orderly restart
|
||||
// never loses run output. No-op when the mirror is off.
|
||||
if (!skipHeartbeatDrain && drainHeartbeatRunsForShutdown) {
|
||||
try {
|
||||
await flushInFlightRunLogMirrors();
|
||||
const drain = await drainHeartbeatRunsForShutdown(signal, selectiveDrainRunIds);
|
||||
logger.info({ signal, drain }, "graceful heartbeat run drain complete");
|
||||
} catch (err) {
|
||||
logger.error({ err, signal }, "run-log in-flight mirror flush failed");
|
||||
logger.error({ err, signal }, "graceful heartbeat run drain failed");
|
||||
}
|
||||
}
|
||||
|
||||
const appShutdown = (app as { locals?: { paperclipShutdown?: () => Promise<void> } }).locals
|
||||
?.paperclipShutdown;
|
||||
const stopEmbeddedPostgres = embeddedPostgres && embeddedPostgresStartedByThisProcess
|
||||
? () => embeddedPostgresSupervisor?.shutdown() ?? embeddedPostgres!.stop()
|
||||
: null;
|
||||
|
||||
// Await the ordered application teardown before the process exits. A live
|
||||
// setup-token login session must stop and release its sandbox lease before
|
||||
// the database and the provider stop, so an orderly shutdown never leaves a
|
||||
// sandbox lease or confidential login state alive past the process exit.
|
||||
await finalizeServerShutdown({
|
||||
if (!skipHeartbeatDrain) {
|
||||
await drainRunExecutionFinalizersForShutdown({
|
||||
signal,
|
||||
shutdownAppServices: appShutdown,
|
||||
stopEmbeddedPostgres,
|
||||
shutdownInstrumentation,
|
||||
shutdownSentry,
|
||||
drain: drainHeartbeatExecutionFinalizers,
|
||||
log: logger,
|
||||
});
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
// Whatever the drain did not finalize (timed-out runs, the hot-restart
|
||||
// skip path) still has a local-only tail when the in-flight run-log
|
||||
// mirror is enabled; upload those tails now so an orderly restart
|
||||
// never loses run output. No-op when the mirror is off.
|
||||
try {
|
||||
await flushInFlightRunLogMirrors();
|
||||
} catch (err) {
|
||||
logger.error({ err, signal }, "run-log in-flight mirror flush failed");
|
||||
}
|
||||
|
||||
process.once("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
const appShutdown = (app as { locals?: { paperclipShutdown?: () => Promise<void> } }).locals
|
||||
?.paperclipShutdown;
|
||||
const stopEmbeddedPostgres = embeddedPostgres && embeddedPostgresStartedByThisProcess
|
||||
? () => embeddedPostgresSupervisor?.shutdown() ?? embeddedPostgres!.stop()
|
||||
: null;
|
||||
|
||||
// Await the ordered application teardown before the process exits. A live
|
||||
// setup-token login session must stop and release its sandbox lease before
|
||||
// the database and the provider stop, so an orderly shutdown never leaves a
|
||||
// sandbox lease or confidential login state alive past the process exit.
|
||||
await finalizeServerShutdown({
|
||||
signal,
|
||||
shutdownAppServices: appShutdown,
|
||||
stopEmbeddedPostgres,
|
||||
shutdownInstrumentation,
|
||||
shutdownSentry,
|
||||
log: logger,
|
||||
});
|
||||
process.once("SIGTERM", () => {
|
||||
void shutdown("SIGTERM");
|
||||
});
|
||||
}
|
||||
|
||||
if (!exitProcess && server.listening) {
|
||||
await new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((err) => {
|
||||
if (err) rejectClose(err);
|
||||
else resolveClose();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (exitProcess) process.exit(0);
|
||||
};
|
||||
|
||||
process.once("SIGINT", () => {
|
||||
void shutdown("SIGINT", true);
|
||||
});
|
||||
process.once("SIGTERM", () => {
|
||||
void shutdown("SIGTERM", true);
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
|
|
@ -1907,6 +1917,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
listenPort,
|
||||
apiUrl: configuredApiUrl,
|
||||
databaseUrl: activeDatabaseConnectionString,
|
||||
shutdown: (signal = "SIGTERM") => shutdown(signal, false),
|
||||
};
|
||||
} catch (error) {
|
||||
if (startupListenerBound) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue