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

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

No files matched your search

@@ -0,0 +1,65 @@
/**
* esbuild configuration for building the paperclipai CLI for npm.
*
* Bundles all workspace packages (@paperclipai/*) into a single file.
* External npm packages remain as regular dependencies.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
// Workspace packages whose code should be bundled into the CLI.
// Note: "server" is excluded — it's published separately and resolved at runtime.
const workspacePaths = [
"cli",
"packages/db",
"packages/shared",
"packages/adapter-utils",
"packages/adapters/claude-local",
"packages/adapters/codex-local",
"packages/adapters/openclaw-gateway",
];
// Workspace packages that should NOT be bundled — they'll be published
// to npm and resolved at runtime (e.g. @paperclipai/server uses dynamic import).
const externalWorkspacePackages = new Set([
"@paperclipai/server",
]);
// Collect all external (non-workspace) npm package names
const externals = new Set();
for (const p of workspacePaths) {
const pkg = JSON.parse(readFileSync(resolve(repoRoot, p, "package.json"), "utf8"));
for (const name of Object.keys(pkg.dependencies || {})) {
if (externalWorkspacePackages.has(name)) {
externals.add(name);
} else if (!name.startsWith("@paperclipai/")) {
externals.add(name);
}
}
for (const name of Object.keys(pkg.optionalDependencies || {})) {
externals.add(name);
}
}
// Also add all published workspace packages as external
for (const name of externalWorkspacePackages) {
externals.add(name);
}
/** @type {import('esbuild').BuildOptions} */
export default {
entryPoints: ["src/index.ts"],
bundle: true,
platform: "node",
target: "node20",
format: "esm",
outfile: "dist/index.js",
banner: { js: "#!/usr/bin/env node" },
external: [...externals].sort(),
treeShaking: true,
sourcemap: true,
};
+62
View File
@@ -0,0 +1,62 @@
{
"name": "paperclipai",
"version": "0.3.1",
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
"type": "module",
"bin": {
"paperclipai": "./dist/index.js"
},
"keywords": [
"paperclip",
"ai",
"agents",
"orchestration",
"cli"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "cli"
},
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "node --input-type=module -e \"import esbuild from 'esbuild'; import config from './esbuild.config.mjs'; await esbuild.build(config);\" && chmod +x dist/index.js",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"@paperclipai/adapter-claude-local": "workspace:*",
"@paperclipai/adapter-codex-local": "workspace:*",
"@paperclipai/adapter-cursor-local": "workspace:*",
"@paperclipai/adapter-gemini-local": "workspace:*",
"@paperclipai/adapter-opencode-local": "workspace:*",
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/db": "workspace:*",
"@paperclipai/server": "workspace:*",
"@paperclipai/shared": "workspace:*",
"drizzle-orm": "0.38.4",
"dotenv": "^17.0.1",
"commander": "^13.1.0",
"embedded-postgres": "^18.1.0-beta.16",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}
@@ -0,0 +1,79 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
ensureAgentJwtSecret,
mergePaperclipEnvEntries,
readAgentJwtSecretFromEnv,
readPaperclipEnvEntries,
resolveAgentJwtEnvFile,
} from "../config/env.js";
import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js";
const ORIGINAL_ENV = { ...process.env };
function tempConfigPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-jwt-env-"));
const configDir = path.join(dir, "custom");
fs.mkdirSync(configDir, { recursive: true });
return path.join(configDir, "config.json");
}
describe("agent jwt env helpers", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("writes .env next to explicit config path", () => {
const configPath = tempConfigPath();
const result = ensureAgentJwtSecret(configPath);
expect(result.created).toBe(true);
const envPath = resolveAgentJwtEnvFile(configPath);
expect(fs.existsSync(envPath)).toBe(true);
const contents = fs.readFileSync(envPath, "utf-8");
expect(contents).toContain("PAPERCLIP_AGENT_JWT_SECRET=");
});
it("loads secret from .env next to explicit config path", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=test-secret\n", { mode: 0o600 });
const loaded = readAgentJwtSecretFromEnv(configPath);
expect(loaded).toBe("test-secret");
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBe("test-secret");
});
it("doctor check passes when secret exists in adjacent .env", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
fs.writeFileSync(envPath, "PAPERCLIP_AGENT_JWT_SECRET=check-secret\n", { mode: 0o600 });
const result = agentJwtSecretCheck(configPath);
expect(result.status).toBe("pass");
});
it("quotes hash-prefixed env values so dotenv round-trips them", () => {
const configPath = tempConfigPath();
const envPath = resolveAgentJwtEnvFile(configPath);
mergePaperclipEnvEntries(
{
PAPERCLIP_WORKTREE_COLOR: "#439edb",
},
envPath,
);
const contents = fs.readFileSync(envPath, "utf-8");
expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"');
expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb");
});
});
@@ -0,0 +1,77 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { PaperclipConfig } from "../config/schema.js";
import { addAllowedHostname } from "../commands/allowed-hostname.js";
function createTempConfigPath() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-allowed-hostname-"));
return path.join(dir, "config.json");
}
function writeBaseConfig(configPath: string) {
const base: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(),
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/tmp/paperclip-db",
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: "/tmp/paperclip-backups",
},
},
logging: {
mode: "file",
logDir: "/tmp/paperclip-logs",
},
server: {
deploymentMode: "authenticated",
exposure: "private",
host: "0.0.0.0",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: { baseDir: "/tmp/paperclip-storage" },
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: { keyFilePath: "/tmp/paperclip-secrets/master.key" },
},
};
fs.writeFileSync(configPath, JSON.stringify(base, null, 2));
}
describe("allowed-hostname command", () => {
it("adds and normalizes hostnames", async () => {
const configPath = createTempConfigPath();
writeBaseConfig(configPath);
await addAllowedHostname("https://Dotta-MacBook-Pro:3100", { config: configPath });
await addAllowedHostname("dotta-macbook-pro", { config: configPath });
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")) as PaperclipConfig;
expect(raw.server.allowedHostnames).toEqual(["dotta-macbook-pro"]);
});
});
@@ -0,0 +1,16 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { registerClientAuthCommands } from "../commands/client/auth.js";
describe("registerClientAuthCommands", () => {
it("registers auth commands without duplicate company-id flags", () => {
const program = new Command();
const auth = program.command("auth");
expect(() => registerClientAuthCommands(auth)).not.toThrow();
const login = auth.commands.find((command) => command.name() === "login");
expect(login).toBeDefined();
expect(login?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
});
});
@@ -0,0 +1,53 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
getStoredBoardCredential,
readBoardAuthStore,
removeStoredBoardCredential,
setStoredBoardCredential,
} from "../client/board-auth.js";
function createTempAuthPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-auth-"));
return path.join(dir, "auth.json");
}
describe("board auth store", () => {
it("returns an empty store when the file does not exist", () => {
const authPath = createTempAuthPath();
expect(readBoardAuthStore(authPath)).toEqual({
version: 1,
credentials: {},
});
});
it("stores and retrieves credentials by normalized api base", () => {
const authPath = createTempAuthPath();
setStoredBoardCredential({
apiBase: "http://localhost:3100/",
token: "token-123",
userId: "user-1",
storePath: authPath,
});
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toMatchObject({
apiBase: "http://localhost:3100",
token: "token-123",
userId: "user-1",
});
});
it("removes stored credentials", () => {
const authPath = createTempAuthPath();
setStoredBoardCredential({
apiBase: "http://localhost:3100",
token: "token-123",
storePath: authPath,
});
expect(removeStoredBoardCredential("http://localhost:3100", authPath)).toBe(true);
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toBeNull();
});
});
@@ -0,0 +1,98 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { writeContext } from "../client/context.js";
import { resolveCommandContext } from "../commands/client/common.js";
const ORIGINAL_ENV = { ...process.env };
function createTempPath(name: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-common-"));
return path.join(dir, name);
}
describe("resolveCommandContext", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("uses profile defaults when options/env are not provided", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 1,
currentProfile: "ops",
profiles: {
ops: {
apiBase: "http://127.0.0.1:9999",
companyId: "company-profile",
apiKeyEnvVarName: "AGENT_KEY",
},
},
},
contextPath,
);
process.env.AGENT_KEY = "key-from-env";
const resolved = resolveCommandContext({ context: contextPath }, { requireCompany: true });
expect(resolved.api.apiBase).toBe("http://127.0.0.1:9999");
expect(resolved.companyId).toBe("company-profile");
expect(resolved.api.apiKey).toBe("key-from-env");
});
it("prefers explicit options over profile values", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 1,
currentProfile: "default",
profiles: {
default: {
apiBase: "http://profile:3100",
companyId: "company-profile",
},
},
},
contextPath,
);
const resolved = resolveCommandContext(
{
context: contextPath,
apiBase: "http://override:3200",
apiKey: "direct-token",
companyId: "company-override",
},
{ requireCompany: true },
);
expect(resolved.api.apiBase).toBe("http://override:3200");
expect(resolved.companyId).toBe("company-override");
expect(resolved.api.apiKey).toBe("direct-token");
});
it("throws when company is required but unresolved", () => {
const contextPath = createTempPath("context.json");
writeContext(
{
version: 1,
currentProfile: "default",
profiles: { default: {} },
},
contextPath,
);
expect(() =>
resolveCommandContext({ context: contextPath, apiBase: "http://localhost:3100" }, { requireCompany: true }),
).toThrow(/Company ID is required/);
});
});
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import type { Company } from "@paperclipai/shared";
import { assertDeleteConfirmation, resolveCompanyForDeletion } from "../commands/client/company.js";
function makeCompany(overrides: Partial<Company>): Company {
return {
id: "11111111-1111-1111-1111-111111111111",
name: "Alpha",
description: null,
status: "active",
pauseReason: null,
pausedAt: null,
issuePrefix: "ALP",
issueCounter: 1,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
requireBoardApprovalForNewAgents: false,
brandColor: null,
logoAssetId: null,
logoUrl: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe("resolveCompanyForDeletion", () => {
const companies: Company[] = [
makeCompany({
id: "11111111-1111-1111-1111-111111111111",
name: "Alpha",
issuePrefix: "ALP",
}),
makeCompany({
id: "22222222-2222-2222-2222-222222222222",
name: "Paperclip",
issuePrefix: "PAP",
}),
];
it("resolves by ID in auto mode", () => {
const result = resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "auto");
expect(result.issuePrefix).toBe("PAP");
});
it("resolves by prefix in auto mode", () => {
const result = resolveCompanyForDeletion(companies, "pap", "auto");
expect(result.id).toBe("22222222-2222-2222-2222-222222222222");
});
it("throws when selector is not found", () => {
expect(() => resolveCompanyForDeletion(companies, "MISSING", "auto")).toThrow(/No company found/);
});
it("respects explicit id mode", () => {
expect(() => resolveCompanyForDeletion(companies, "PAP", "id")).toThrow(/No company found by ID/);
});
it("respects explicit prefix mode", () => {
expect(() => resolveCompanyForDeletion(companies, "22222222-2222-2222-2222-222222222222", "prefix"))
.toThrow(/No company found by shortname/);
});
});
describe("assertDeleteConfirmation", () => {
const company = makeCompany({
id: "22222222-2222-2222-2222-222222222222",
issuePrefix: "PAP",
});
it("requires --yes", () => {
expect(() => assertDeleteConfirmation(company, { confirm: "PAP" })).toThrow(/requires --yes/);
});
it("accepts matching prefix confirmation", () => {
expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "pap" })).not.toThrow();
});
it("accepts matching id confirmation", () => {
expect(() =>
assertDeleteConfirmation(company, {
yes: true,
confirm: "22222222-2222-2222-2222-222222222222",
})).not.toThrow();
});
it("rejects mismatched confirmation", () => {
expect(() => assertDeleteConfirmation(company, { yes: true, confirm: "nope" }))
.toThrow(/does not match target company/);
});
});
@@ -0,0 +1,543 @@
import { execFile, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createStoredZipArchive } from "./helpers/zip.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
};
type EmbeddedPostgresCtor = new (opts: {
databaseDir: string;
user: string;
password: string;
port: number;
persistent: boolean;
initdbFlags?: string[];
onLog?: (message: unknown) => void;
onError?: (message: unknown) => void;
}) => EmbeddedPostgresInstance;
const execFileAsync = promisify(execFile);
type ServerProcess = ReturnType<typeof spawn>;
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
return mod.default as EmbeddedPostgresCtor;
}
async function getAvailablePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate test port")));
return;
}
const { port } = address;
server.close((error) => {
if (error) reject(error);
else resolve(port);
});
});
});
}
async function startTempDatabase() {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-db-"));
const port = await getAvailablePort();
const EmbeddedPostgres = await getEmbeddedPostgresCtor();
const instance = new EmbeddedPostgres({
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
onLog: () => {},
onError: () => {},
});
await instance.initialise();
await instance.start();
const { applyPendingMigrations, ensurePostgresDatabase } = await import("@paperclipai/db");
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
await applyPendingMigrations(connectionString);
return { connectionString, dataDir, instance };
}
function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) {
const config = {
$meta: {
version: 1,
updatedAt: new Date().toISOString(),
source: "doctor",
},
database: {
mode: "postgres",
connectionString,
embeddedPostgresDataDir: path.join(tempRoot, "embedded-db"),
embeddedPostgresPort: 54329,
backup: {
enabled: false,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(tempRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(tempRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port,
allowedHostnames: [],
serveUi: false,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(tempRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(tempRoot, "secrets", "master.key"),
},
},
};
mkdirSync(path.dirname(configPath), { recursive: true });
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
}
function createServerEnv(configPath: string, port: number, connectionString: string) {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith("PAPERCLIP_")) {
delete env[key];
}
}
delete env.DATABASE_URL;
delete env.PORT;
delete env.HOST;
delete env.SERVE_UI;
delete env.HEARTBEAT_SCHEDULER_ENABLED;
env.PAPERCLIP_CONFIG = configPath;
env.DATABASE_URL = connectionString;
env.HOST = "127.0.0.1";
env.PORT = String(port);
env.SERVE_UI = "false";
env.PAPERCLIP_DB_BACKUP_ENABLED = "false";
env.HEARTBEAT_SCHEDULER_ENABLED = "false";
env.PAPERCLIP_MIGRATION_AUTO_APPLY = "true";
env.PAPERCLIP_UI_DEV_MIDDLEWARE = "false";
return env;
}
function createCliEnv() {
const env = { ...process.env };
for (const key of Object.keys(env)) {
if (key.startsWith("PAPERCLIP_")) {
delete env[key];
}
}
delete env.DATABASE_URL;
delete env.PORT;
delete env.HOST;
delete env.SERVE_UI;
delete env.PAPERCLIP_DB_BACKUP_ENABLED;
delete env.HEARTBEAT_SCHEDULER_ENABLED;
delete env.PAPERCLIP_MIGRATION_AUTO_APPLY;
delete env.PAPERCLIP_UI_DEV_MIDDLEWARE;
return env;
}
function collectTextFiles(root: string, current: string, files: Record<string, string>) {
for (const entry of readdirSync(current, { withFileTypes: true })) {
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
collectTextFiles(root, absolutePath, files);
continue;
}
if (!entry.isFile()) continue;
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
files[relativePath] = readFileSync(absolutePath, "utf8");
}
}
async function stopServerProcess(child: ServerProcess | null) {
if (!child || child.exitCode !== null) return;
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
child.once("exit", () => resolve());
setTimeout(() => {
if (child.exitCode === null) {
child.kill("SIGKILL");
}
}, 5_000);
});
}
async function api<T>(baseUrl: string, pathname: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${baseUrl}${pathname}`, init);
const text = await res.text();
if (!res.ok) {
throw new Error(`Request failed ${res.status} ${pathname}: ${text}`);
}
return text ? JSON.parse(text) as T : (null as T);
}
async function runCliJson<T>(args: string[], opts: { apiBase: string; configPath: string }) {
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const result = await execFileAsync(
"pnpm",
["--silent", "paperclipai", ...args, "--api-base", opts.apiBase, "--config", opts.configPath, "--json"],
{
cwd: repoRoot,
env: createCliEnv(),
maxBuffer: 10 * 1024 * 1024,
},
);
const stdout = result.stdout.trim();
const jsonStart = stdout.search(/[\[{]/);
if (jsonStart === -1) {
throw new Error(`CLI did not emit JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
}
return JSON.parse(stdout.slice(jsonStart)) as T;
}
async function waitForServer(
apiBase: string,
child: ServerProcess,
output: { stdout: string[]; stderr: string[] },
) {
const startedAt = Date.now();
while (Date.now() - startedAt < 30_000) {
if (child.exitCode !== null) {
throw new Error(
`paperclipai run exited before healthcheck succeeded.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
);
}
try {
const res = await fetch(`${apiBase}/api/health`);
if (res.ok) return;
} catch {
// Server is still starting.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(
`Timed out waiting for ${apiBase}/api/health.\nstdout:\n${output.stdout.join("")}\nstderr:\n${output.stderr.join("")}`,
);
}
describe("paperclipai company import/export e2e", () => {
let tempRoot = "";
let configPath = "";
let exportDir = "";
let apiBase = "";
let serverProcess: ServerProcess | null = null;
let dbDataDir = "";
let dbInstance: EmbeddedPostgresInstance | null = null;
beforeAll(async () => {
tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-"));
configPath = path.join(tempRoot, "config", "config.json");
exportDir = path.join(tempRoot, "exported-company");
const db = await startTempDatabase();
dbDataDir = db.dataDir;
dbInstance = db.instance;
const port = await getAvailablePort();
writeTestConfig(configPath, tempRoot, port, db.connectionString);
apiBase = `http://127.0.0.1:${port}`;
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const output = { stdout: [] as string[], stderr: [] as string[] };
const child = spawn(
"pnpm",
["paperclipai", "run", "--config", configPath],
{
cwd: repoRoot,
env: createServerEnv(configPath, port, db.connectionString),
stdio: ["ignore", "pipe", "pipe"],
},
);
serverProcess = child;
child.stdout?.on("data", (chunk) => {
output.stdout.push(String(chunk));
});
child.stderr?.on("data", (chunk) => {
output.stderr.push(String(chunk));
});
await waitForServer(apiBase, child, output);
}, 60_000);
afterAll(async () => {
await stopServerProcess(serverProcess);
await dbInstance?.stop();
if (dbDataDir) {
rmSync(dbDataDir, { recursive: true, force: true });
}
if (tempRoot) {
rmSync(tempRoot, { recursive: true, force: true });
}
});
it("exports a company package and imports it into new and existing companies", async () => {
expect(serverProcess).not.toBeNull();
const sourceCompany = await api<{ id: string; name: string; issuePrefix: string }>(apiBase, "/api/companies", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: `CLI Export Source ${Date.now()}` }),
});
const sourceAgent = await api<{ id: string; name: string }>(
apiBase,
`/api/companies/${sourceCompany.id}/agents`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "Export Engineer",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You verify company portability.",
},
}),
},
);
const sourceProject = await api<{ id: string; name: string }>(
apiBase,
`/api/companies/${sourceCompany.id}/projects`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "Portability Verification",
status: "in_progress",
}),
},
);
const largeIssueDescription = `Round-trip the company package through the CLI.\n\n${"portable-data ".repeat(12_000)}`;
const sourceIssue = await api<{ id: string; title: string; identifier: string }>(
apiBase,
`/api/companies/${sourceCompany.id}/issues`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: "Validate company import/export",
description: largeIssueDescription,
status: "todo",
projectId: sourceProject.id,
assigneeAgentId: sourceAgent.id,
}),
},
);
const exportResult = await runCliJson<{
ok: boolean;
out: string;
filesWritten: number;
}>(
[
"company",
"export",
sourceCompany.id,
"--out",
exportDir,
"--include",
"company,agents,projects,issues",
],
{ apiBase, configPath },
);
expect(exportResult.ok).toBe(true);
expect(exportResult.filesWritten).toBeGreaterThan(0);
expect(readFileSync(path.join(exportDir, "COMPANY.md"), "utf8")).toContain(sourceCompany.name);
expect(readFileSync(path.join(exportDir, ".paperclip.yaml"), "utf8")).toContain('schema: "paperclip/v1"');
const importedNew = await runCliJson<{
company: { id: string; name: string; action: string };
agents: Array<{ id: string | null; action: string; name: string }>;
}>(
[
"company",
"import",
exportDir,
"--target",
"new",
"--new-company-name",
`Imported ${sourceCompany.name}`,
"--include",
"company,agents,projects,issues",
"--yes",
],
{ apiBase, configPath },
);
expect(importedNew.company.action).toBe("created");
expect(importedNew.agents).toHaveLength(1);
expect(importedNew.agents[0]?.action).toBe("created");
const importedAgents = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/agents`,
);
const importedProjects = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/projects`,
);
const importedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/issues`,
);
expect(importedAgents.map((agent) => agent.name)).toContain(sourceAgent.name);
expect(importedProjects.map((project) => project.name)).toContain(sourceProject.name);
expect(importedIssues.map((issue) => issue.title)).toContain(sourceIssue.title);
const previewExisting = await runCliJson<{
errors: string[];
plan: {
companyAction: string;
agentPlans: Array<{ action: string }>;
projectPlans: Array<{ action: string }>;
issuePlans: Array<{ action: string }>;
};
}>(
[
"company",
"import",
exportDir,
"--target",
"existing",
"--company-id",
importedNew.company.id,
"--include",
"company,agents,projects,issues",
"--collision",
"rename",
"--dry-run",
],
{ apiBase, configPath },
);
expect(previewExisting.errors).toEqual([]);
expect(previewExisting.plan.companyAction).toBe("none");
expect(previewExisting.plan.agentPlans.some((plan) => plan.action === "create")).toBe(true);
expect(previewExisting.plan.projectPlans.some((plan) => plan.action === "create")).toBe(true);
expect(previewExisting.plan.issuePlans.some((plan) => plan.action === "create")).toBe(true);
const importedExisting = await runCliJson<{
company: { id: string; action: string };
agents: Array<{ id: string | null; action: string; name: string }>;
}>(
[
"company",
"import",
exportDir,
"--target",
"existing",
"--company-id",
importedNew.company.id,
"--include",
"company,agents,projects,issues",
"--collision",
"rename",
"--yes",
],
{ apiBase, configPath },
);
expect(importedExisting.company.action).toBe("unchanged");
expect(importedExisting.agents.some((agent) => agent.action === "created")).toBe(true);
const twiceImportedAgents = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/agents`,
);
const twiceImportedProjects = await api<Array<{ id: string; name: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/projects`,
);
const twiceImportedIssues = await api<Array<{ id: string; title: string; identifier: string }>>(
apiBase,
`/api/companies/${importedNew.company.id}/issues`,
);
expect(twiceImportedAgents).toHaveLength(2);
expect(new Set(twiceImportedAgents.map((agent) => agent.name)).size).toBe(2);
expect(twiceImportedProjects).toHaveLength(2);
expect(twiceImportedIssues).toHaveLength(2);
const zipPath = path.join(tempRoot, "exported-company.zip");
const portableFiles: Record<string, string> = {};
collectTextFiles(exportDir, exportDir, portableFiles);
writeFileSync(zipPath, createStoredZipArchive(portableFiles, "paperclip-demo"));
const importedFromZip = await runCliJson<{
company: { id: string; name: string; action: string };
agents: Array<{ id: string | null; action: string; name: string }>;
}>(
[
"company",
"import",
zipPath,
"--target",
"new",
"--new-company-name",
`Zip Imported ${sourceCompany.name}`,
"--include",
"company,agents,projects,issues",
"--yes",
],
{ apiBase, configPath },
);
expect(importedFromZip.company.action).toBe("created");
expect(importedFromZip.agents.some((agent) => agent.action === "created")).toBe(true);
}, 60_000);
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
isGithubShorthand,
isGithubUrl,
isHttpUrl,
normalizeGithubImportSource,
} from "../commands/client/company.js";
describe("isHttpUrl", () => {
it("matches http URLs", () => {
expect(isHttpUrl("http://example.com/foo")).toBe(true);
});
it("matches https URLs", () => {
expect(isHttpUrl("https://example.com/foo")).toBe(true);
});
it("rejects local paths", () => {
expect(isHttpUrl("/tmp/my-company")).toBe(false);
expect(isHttpUrl("./relative")).toBe(false);
});
});
describe("isGithubUrl", () => {
it("matches GitHub URLs", () => {
expect(isGithubUrl("https://github.com/org/repo")).toBe(true);
});
it("rejects non-GitHub HTTP URLs", () => {
expect(isGithubUrl("https://example.com/foo")).toBe(false);
});
it("rejects local paths", () => {
expect(isGithubUrl("/tmp/my-company")).toBe(false);
});
});
describe("isGithubShorthand", () => {
it("matches owner/repo/path shorthands", () => {
expect(isGithubShorthand("paperclipai/companies/gstack")).toBe(true);
expect(isGithubShorthand("paperclipai/companies")).toBe(true);
});
it("rejects local-looking paths", () => {
expect(isGithubShorthand("./exports/acme")).toBe(false);
expect(isGithubShorthand("/tmp/acme")).toBe(false);
expect(isGithubShorthand("C:\\temp\\acme")).toBe(false);
});
});
describe("normalizeGithubImportSource", () => {
it("normalizes shorthand imports to canonical GitHub sources", () => {
expect(normalizeGithubImportSource("paperclipai/companies/gstack")).toBe(
"https://github.com/paperclipai/companies?ref=main&path=gstack",
);
});
it("applies --ref to shorthand imports", () => {
expect(normalizeGithubImportSource("paperclipai/companies/gstack", "feature/demo")).toBe(
"https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack",
);
});
it("applies --ref to existing GitHub tree URLs without losing the package path", () => {
expect(
normalizeGithubImportSource(
"https://github.com/paperclipai/companies/tree/main/gstack",
"release/2026-03-23",
),
).toBe(
"https://github.com/paperclipai/companies?ref=release%2F2026-03-23&path=gstack",
);
});
});
@@ -0,0 +1,44 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveInlineSourceFromPath } from "../commands/client/company.js";
import { createStoredZipArchive } from "./helpers/zip.js";
const tempDirs: string[] = [];
afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
});
describe("resolveInlineSourceFromPath", () => {
it("imports portable files from a zip archive instead of scanning the parent directory", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-company-import-zip-"));
tempDirs.push(tempDir);
const archivePath = path.join(tempDir, "paperclip-demo.zip");
const archive = createStoredZipArchive(
{
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
"notes/todo.txt": "ignore me\n",
},
"paperclip-demo",
);
await writeFile(archivePath, archive);
const resolved = await resolveInlineSourceFromPath(archivePath);
expect(resolved).toEqual({
rootPath: "paperclip-demo",
files: {
"COMPANY.md": "# Company\n",
".paperclip.yaml": "schema: paperclip/v1\n",
"agents/ceo/AGENT.md": "# CEO\n",
},
});
});
});
@@ -0,0 +1,587 @@
import { describe, expect, it } from "vitest";
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
import {
buildCompanyDashboardUrl,
buildDefaultImportAdapterOverrides,
buildDefaultImportSelectionState,
buildImportSelectionCatalog,
buildSelectedFilesFromImportSelection,
renderCompanyImportPreview,
renderCompanyImportResult,
resolveCompanyImportApplyConfirmationMode,
resolveCompanyImportApiPath,
} from "../commands/client/company.js";
describe("resolveCompanyImportApiPath", () => {
it("uses company-scoped preview route for existing-company dry runs", () => {
expect(
resolveCompanyImportApiPath({
dryRun: true,
targetMode: "existing_company",
companyId: "company-123",
}),
).toBe("/api/companies/company-123/imports/preview");
});
it("uses company-scoped apply route for existing-company imports", () => {
expect(
resolveCompanyImportApiPath({
dryRun: false,
targetMode: "existing_company",
companyId: "company-123",
}),
).toBe("/api/companies/company-123/imports/apply");
});
it("keeps global routes for new-company imports", () => {
expect(
resolveCompanyImportApiPath({
dryRun: true,
targetMode: "new_company",
}),
).toBe("/api/companies/import/preview");
expect(
resolveCompanyImportApiPath({
dryRun: false,
targetMode: "new_company",
}),
).toBe("/api/companies/import");
});
it("throws when an existing-company import is missing a company id", () => {
expect(() =>
resolveCompanyImportApiPath({
dryRun: true,
targetMode: "existing_company",
companyId: " ",
})
).toThrow(/require a companyId/i);
});
});
describe("resolveCompanyImportApplyConfirmationMode", () => {
it("skips confirmation when --yes is set", () => {
expect(
resolveCompanyImportApplyConfirmationMode({
yes: true,
interactive: false,
json: false,
}),
).toBe("skip");
});
it("prompts in interactive text mode when --yes is not set", () => {
expect(
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: true,
json: false,
}),
).toBe("prompt");
});
it("requires --yes for non-interactive apply", () => {
expect(() =>
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: false,
json: false,
})
).toThrow(/non-interactive terminal requires --yes/i);
});
it("requires --yes for json apply", () => {
expect(() =>
resolveCompanyImportApplyConfirmationMode({
yes: false,
interactive: false,
json: true,
})
).toThrow(/with --json requires --yes/i);
});
});
describe("buildCompanyDashboardUrl", () => {
it("preserves the configured base path when building a dashboard URL", () => {
expect(buildCompanyDashboardUrl("https://paperclip.example/app/", "PAP")).toBe(
"https://paperclip.example/app/PAP/dashboard",
);
});
});
describe("renderCompanyImportPreview", () => {
it("summarizes the preview with counts, selection info, and truncated examples", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo", "cto", "eng-1", "eng-2", "eng-3", "eng-4", "eng-5"],
plan: {
companyAction: "update",
agentPlans: [
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
{ slug: "cto", action: "update", plannedName: "CTO", existingAgentId: "agent-2", reason: "replace strategy" },
{ slug: "eng-1", action: "skip", plannedName: "Engineer 1", existingAgentId: "agent-3", reason: "skip strategy" },
{ slug: "eng-2", action: "create", plannedName: "Engineer 2", existingAgentId: null, reason: null },
{ slug: "eng-3", action: "create", plannedName: "Engineer 3", existingAgentId: null, reason: null },
{ slug: "eng-4", action: "create", plannedName: "Engineer 4", existingAgentId: null, reason: null },
{ slug: "eng-5", action: "create", plannedName: "Engineer 5", existingAgentId: null, reason: null },
],
projectPlans: [
{ slug: "alpha", action: "create", plannedName: "Alpha", existingProjectId: null, reason: null },
],
issuePlans: [
{ slug: "kickoff", action: "create", plannedTitle: "Kickoff", reason: null },
],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T17:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
brandColor: null,
logoPath: null,
requireBoardApprovalForNewAgents: false,
},
sidebar: {
agents: ["ceo"],
projects: ["alpha"],
},
agents: [
{
slug: "ceo",
name: "CEO",
path: "agents/ceo/AGENT.md",
skills: [],
role: "ceo",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [
{
key: "skill-a",
slug: "skill-a",
name: "Skill A",
path: "skills/skill-a/SKILL.md",
description: null,
sourceType: "inline",
sourceLocator: null,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [],
},
],
projects: [
{
slug: "alpha",
name: "Alpha",
path: "projects/alpha/PROJECT.md",
description: null,
ownerAgentSlug: null,
leadAgentSlug: null,
targetDate: null,
color: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
],
issues: [
{
slug: "kickoff",
identifier: null,
title: "Kickoff",
path: "projects/alpha/issues/kickoff/TASK.md",
projectSlug: "alpha",
projectWorkspaceKey: null,
assigneeAgentSlug: "ceo",
description: null,
recurring: false,
routine: null,
legacyRecurrence: null,
status: null,
priority: null,
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
metadata: null,
},
],
envInputs: [
{
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
],
},
files: {
"COMPANY.md": "# Source Co",
},
envInputs: [
{
key: "OPENAI_API_KEY",
description: null,
agentSlug: "ceo",
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
],
warnings: ["One warning"],
errors: ["One error"],
};
const rendered = renderCompanyImportPreview(preview, {
sourceLabel: "GitHub: https://github.com/paperclipai/companies/demo",
targetLabel: "Imported Co (company-123)",
infoMessages: ["Using claude-local adapter"],
});
expect(rendered).toContain("Include");
expect(rendered).toContain("company, projects, tasks, agents, skills");
expect(rendered).toContain("7 agents total");
expect(rendered).toContain("1 project total");
expect(rendered).toContain("1 task total");
expect(rendered).toContain("skills: 1 skill packaged");
expect(rendered).toContain("+1 more");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Warnings");
expect(rendered).toContain("Errors");
});
});
describe("renderCompanyImportResult", () => {
it("summarizes import results with created, updated, and skipped counts", () => {
const rendered = renderCompanyImportResult(
{
company: {
id: "company-123",
name: "Imported Co",
action: "updated",
},
agents: [
{ slug: "ceo", id: "agent-1", action: "created", name: "CEO", reason: null },
{ slug: "cto", id: "agent-2", action: "updated", name: "CTO", reason: "replace strategy" },
{ slug: "ops", id: null, action: "skipped", name: "Ops", reason: "skip strategy" },
],
projects: [
{ slug: "app", id: "project-1", action: "created", name: "App", reason: null },
{ slug: "ops", id: "project-2", action: "updated", name: "Operations", reason: "replace strategy" },
{ slug: "archive", id: null, action: "skipped", name: "Archive", reason: "skip strategy" },
],
envInputs: [],
warnings: ["Review API keys"],
},
{
targetLabel: "Imported Co (company-123)",
companyUrl: "https://paperclip.example/PAP/dashboard",
infoMessages: ["Using claude-local adapter"],
},
);
expect(rendered).toContain("Company");
expect(rendered).toContain("https://paperclip.example/PAP/dashboard");
expect(rendered).toContain("3 agents total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("3 projects total (1 created, 1 updated, 1 skipped)");
expect(rendered).toContain("Agent results");
expect(rendered).toContain("Project results");
expect(rendered).toContain("Using claude-local adapter");
expect(rendered).toContain("Review API keys");
});
});
describe("import selection catalog", () => {
it("defaults to everything and keeps project selection separate from task selection", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
targetCompanyId: "company-123",
targetCompanyName: "Imported Co",
collisionStrategy: "rename",
selectedAgentSlugs: ["ceo"],
plan: {
companyAction: "create",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:00:00.000Z",
source: {
companyId: "company-src",
companyName: "Source Co",
},
includes: {
company: true,
agents: true,
projects: true,
issues: true,
skills: true,
},
company: {
path: "COMPANY.md",
name: "Source Co",
description: null,
brandColor: null,
logoPath: "images/company-logo.png",
requireBoardApprovalForNewAgents: false,
},
sidebar: {
agents: ["ceo"],
projects: ["alpha"],
},
agents: [
{
slug: "ceo",
name: "CEO",
path: "agents/ceo/AGENT.md",
skills: [],
role: "ceo",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [
{
key: "skill-a",
slug: "skill-a",
name: "Skill A",
path: "skills/skill-a/SKILL.md",
description: null,
sourceType: "inline",
sourceLocator: null,
sourceRef: null,
trustLevel: null,
compatibility: null,
metadata: null,
fileInventory: [{ path: "skills/skill-a/helper.md", kind: "doc" }],
},
],
projects: [
{
slug: "alpha",
name: "Alpha",
path: "projects/alpha/PROJECT.md",
description: null,
ownerAgentSlug: null,
leadAgentSlug: null,
targetDate: null,
color: null,
status: null,
executionWorkspacePolicy: null,
workspaces: [],
metadata: null,
},
],
issues: [
{
slug: "kickoff",
identifier: null,
title: "Kickoff",
path: "projects/alpha/issues/kickoff/TASK.md",
projectSlug: "alpha",
projectWorkspaceKey: null,
assigneeAgentSlug: "ceo",
description: null,
recurring: false,
routine: null,
legacyRecurrence: null,
status: null,
priority: null,
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
metadata: null,
},
],
envInputs: [],
},
files: {
"COMPANY.md": "# Source Co",
"README.md": "# Readme",
".paperclip.yaml": "schema: paperclip/v1\n",
"images/company-logo.png": {
encoding: "base64",
data: "",
contentType: "image/png",
},
"projects/alpha/PROJECT.md": "# Alpha",
"projects/alpha/notes.md": "project notes",
"projects/alpha/issues/kickoff/TASK.md": "# Kickoff",
"projects/alpha/issues/kickoff/details.md": "task details",
"agents/ceo/AGENT.md": "# CEO",
"agents/ceo/prompt.md": "prompt",
"skills/skill-a/SKILL.md": "# Skill A",
"skills/skill-a/helper.md": "helper",
},
envInputs: [],
warnings: [],
errors: [],
};
const catalog = buildImportSelectionCatalog(preview);
const state = buildDefaultImportSelectionState(catalog);
expect(state.company).toBe(true);
expect(state.projects.has("alpha")).toBe(true);
expect(state.issues.has("kickoff")).toBe(true);
expect(state.agents.has("ceo")).toBe(true);
expect(state.skills.has("skill-a")).toBe(true);
state.company = false;
state.issues.clear();
state.agents.clear();
state.skills.clear();
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
expect(selectedFiles).toContain(".paperclip.yaml");
expect(selectedFiles).toContain("projects/alpha/PROJECT.md");
expect(selectedFiles).toContain("projects/alpha/notes.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md");
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md");
});
});
describe("default adapter overrides", () => {
it("maps process-only imported agents to claude_local", () => {
const preview: CompanyPortabilityPreviewResult = {
include: {
company: false,
agents: true,
projects: false,
issues: false,
skills: false,
},
targetCompanyId: null,
targetCompanyName: null,
collisionStrategy: "rename",
selectedAgentSlugs: ["legacy-agent", "explicit-agent"],
plan: {
companyAction: "none",
agentPlans: [],
projectPlans: [],
issuePlans: [],
},
manifest: {
schemaVersion: 1,
generatedAt: "2026-03-23T18:20:00.000Z",
source: null,
includes: {
company: false,
agents: true,
projects: false,
issues: false,
skills: false,
},
company: null,
sidebar: null,
agents: [
{
slug: "legacy-agent",
name: "Legacy Agent",
path: "agents/legacy-agent/AGENT.md",
skills: [],
role: "agent",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
{
slug: "explicit-agent",
name: "Explicit Agent",
path: "agents/explicit-agent/AGENT.md",
skills: [],
role: "agent",
title: null,
icon: null,
capabilities: null,
reportsToSlug: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
budgetMonthlyCents: 0,
metadata: null,
},
],
skills: [],
projects: [],
issues: [],
envInputs: [],
},
files: {},
envInputs: [],
warnings: [],
errors: [],
};
expect(buildDefaultImportAdapterOverrides(preview)).toEqual({
"legacy-agent": {
adapterType: "claude_local",
},
});
});
});
@@ -0,0 +1,70 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
defaultClientContext,
readContext,
setCurrentProfile,
upsertProfile,
writeContext,
} from "../client/context.js";
function createTempContextPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-context-"));
return path.join(dir, "context.json");
}
describe("client context store", () => {
it("returns default context when file does not exist", () => {
const contextPath = createTempContextPath();
const context = readContext(contextPath);
expect(context).toEqual(defaultClientContext());
});
it("upserts profile values and switches current profile", () => {
const contextPath = createTempContextPath();
upsertProfile(
"work",
{
apiBase: "http://localhost:3100",
companyId: "company-123",
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
},
contextPath,
);
setCurrentProfile("work", contextPath);
const context = readContext(contextPath);
expect(context.currentProfile).toBe("work");
expect(context.profiles.work).toEqual({
apiBase: "http://localhost:3100",
companyId: "company-123",
apiKeyEnvVarName: "PAPERCLIP_AGENT_TOKEN",
});
});
it("normalizes invalid file content to safe defaults", () => {
const contextPath = createTempContextPath();
writeContext(
{
version: 1,
currentProfile: "x",
profiles: {
x: {
apiBase: " ",
companyId: " ",
apiKeyEnvVarName: " ",
},
},
},
contextPath,
);
const context = readContext(contextPath);
expect(context.currentProfile).toBe("x");
expect(context.profiles.x).toEqual({});
});
});
@@ -0,0 +1,79 @@
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { applyDataDirOverride } from "../config/data-dir.js";
const ORIGINAL_ENV = { ...process.env };
describe("applyDataDirOverride", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_CONFIG;
delete process.env.PAPERCLIP_CONTEXT;
delete process.env.PAPERCLIP_INSTANCE_ID;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("sets PAPERCLIP_HOME and isolated default config/context paths", () => {
const home = applyDataDirOverride({
dataDir: "~/paperclip-data",
config: undefined,
context: undefined,
}, { hasConfigOption: true, hasContextOption: true });
const expectedHome = path.resolve(os.homedir(), "paperclip-data");
expect(home).toBe(expectedHome);
expect(process.env.PAPERCLIP_HOME).toBe(expectedHome);
expect(process.env.PAPERCLIP_CONFIG).toBe(
path.resolve(expectedHome, "instances", "default", "config.json"),
);
expect(process.env.PAPERCLIP_CONTEXT).toBe(path.resolve(expectedHome, "context.json"));
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("default");
});
it("uses the provided instance id when deriving default config path", () => {
const home = applyDataDirOverride({
dataDir: "/tmp/paperclip-alt",
instance: "dev_1",
config: undefined,
context: undefined,
}, { hasConfigOption: true, hasContextOption: true });
expect(home).toBe(path.resolve("/tmp/paperclip-alt"));
expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("dev_1");
expect(process.env.PAPERCLIP_CONFIG).toBe(
path.resolve("/tmp/paperclip-alt", "instances", "dev_1", "config.json"),
);
});
it("does not override explicit config/context settings", () => {
process.env.PAPERCLIP_CONFIG = "/env/config.json";
process.env.PAPERCLIP_CONTEXT = "/env/context.json";
applyDataDirOverride({
dataDir: "/tmp/paperclip-alt",
config: "/flag/config.json",
context: "/flag/context.json",
}, { hasConfigOption: true, hasContextOption: true });
expect(process.env.PAPERCLIP_CONFIG).toBe("/env/config.json");
expect(process.env.PAPERCLIP_CONTEXT).toBe("/env/context.json");
});
it("only applies defaults for options supported by the command", () => {
applyDataDirOverride(
{
dataDir: "/tmp/paperclip-alt",
},
{ hasConfigOption: false, hasContextOption: false },
);
expect(process.env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-alt"));
expect(process.env.PAPERCLIP_CONFIG).toBeUndefined();
expect(process.env.PAPERCLIP_CONTEXT).toBeUndefined();
});
});
@@ -0,0 +1,99 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { doctor } from "../commands/doctor.js";
import { writeConfig } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
function createTempConfig(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-doctor-"));
const configPath = path.join(root, ".paperclip", "config.json");
const runtimeRoot = path.join(root, "runtime");
const config: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: "2026-03-10T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: path.join(runtimeRoot, "db"),
embeddedPostgresPort: 55432,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: path.join(runtimeRoot, "backups"),
},
},
logging: {
mode: "file",
logDir: path.join(runtimeRoot, "logs"),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3199,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: path.join(runtimeRoot, "storage"),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: path.join(runtimeRoot, "secrets", "master.key"),
},
},
};
writeConfig(config, configPath);
return configPath;
}
describe("doctor", () => {
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("re-runs repairable checks so repaired failures do not remain blocking", async () => {
const configPath = createTempConfig();
const summary = await doctor({
config: configPath,
repair: true,
yes: true,
});
expect(summary.failed).toBe(0);
expect(summary.warned).toBe(0);
expect(process.env.PAPERCLIP_AGENT_JWT_SECRET).toBeTruthy();
});
});
@@ -0,0 +1,87 @@
function writeUint16(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
}
function writeUint32(target: Uint8Array, offset: number, value: number) {
target[offset] = value & 0xff;
target[offset + 1] = (value >>> 8) & 0xff;
target[offset + 2] = (value >>> 16) & 0xff;
target[offset + 3] = (value >>> 24) & 0xff;
}
function crc32(bytes: Uint8Array) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc & 1) === 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
export function createStoredZipArchive(files: Record<string, string>, rootPath: string) {
const encoder = new TextEncoder();
const localChunks: Uint8Array[] = [];
const centralChunks: Uint8Array[] = [];
let localOffset = 0;
let entryCount = 0;
for (const [relativePath, content] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) {
const fileName = encoder.encode(`${rootPath}/${relativePath}`);
const body = encoder.encode(content);
const checksum = crc32(body);
const localHeader = new Uint8Array(30 + fileName.length);
writeUint32(localHeader, 0, 0x04034b50);
writeUint16(localHeader, 4, 20);
writeUint16(localHeader, 6, 0x0800);
writeUint16(localHeader, 8, 0);
writeUint32(localHeader, 14, checksum);
writeUint32(localHeader, 18, body.length);
writeUint32(localHeader, 22, body.length);
writeUint16(localHeader, 26, fileName.length);
localHeader.set(fileName, 30);
const centralHeader = new Uint8Array(46 + fileName.length);
writeUint32(centralHeader, 0, 0x02014b50);
writeUint16(centralHeader, 4, 20);
writeUint16(centralHeader, 6, 20);
writeUint16(centralHeader, 8, 0x0800);
writeUint16(centralHeader, 10, 0);
writeUint32(centralHeader, 16, checksum);
writeUint32(centralHeader, 20, body.length);
writeUint32(centralHeader, 24, body.length);
writeUint16(centralHeader, 28, fileName.length);
writeUint32(centralHeader, 42, localOffset);
centralHeader.set(fileName, 46);
localChunks.push(localHeader, body);
centralChunks.push(centralHeader);
localOffset += localHeader.length + body.length;
entryCount += 1;
}
const centralDirectoryLength = centralChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const archive = new Uint8Array(
localChunks.reduce((sum, chunk) => sum + chunk.length, 0) + centralDirectoryLength + 22,
);
let offset = 0;
for (const chunk of localChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
const centralDirectoryOffset = offset;
for (const chunk of centralChunks) {
archive.set(chunk, offset);
offset += chunk.length;
}
writeUint32(archive, offset, 0x06054b50);
writeUint16(archive, offset + 8, entryCount);
writeUint16(archive, offset + 10, entryCount);
writeUint32(archive, offset + 12, centralDirectoryLength);
writeUint32(archive, offset + 16, centralDirectoryOffset);
return archive;
}
@@ -0,0 +1,44 @@
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
describeLocalInstancePaths,
expandHomePrefix,
resolvePaperclipHomeDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
const ORIGINAL_ENV = { ...process.env };
describe("home path resolution", () => {
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
});
it("defaults to ~/.paperclip and default instance", () => {
delete process.env.PAPERCLIP_HOME;
delete process.env.PAPERCLIP_INSTANCE_ID;
const paths = describeLocalInstancePaths();
expect(paths.homeDir).toBe(path.resolve(os.homedir(), ".paperclip"));
expect(paths.instanceId).toBe("default");
expect(paths.configPath).toBe(path.resolve(os.homedir(), ".paperclip", "instances", "default", "config.json"));
});
it("supports PAPERCLIP_HOME and explicit instance ids", () => {
process.env.PAPERCLIP_HOME = "~/paperclip-home";
const home = resolvePaperclipHomeDir();
expect(home).toBe(path.resolve(os.homedir(), "paperclip-home"));
expect(resolvePaperclipInstanceId("dev_1")).toBe("dev_1");
});
it("rejects invalid instance ids", () => {
expect(() => resolvePaperclipInstanceId("bad/id")).toThrow(/Invalid instance id/);
});
it("expands ~ prefixes", () => {
expect(expandHomePrefix("~")).toBe(os.homedir());
expect(expandHomePrefix("~/x/y")).toBe(path.resolve(os.homedir(), "x/y"));
});
});
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiConnectionError, ApiRequestError, PaperclipApiClient } from "../client/http.js";
describe("PaperclipApiClient", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("adds authorization and run-id headers", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({
apiBase: "http://localhost:3100",
apiKey: "token-123",
runId: "run-abc",
});
await client.post("/api/test", { hello: "world" });
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0] as [string, RequestInit];
expect(call[0]).toContain("/api/test");
const headers = call[1].headers as Record<string, string>;
expect(headers.authorization).toBe("Bearer token-123");
expect(headers["x-paperclip-run-id"]).toBe("run-abc");
expect(headers["content-type"]).toBe("application/json");
});
it("returns null on ignoreNotFound", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
);
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
const result = await client.get("/api/missing", { ignoreNotFound: true });
expect(result).toBeNull();
});
it("throws ApiRequestError with details", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ error: "Issue checkout conflict", details: { issueId: "1" } }),
{ status: 409 },
),
);
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
await expect(client.post("/api/issues/1/checkout", {})).rejects.toMatchObject({
status: 409,
message: "Issue checkout conflict",
details: { issueId: "1" },
} satisfies Partial<ApiRequestError>);
});
it("throws ApiConnectionError with recovery guidance when fetch fails", async () => {
const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
vi.stubGlobal("fetch", fetchMock);
const client = new PaperclipApiClient({ apiBase: "http://localhost:3100" });
await expect(client.post("/api/companies/import/preview", {})).rejects.toBeInstanceOf(ApiConnectionError);
await expect(client.post("/api/companies/import/preview", {})).rejects.toMatchObject({
url: "http://localhost:3100/api/companies/import/preview",
method: "POST",
causeMessage: "fetch failed",
} satisfies Partial<ApiConnectionError>);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/Could not reach the Paperclip API\./,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/curl http:\/\/localhost:3100\/api\/health/,
);
await expect(client.post("/api/companies/import/preview", {})).rejects.toThrow(
/pnpm dev|pnpm paperclipai run/,
);
});
it("retries once after interactive auth recovery", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Board access required" }), { status: 403 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const recoverAuth = vi.fn().mockResolvedValue("board-token-123");
const client = new PaperclipApiClient({
apiBase: "http://localhost:3100",
recoverAuth,
});
const result = await client.post<{ ok: boolean }>("/api/test", { hello: "world" });
expect(result).toEqual({ ok: true });
expect(recoverAuth).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledTimes(2);
const retryHeaders = fetchMock.mock.calls[1]?.[1]?.headers as Record<string, string>;
expect(retryHeaders.authorization).toBe("Bearer board-token-123");
});
});
@@ -0,0 +1,492 @@
import { describe, expect, it } from "vitest";
import { buildWorktreeMergePlan, parseWorktreeMergeScopes } from "../commands/worktree-merge-history-lib.js";
function makeIssue(overrides: Record<string, unknown> = {}) {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: "goal-1",
parentId: null,
title: "Issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "local-board",
issueNumber: 1,
identifier: "PAP-1",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeComment(overrides: Record<string, unknown> = {}) {
return {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "local-board",
body: "hello",
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeIssueDocument(overrides: Record<string, unknown> = {}) {
return {
id: "issue-document-1",
companyId: "company-1",
issueId: "issue-1",
documentId: "document-1",
key: "plan",
linkCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
linkUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
title: "Plan",
format: "markdown",
latestBody: "# Plan",
latestRevisionId: "revision-1",
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: "local-board",
updatedByAgentId: null,
updatedByUserId: "local-board",
documentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
documentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeDocumentRevision(overrides: Record<string, unknown> = {}) {
return {
id: "revision-1",
companyId: "company-1",
documentId: "document-1",
revisionNumber: 1,
body: "# Plan",
changeSummary: null,
createdByAgentId: null,
createdByUserId: "local-board",
createdAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeAttachment(overrides: Record<string, unknown> = {}) {
return {
id: "attachment-1",
companyId: "company-1",
issueId: "issue-1",
issueCommentId: null,
assetId: "asset-1",
provider: "local_disk",
objectKey: "company-1/issues/issue-1/2026/03/20/asset.png",
contentType: "image/png",
byteSize: 12,
sha256: "deadbeef",
originalFilename: "asset.png",
createdByAgentId: null,
createdByUserId: "local-board",
assetCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
assetUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
attachmentCreatedAt: new Date("2026-03-20T00:00:00.000Z"),
attachmentUpdatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeProject(overrides: Record<string, unknown> = {}) {
return {
id: "project-1",
companyId: "company-1",
goalId: null,
name: "Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#22c55e",
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
archivedAt: null,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
function makeProjectWorkspace(overrides: Record<string, unknown> = {}) {
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Workspace",
sourceType: "local_path",
cwd: "/tmp/project",
repoUrl: "https://github.com/example/project.git",
repoRef: "main",
defaultRef: "main",
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
isPrimary: true,
createdAt: new Date("2026-03-20T00:00:00.000Z"),
updatedAt: new Date("2026-03-20T00:00:00.000Z"),
...overrides,
} as any;
}
describe("worktree merge history planner", () => {
it("parses default scopes", () => {
expect(parseWorktreeMergeScopes(undefined)).toEqual(["issues", "comments"]);
expect(parseWorktreeMergeScopes("issues")).toEqual(["issues"]);
});
it("dedupes nested worktree issues by preserved source uuid", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10", title: "Shared" });
const branchOneIssue = makeIssue({
id: "issue-b",
identifier: "PAP-22",
title: "Branch one issue",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const branchTwoIssue = makeIssue({
id: "issue-c",
identifier: "PAP-23",
title: "Branch two issue",
createdAt: new Date("2026-03-20T02:00:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 500,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue, branchOneIssue, branchTwoIssue],
targetIssues: [sharedIssue, branchOneIssue],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.issuesToInsert).toBe(1);
expect(plan.issuePlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual(["issue-c"]);
expect(plan.issuePlans.find((item) => item.source.id === "issue-c" && item.action === "insert")).toMatchObject({
previewIdentifier: "PAP-501",
});
});
it("clears missing references and coerces in_progress without an assignee", () => {
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-x",
identifier: "PAP-99",
status: "in_progress",
assigneeAgentId: "agent-missing",
projectId: "project-missing",
projectWorkspaceId: "workspace-missing",
goalId: "goal-missing",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [],
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetStatus).toBe("todo");
expect(insert.targetAssigneeAgentId).toBeNull();
expect(insert.targetProjectId).toBeNull();
expect(insert.targetProjectWorkspaceId).toBeNull();
expect(insert.targetGoalId).toBeNull();
expect(insert.adjustments).toEqual([
"clear_assignee_agent",
"clear_project",
"clear_project_workspace",
"clear_goal",
"coerce_in_progress_to_todo",
]);
});
it("applies an explicit project mapping override instead of clearing the project", () => {
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-project-map",
identifier: "PAP-77",
projectId: "source-project-1",
projectWorkspaceId: "source-workspace-1",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
targetAgents: [],
targetProjects: [{ id: "target-project-1", name: "Mapped project", status: "in_progress" }] as any,
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
projectIdOverrides: {
"source-project-1": "target-project-1",
},
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetProjectId).toBe("target-project-1");
expect(insert.projectResolution).toBe("mapped");
expect(insert.mappedProjectName).toBe("Mapped project");
expect(insert.targetProjectWorkspaceId).toBeNull();
expect(insert.adjustments).toEqual(["clear_project_workspace"]);
});
it("plans selected project imports and preserves project workspace links", () => {
const sourceProject = makeProject({
id: "source-project-1",
name: "Paperclip Evals",
goalId: "goal-1",
});
const sourceWorkspace = makeProjectWorkspace({
id: "source-workspace-1",
projectId: "source-project-1",
cwd: "/Users/dotta/paperclip-evals",
repoUrl: "https://github.com/paperclipai/paperclip-evals.git",
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [
makeIssue({
id: "issue-project-import",
identifier: "PAP-88",
projectId: "source-project-1",
projectWorkspaceId: "source-workspace-1",
}),
],
targetIssues: [],
sourceComments: [],
targetComments: [],
sourceProjects: [sourceProject],
sourceProjectWorkspaces: [sourceWorkspace],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
importProjectIds: ["source-project-1"],
});
expect(plan.counts.projectsToImport).toBe(1);
expect(plan.projectImports[0]).toMatchObject({
source: { id: "source-project-1", name: "Paperclip Evals" },
targetGoalId: "goal-1",
workspaces: [{ id: "source-workspace-1" }],
});
const insert = plan.issuePlans[0] as any;
expect(insert.targetProjectId).toBe("source-project-1");
expect(insert.targetProjectWorkspaceId).toBe("source-workspace-1");
expect(insert.projectResolution).toBe("imported");
expect(insert.mappedProjectName).toBe("Paperclip Evals");
expect(insert.adjustments).toEqual([]);
});
it("imports comments onto shared or newly imported issues while skipping existing comments", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const newIssue = makeIssue({
id: "issue-b",
identifier: "PAP-11",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const existingComment = makeComment({ id: "comment-existing", issueId: "issue-a" });
const sharedIssueComment = makeComment({ id: "comment-shared", issueId: "issue-a" });
const newIssueComment = makeComment({
id: "comment-new-issue",
issueId: "issue-b",
authorAgentId: "missing-agent",
createdAt: new Date("2026-03-20T01:05:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue, newIssue],
targetIssues: [sharedIssue],
sourceComments: [existingComment, sharedIssueComment, newIssueComment],
targetComments: [existingComment],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.commentsToInsert).toBe(2);
expect(plan.counts.commentsExisting).toBe(1);
expect(plan.commentPlans.filter((item) => item.action === "insert").map((item) => item.source.id)).toEqual([
"comment-shared",
"comment-new-issue",
]);
expect(plan.adjustments.clear_author_agent).toBe(1);
});
it("merges document revisions onto an existing shared document and renumbers conflicts", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const sourceDocument = makeIssueDocument({
issueId: "issue-a",
documentId: "document-a",
latestBody: "# Branch plan",
latestRevisionId: "revision-branch-2",
latestRevisionNumber: 2,
documentUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
linkUpdatedAt: new Date("2026-03-20T02:00:00.000Z"),
});
const targetDocument = makeIssueDocument({
issueId: "issue-a",
documentId: "document-a",
latestBody: "# Main plan",
latestRevisionId: "revision-main-2",
latestRevisionNumber: 2,
documentUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
linkUpdatedAt: new Date("2026-03-20T01:00:00.000Z"),
});
const sourceRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
const sourceRevisionTwo = makeDocumentRevision({
documentId: "document-a",
id: "revision-branch-2",
revisionNumber: 2,
body: "# Branch plan",
createdAt: new Date("2026-03-20T02:00:00.000Z"),
});
const targetRevisionOne = makeDocumentRevision({ documentId: "document-a", id: "revision-1" });
const targetRevisionTwo = makeDocumentRevision({
documentId: "document-a",
id: "revision-main-2",
revisionNumber: 2,
body: "# Main plan",
createdAt: new Date("2026-03-20T01:00:00.000Z"),
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues", "comments"],
sourceIssues: [sharedIssue],
targetIssues: [sharedIssue],
sourceComments: [],
targetComments: [],
sourceDocuments: [sourceDocument],
targetDocuments: [targetDocument],
sourceDocumentRevisions: [sourceRevisionOne, sourceRevisionTwo],
targetDocumentRevisions: [targetRevisionOne, targetRevisionTwo],
sourceAttachments: [],
targetAttachments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.documentsToMerge).toBe(1);
expect(plan.counts.documentRevisionsToInsert).toBe(1);
expect(plan.documentPlans[0]).toMatchObject({
action: "merge_existing",
latestRevisionId: "revision-branch-2",
latestRevisionNumber: 3,
});
const mergePlan = plan.documentPlans[0] as any;
expect(mergePlan.revisionsToInsert).toHaveLength(1);
expect(mergePlan.revisionsToInsert[0]).toMatchObject({
source: { id: "revision-branch-2" },
targetRevisionNumber: 3,
});
});
it("imports attachments while clearing missing comment and author references", () => {
const sharedIssue = makeIssue({ id: "issue-a", identifier: "PAP-10" });
const attachment = makeAttachment({
issueId: "issue-a",
issueCommentId: "comment-missing",
createdByAgentId: "agent-missing",
});
const plan = buildWorktreeMergePlan({
companyId: "company-1",
companyName: "Paperclip",
issuePrefix: "PAP",
previewIssueCounterStart: 10,
scopes: ["issues"],
sourceIssues: [sharedIssue],
targetIssues: [sharedIssue],
sourceComments: [],
targetComments: [],
sourceDocuments: [],
targetDocuments: [],
sourceDocumentRevisions: [],
targetDocumentRevisions: [],
sourceAttachments: [attachment],
targetAttachments: [],
targetAgents: [],
targetProjects: [],
targetProjectWorkspaces: [],
targetGoals: [{ id: "goal-1" }] as any,
});
expect(plan.counts.attachmentsToInsert).toBe(1);
expect(plan.adjustments.clear_attachment_agent).toBe(1);
expect(plan.attachmentPlans[0]).toMatchObject({
action: "insert",
targetIssueCommentId: null,
targetCreatedByAgentId: null,
});
});
});
@@ -0,0 +1,510 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
copyGitHooksToWorktreeGitDir,
copySeededSecretsKey,
readSourceAttachmentBody,
rebindWorkspaceCwd,
resolveSourceConfigPath,
resolveGitWorktreeAddArgs,
resolveWorktreeMakeTargetPath,
worktreeInitCommand,
worktreeMakeCommand,
} from "../commands/worktree.js";
import {
buildWorktreeConfig,
buildWorktreeEnvEntries,
formatShellExports,
generateWorktreeColor,
resolveWorktreeSeedPlan,
resolveWorktreeLocalPaths,
rewriteLocalUrlPort,
sanitizeWorktreeInstanceId,
} from "../commands/worktree-lib.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_CWD = process.cwd();
const ORIGINAL_ENV = { ...process.env };
afterEach(() => {
process.chdir(ORIGINAL_CWD);
for (const key of Object.keys(process.env)) {
if (!(key in ORIGINAL_ENV)) delete process.env[key];
}
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function buildSourceConfig(): PaperclipConfig {
return {
$meta: {
version: 1,
updatedAt: "2026-03-09T00:00:00.000Z",
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: "/tmp/main/db",
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: "/tmp/main/backups",
},
},
logging: {
mode: "file",
logDir: "/tmp/main/logs",
},
server: {
deploymentMode: "authenticated",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: ["localhost"],
serveUi: true,
},
auth: {
baseUrlMode: "explicit",
publicBaseUrl: "http://127.0.0.1:3100",
disableSignUp: false,
},
storage: {
provider: "local_disk",
localDisk: {
baseDir: "/tmp/main/storage",
},
s3: {
bucket: "paperclip",
region: "us-east-1",
prefix: "",
forcePathStyle: false,
},
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: "/tmp/main/secrets/master.key",
},
},
};
}
describe("worktree helpers", () => {
it("sanitizes instance ids", () => {
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
});
it("resolves worktree:make target paths under the user home directory", () => {
expect(resolveWorktreeMakeTargetPath("paperclip-pr-432")).toBe(
path.resolve(os.homedir(), "paperclip-pr-432"),
);
});
it("rejects worktree:make names that are not safe directory/branch names", () => {
expect(() => resolveWorktreeMakeTargetPath("paperclip/pr-432")).toThrow(
"Worktree name must contain only letters, numbers, dots, underscores, or dashes.",
);
});
it("builds git worktree add args for new and existing branches", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "feature-branch",
targetPath: "/tmp/feature-branch",
branchExists: false,
}),
).toEqual(["worktree", "add", "-b", "feature-branch", "/tmp/feature-branch", "HEAD"]);
expect(
resolveGitWorktreeAddArgs({
branchName: "feature-branch",
targetPath: "/tmp/feature-branch",
branchExists: true,
}),
).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]);
});
it("builds git worktree add args with a start point", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "my-worktree",
targetPath: "/tmp/my-worktree",
branchExists: false,
startPoint: "public-gh/master",
}),
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]);
});
it("uses start point even when a local branch with the same name exists", () => {
expect(
resolveGitWorktreeAddArgs({
branchName: "my-worktree",
targetPath: "/tmp/my-worktree",
branchExists: true,
startPoint: "origin/main",
}),
).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]);
});
it("rewrites loopback auth URLs to the new port only", () => {
expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/");
expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example");
});
it("builds isolated config and env paths for a worktree", () => {
const paths = resolveWorktreeLocalPaths({
cwd: "/tmp/paperclip-feature",
homeDir: "/tmp/paperclip-worktrees",
instanceId: "feature-worktree-support",
});
const config = buildWorktreeConfig({
sourceConfig: buildSourceConfig(),
paths,
serverPort: 3110,
databasePort: 54339,
now: new Date("2026-03-09T12:00:00.000Z"),
});
expect(config.database.embeddedPostgresDataDir).toBe(
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"),
);
expect(config.database.embeddedPostgresPort).toBe(54339);
expect(config.server.port).toBe(3110);
expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/");
expect(config.storage.localDisk.baseDir).toBe(
path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"),
);
const env = buildWorktreeEnvEntries(paths, {
name: "feature-worktree-support",
color: "#3abf7a",
});
expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees"));
expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support");
expect(env.PAPERCLIP_IN_WORKTREE).toBe("true");
expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support");
expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a");
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
});
it("falls back across storage roots before skipping a missing attachment object", async () => {
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
const expected = Buffer.from("image-bytes");
await expect(
readSourceAttachmentBody(
[
{
getObject: vi.fn().mockRejectedValue(missingErr),
},
{
getObject: vi.fn().mockResolvedValue(expected),
},
],
"company-1",
"company-1/issues/issue-1/missing.png",
),
).resolves.toEqual(expected);
});
it("returns null when an attachment object is missing from every lookup storage", async () => {
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
await expect(
readSourceAttachmentBody(
[
{
getObject: vi.fn().mockRejectedValue(missingErr),
},
{
getObject: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { status: 404 })),
},
],
"company-1",
"company-1/issues/issue-1/missing.png",
),
).resolves.toBeNull();
});
it("generates vivid worktree colors as hex", () => {
expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/);
});
it("uses minimal seed mode to keep app state but drop heavy runtime history", () => {
const minimal = resolveWorktreeSeedPlan("minimal");
const full = resolveWorktreeSeedPlan("full");
expect(minimal.excludedTables).toContain("heartbeat_runs");
expect(minimal.excludedTables).toContain("heartbeat_run_events");
expect(minimal.excludedTables).toContain("workspace_runtime_services");
expect(minimal.excludedTables).toContain("agent_task_sessions");
expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]);
expect(full.excludedTables).toEqual([]);
expect(full.nullifyColumns).toEqual({});
});
it("copies the source local_encrypted secrets key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
try {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
const sourceConfig = buildSourceConfig();
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
copySeededSecretsKey({
sourceConfigPath,
sourceConfig,
sourceEnvEntries: {},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key");
} finally {
if (originalInlineMasterKey === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey;
}
if (originalKeyFile === undefined) {
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
} else {
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("writes the source inline secrets master key into the seeded worktree instance", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-"));
try {
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key");
copySeededSecretsKey({
sourceConfigPath,
sourceConfig: buildSourceConfig(),
sourceEnvEntries: {
PAPERCLIP_SECRETS_MASTER_KEY: "inline-source-master-key",
},
targetKeyFilePath: targetKeyPath,
});
expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("inline-source-master-key");
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("persists the current agent jwt secret into the worktree env file", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
const repoRoot = path.join(tempRoot, "repo");
const originalCwd = process.cwd();
const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
try {
fs.mkdirSync(repoRoot, { recursive: true });
process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret";
process.chdir(repoRoot);
await worktreeInitCommand({
seed: false,
fromConfig: path.join(tempRoot, "missing", "config.json"),
home: path.join(tempRoot, ".paperclip-worktrees"),
});
const envPath = path.join(repoRoot, ".paperclip", ".env");
const envContents = fs.readFileSync(envPath, "utf8");
expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret");
expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo");
expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/);
} finally {
process.chdir(originalCwd);
if (originalJwtSecret === undefined) {
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
} else {
process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("defaults the seed source config to the current repo-local Paperclip config", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
const repoRoot = path.join(tempRoot, "repo");
const localConfigPath = path.join(repoRoot, ".paperclip", "config.json");
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(path.dirname(localConfigPath), { recursive: true });
fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
delete process.env.PAPERCLIP_CONFIG;
process.chdir(repoRoot);
expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath));
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("preserves the source config path across worktree:make cwd changes", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-"));
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
const targetRoot = path.join(tempRoot, "target");
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
fs.mkdirSync(targetRoot, { recursive: true });
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8");
delete process.env.PAPERCLIP_CONFIG;
process.chdir(targetRoot);
expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe(
path.resolve(sourceConfigPath),
);
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rebinds same-repo workspace paths onto the current worktree root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip",
}),
).toBe("/Users/example/paperclip-pr-432");
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/paperclip/packages/db",
}),
).toBe("/Users/example/paperclip-pr-432/packages/db");
});
it("does not rebind paths outside the source repo root", () => {
expect(
rebindWorkspaceCwd({
sourceRepoRoot: "/Users/example/paperclip",
targetRepoRoot: "/Users/example/paperclip-pr-432",
workspaceCwd: "/Users/example/other-project",
}),
).toBeNull();
});
it("copies shared git hooks into a linked worktree git dir", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-hooks-"));
const repoRoot = path.join(tempRoot, "repo");
const worktreePath = path.join(tempRoot, "repo-feature");
try {
fs.mkdirSync(repoRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
const sourceHooksDir = path.join(repoRoot, ".git", "hooks");
const sourceHookPath = path.join(sourceHooksDir, "pre-commit");
const sourceTokensPath = path.join(sourceHooksDir, "forbidden-tokens.txt");
fs.writeFileSync(sourceHookPath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
fs.chmodSync(sourceHookPath, 0o755);
fs.writeFileSync(sourceTokensPath, "secret-token\n", "utf8");
execFileSync("git", ["worktree", "add", "--detach", worktreePath], { cwd: repoRoot, stdio: "ignore" });
const copied = copyGitHooksToWorktreeGitDir(worktreePath);
const worktreeGitDir = execFileSync("git", ["rev-parse", "--git-dir"], {
cwd: worktreePath,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const resolvedSourceHooksDir = fs.realpathSync(sourceHooksDir);
const resolvedTargetHooksDir = fs.realpathSync(path.resolve(worktreePath, worktreeGitDir, "hooks"));
const targetHookPath = path.join(resolvedTargetHooksDir, "pre-commit");
const targetTokensPath = path.join(resolvedTargetHooksDir, "forbidden-tokens.txt");
expect(copied).toMatchObject({
sourceHooksPath: resolvedSourceHooksDir,
targetHooksPath: resolvedTargetHooksDir,
copied: true,
});
expect(fs.readFileSync(targetHookPath, "utf8")).toBe("#!/usr/bin/env bash\nexit 0\n");
expect(fs.statSync(targetHookPath).mode & 0o111).not.toBe(0);
expect(fs.readFileSync(targetTokensPath, "utf8")).toBe("secret-token\n");
} finally {
execFileSync("git", ["worktree", "remove", "--force", worktreePath], { cwd: repoRoot, stdio: "ignore" });
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("creates and initializes a worktree from the top-level worktree:make command", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-make-"));
const repoRoot = path.join(tempRoot, "repo");
const fakeHome = path.join(tempRoot, "home");
const worktreePath = path.join(fakeHome, "paperclip-make-test");
const originalCwd = process.cwd();
const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
try {
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(fakeHome, { recursive: true });
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
process.chdir(repoRoot);
await worktreeMakeCommand("paperclip-make-test", {
seed: false,
home: path.join(tempRoot, ".paperclip-worktrees"),
});
expect(fs.existsSync(path.join(worktreePath, ".git"))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
} finally {
process.chdir(originalCwd);
homedirSpy.mockRestore();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}, 20_000);
});
@@ -0,0 +1,4 @@
export function printHttpStdoutEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (line) console.log(line);
}
@@ -0,0 +1,7 @@
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printHttpStdoutEvent } from "./format-event.js";
export const httpCLIAdapter: CLIAdapterModule = {
type: "http",
formatStdoutEvent: printHttpStdoutEvent,
};
@@ -0,0 +1,2 @@
export { getCLIAdapter } from "./registry.js";
export type { CLIAdapterModule } from "@paperclipai/adapter-utils";
@@ -0,0 +1,4 @@
export function printProcessStdoutEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (line) console.log(line);
}
@@ -0,0 +1,7 @@
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printProcessStdoutEvent } from "./format-event.js";
export const processCLIAdapter: CLIAdapterModule = {
type: "process",
formatStdoutEvent: printProcessStdoutEvent,
};
@@ -0,0 +1,63 @@
import type { CLIAdapterModule } from "@paperclipai/adapter-utils";
import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli";
import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli";
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
import { processCLIAdapter } from "./process/index.js";
import { httpCLIAdapter } from "./http/index.js";
const claudeLocalCLIAdapter: CLIAdapterModule = {
type: "claude_local",
formatStdoutEvent: printClaudeStreamEvent,
};
const codexLocalCLIAdapter: CLIAdapterModule = {
type: "codex_local",
formatStdoutEvent: printCodexStreamEvent,
};
const openCodeLocalCLIAdapter: CLIAdapterModule = {
type: "opencode_local",
formatStdoutEvent: printOpenCodeStreamEvent,
};
const piLocalCLIAdapter: CLIAdapterModule = {
type: "pi_local",
formatStdoutEvent: printPiStreamEvent,
};
const cursorLocalCLIAdapter: CLIAdapterModule = {
type: "cursor",
formatStdoutEvent: printCursorStreamEvent,
};
const geminiLocalCLIAdapter: CLIAdapterModule = {
type: "gemini_local",
formatStdoutEvent: printGeminiStreamEvent,
};
const openclawGatewayCLIAdapter: CLIAdapterModule = {
type: "openclaw_gateway",
formatStdoutEvent: printOpenClawGatewayStreamEvent,
};
const adaptersByType = new Map<string, CLIAdapterModule>(
[
claudeLocalCLIAdapter,
codexLocalCLIAdapter,
openCodeLocalCLIAdapter,
piLocalCLIAdapter,
cursorLocalCLIAdapter,
geminiLocalCLIAdapter,
openclawGatewayCLIAdapter,
processCLIAdapter,
httpCLIAdapter,
].map((a) => [a.type, a]),
);
export function getCLIAdapter(type: string): CLIAdapterModule {
return adaptersByType.get(type) ?? processCLIAdapter;
}
@@ -0,0 +1,40 @@
import {
ensureAgentJwtSecret,
readAgentJwtSecretFromEnv,
readAgentJwtSecretFromEnvFile,
resolveAgentJwtEnvFile,
} from "../config/env.js";
import type { CheckResult } from "./index.js";
export function agentJwtSecretCheck(configPath?: string): CheckResult {
if (readAgentJwtSecretFromEnv(configPath)) {
return {
name: "Agent JWT secret",
status: "pass",
message: "PAPERCLIP_AGENT_JWT_SECRET is set in environment",
};
}
const envPath = resolveAgentJwtEnvFile(configPath);
const fileSecret = readAgentJwtSecretFromEnvFile(envPath);
if (fileSecret) {
return {
name: "Agent JWT secret",
status: "warn",
message: `PAPERCLIP_AGENT_JWT_SECRET is present in ${envPath} but not loaded into environment`,
repairHint: `Set the value from ${envPath} in your shell before starting the Paperclip server`,
};
}
return {
name: "Agent JWT secret",
status: "fail",
message: `PAPERCLIP_AGENT_JWT_SECRET missing from environment and ${envPath}`,
canRepair: true,
repair: () => {
ensureAgentJwtSecret(configPath);
},
repairHint: `Run with --repair to create ${envPath} containing PAPERCLIP_AGENT_JWT_SECRET`,
};
}
@@ -0,0 +1,33 @@
import { readConfig, configExists, resolveConfigPath } from "../config/store.js";
import type { CheckResult } from "./index.js";
export function configCheck(configPath?: string): CheckResult {
const filePath = resolveConfigPath(configPath);
if (!configExists(configPath)) {
return {
name: "Config file",
status: "fail",
message: `Config file not found at ${filePath}`,
canRepair: false,
repairHint: "Run `paperclipai onboard` to create one",
};
}
try {
readConfig(configPath);
return {
name: "Config file",
status: "pass",
message: `Valid config at ${filePath}`,
};
} catch (err) {
return {
name: "Config file",
status: "fail",
message: `Invalid config: ${err instanceof Error ? err.message : String(err)}`,
canRepair: false,
repairHint: "Run `paperclipai configure --section database` (or `paperclipai onboard` to recreate)",
};
}
}
@@ -0,0 +1,59 @@
import fs from "node:fs";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
import { resolveRuntimeLikePath } from "./path-resolver.js";
export async function databaseCheck(config: PaperclipConfig, configPath?: string): Promise<CheckResult> {
if (config.database.mode === "postgres") {
if (!config.database.connectionString) {
return {
name: "Database",
status: "fail",
message: "PostgreSQL mode selected but no connection string configured",
canRepair: false,
repairHint: "Run `paperclipai configure --section database`",
};
}
try {
const { createDb } = await import("@paperclipai/db");
const db = createDb(config.database.connectionString);
await db.execute("SELECT 1");
return {
name: "Database",
status: "pass",
message: "PostgreSQL connection successful",
};
} catch (err) {
return {
name: "Database",
status: "fail",
message: `Cannot connect to PostgreSQL: ${err instanceof Error ? err.message : String(err)}`,
canRepair: false,
repairHint: "Check your connection string and ensure PostgreSQL is running",
};
}
}
if (config.database.mode === "embedded-postgres") {
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
const reportedPath = dataDir;
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(reportedPath, { recursive: true });
}
return {
name: "Database",
status: "pass",
message: `Embedded PostgreSQL configured at ${dataDir} (port ${config.database.embeddedPostgresPort})`,
};
}
return {
name: "Database",
status: "fail",
message: `Unknown database mode: ${String(config.database.mode)}`,
canRepair: false,
repairHint: "Run `paperclipai configure --section database`",
};
}
@@ -0,0 +1,91 @@
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
function isLoopbackHost(host: string) {
const normalized = host.trim().toLowerCase();
return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1";
}
export function deploymentAuthCheck(config: PaperclipConfig): CheckResult {
const mode = config.server.deploymentMode;
const exposure = config.server.exposure;
const auth = config.auth;
if (mode === "local_trusted") {
if (!isLoopbackHost(config.server.host)) {
return {
name: "Deployment/auth mode",
status: "fail",
message: `local_trusted requires loopback host binding (found ${config.server.host})`,
canRepair: false,
repairHint: "Run `paperclipai configure --section server` and set host to 127.0.0.1",
};
}
return {
name: "Deployment/auth mode",
status: "pass",
message: "local_trusted mode is configured for loopback-only access",
};
}
const secret =
process.env.BETTER_AUTH_SECRET?.trim() ??
process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim();
if (!secret) {
return {
name: "Deployment/auth mode",
status: "fail",
message: "authenticated mode requires BETTER_AUTH_SECRET (or PAPERCLIP_AGENT_JWT_SECRET)",
canRepair: false,
repairHint: "Set BETTER_AUTH_SECRET before starting Paperclip",
};
}
if (auth.baseUrlMode === "explicit" && !auth.publicBaseUrl) {
return {
name: "Deployment/auth mode",
status: "fail",
message: "auth.baseUrlMode=explicit requires auth.publicBaseUrl",
canRepair: false,
repairHint: "Run `paperclipai configure --section server` and provide a base URL",
};
}
if (exposure === "public") {
if (auth.baseUrlMode !== "explicit" || !auth.publicBaseUrl) {
return {
name: "Deployment/auth mode",
status: "fail",
message: "authenticated/public requires explicit auth.publicBaseUrl",
canRepair: false,
repairHint: "Run `paperclipai configure --section server` and select public exposure",
};
}
try {
const url = new URL(auth.publicBaseUrl);
if (url.protocol !== "https:") {
return {
name: "Deployment/auth mode",
status: "warn",
message: "Public exposure should use an https:// auth.publicBaseUrl",
canRepair: false,
repairHint: "Use HTTPS in production for secure session cookies",
};
}
} catch {
return {
name: "Deployment/auth mode",
status: "fail",
message: "auth.publicBaseUrl is not a valid URL",
canRepair: false,
repairHint: "Run `paperclipai configure --section server` and provide a valid URL",
};
}
}
return {
name: "Deployment/auth mode",
status: "pass",
message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`,
};
}
@@ -0,0 +1,18 @@
export interface CheckResult {
name: string;
status: "pass" | "warn" | "fail";
message: string;
canRepair?: boolean;
repair?: () => void | Promise<void>;
repairHint?: string;
}
export { agentJwtSecretCheck } from "./agent-jwt-secret-check.js";
export { configCheck } from "./config-check.js";
export { deploymentAuthCheck } from "./deployment-auth-check.js";
export { databaseCheck } from "./database-check.js";
export { llmCheck } from "./llm-check.js";
export { logCheck } from "./log-check.js";
export { portCheck } from "./port-check.js";
export { secretsCheck } from "./secrets-check.js";
export { storageCheck } from "./storage-check.js";
@@ -0,0 +1,82 @@
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
export async function llmCheck(config: PaperclipConfig): Promise<CheckResult> {
if (!config.llm) {
return {
name: "LLM provider",
status: "pass",
message: "No LLM provider configured (optional)",
};
}
if (!config.llm.apiKey) {
return {
name: "LLM provider",
status: "pass",
message: `${config.llm.provider} configured but no API key set (optional)`,
};
}
try {
if (config.llm.provider === "claude") {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": config.llm.apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-5-20250929",
max_tokens: 1,
messages: [{ role: "user", content: "hi" }],
}),
});
if (res.ok || res.status === 400) {
return { name: "LLM provider", status: "pass", message: "Claude API key is valid" };
}
if (res.status === 401) {
return {
name: "LLM provider",
status: "fail",
message: "Claude API key is invalid (401)",
canRepair: false,
repairHint: "Run `paperclipai configure --section llm`",
};
}
return {
name: "LLM provider",
status: "warn",
message: `Claude API returned status ${res.status}`,
};
} else {
const res = await fetch("https://api.openai.com/v1/models", {
headers: { Authorization: `Bearer ${config.llm.apiKey}` },
});
if (res.ok) {
return { name: "LLM provider", status: "pass", message: "OpenAI API key is valid" };
}
if (res.status === 401) {
return {
name: "LLM provider",
status: "fail",
message: "OpenAI API key is invalid (401)",
canRepair: false,
repairHint: "Run `paperclipai configure --section llm`",
};
}
return {
name: "LLM provider",
status: "warn",
message: `OpenAI API returned status ${res.status}`,
};
}
} catch {
return {
name: "LLM provider",
status: "warn",
message: "Could not reach API to validate key",
};
}
}
@@ -0,0 +1,30 @@
import fs from "node:fs";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
import { resolveRuntimeLikePath } from "./path-resolver.js";
export function logCheck(config: PaperclipConfig, configPath?: string): CheckResult {
const logDir = resolveRuntimeLikePath(config.logging.logDir, configPath);
const reportedDir = logDir;
if (!fs.existsSync(logDir)) {
fs.mkdirSync(reportedDir, { recursive: true });
}
try {
fs.accessSync(reportedDir, fs.constants.W_OK);
return {
name: "Log directory",
status: "pass",
message: `Log directory is writable: ${reportedDir}`,
};
} catch {
return {
name: "Log directory",
status: "fail",
message: `Log directory is not writable: ${logDir}`,
canRepair: false,
repairHint: "Check file permissions on the log directory",
};
}
}
@@ -0,0 +1 @@
export { resolveRuntimeLikePath } from "../utils/path-resolver.js";
@@ -0,0 +1,24 @@
import type { PaperclipConfig } from "../config/schema.js";
import { checkPort } from "../utils/net.js";
import type { CheckResult } from "./index.js";
export async function portCheck(config: PaperclipConfig): Promise<CheckResult> {
const port = config.server.port;
const result = await checkPort(port);
if (result.available) {
return {
name: "Server port",
status: "pass",
message: `Port ${port} is available`,
};
}
return {
name: "Server port",
status: "warn",
message: result.error ?? `Port ${port} is not available`,
canRepair: false,
repairHint: `Check what's using port ${port} with: lsof -i :${port}`,
};
}
@@ -0,0 +1,146 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
import { resolveRuntimeLikePath } from "./path-resolver.js";
function decodeMasterKey(raw: string): Buffer | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^[A-Fa-f0-9]{64}$/.test(trimmed)) {
return Buffer.from(trimmed, "hex");
}
try {
const decoded = Buffer.from(trimmed, "base64");
if (decoded.length === 32) return decoded;
} catch {
// ignored
}
if (Buffer.byteLength(trimmed, "utf8") === 32) {
return Buffer.from(trimmed, "utf8");
}
return null;
}
function withStrictModeNote(
base: Pick<CheckResult, "name" | "status" | "message" | "canRepair" | "repair" | "repairHint">,
config: PaperclipConfig,
): CheckResult {
const strictModeDisabledInDeployedSetup =
config.database.mode === "postgres" && config.secrets.strictMode === false;
if (!strictModeDisabledInDeployedSetup) return base;
if (base.status === "fail") return base;
return {
...base,
status: "warn",
message: `${base.message}; strict secret mode is disabled for postgres deployment`,
repairHint: base.repairHint
? `${base.repairHint}. Consider enabling secrets.strictMode`
: "Consider enabling secrets.strictMode",
};
}
export function secretsCheck(config: PaperclipConfig, configPath?: string): CheckResult {
const provider = config.secrets.provider;
if (provider !== "local_encrypted") {
return {
name: "Secrets adapter",
status: "fail",
message: `${provider} is configured, but this build only supports local_encrypted`,
canRepair: false,
repairHint: "Run `paperclipai configure --section secrets` and set provider to local_encrypted",
};
}
const envMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
if (envMasterKey && envMasterKey.trim().length > 0) {
if (!decodeMasterKey(envMasterKey)) {
return {
name: "Secrets adapter",
status: "fail",
message:
"PAPERCLIP_SECRETS_MASTER_KEY is invalid (expected 32-byte base64, 64-char hex, or raw 32-char string)",
canRepair: false,
repairHint: "Set PAPERCLIP_SECRETS_MASTER_KEY to a valid key or unset it to use a key file",
};
}
return withStrictModeNote(
{
name: "Secrets adapter",
status: "pass",
message: "Local encrypted provider configured via PAPERCLIP_SECRETS_MASTER_KEY",
},
config,
);
}
const keyFileOverride = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const configuredPath =
keyFileOverride && keyFileOverride.trim().length > 0
? keyFileOverride.trim()
: config.secrets.localEncrypted.keyFilePath;
const keyFilePath = resolveRuntimeLikePath(configuredPath, configPath);
if (!fs.existsSync(keyFilePath)) {
return withStrictModeNote(
{
name: "Secrets adapter",
status: "warn",
message: `Secrets key file does not exist yet: ${keyFilePath}`,
canRepair: true,
repair: () => {
fs.mkdirSync(path.dirname(keyFilePath), { recursive: true });
fs.writeFileSync(keyFilePath, randomBytes(32).toString("base64"), {
encoding: "utf8",
mode: 0o600,
});
try {
fs.chmodSync(keyFilePath, 0o600);
} catch {
// best effort
}
},
repairHint: "Run with --repair to create a local encrypted secrets key file",
},
config,
);
}
let raw: string;
try {
raw = fs.readFileSync(keyFilePath, "utf8");
} catch (err) {
return {
name: "Secrets adapter",
status: "fail",
message: `Could not read secrets key file: ${err instanceof Error ? err.message : String(err)}`,
canRepair: false,
repairHint: "Check file permissions or set PAPERCLIP_SECRETS_MASTER_KEY",
};
}
if (!decodeMasterKey(raw)) {
return {
name: "Secrets adapter",
status: "fail",
message: `Invalid key material in ${keyFilePath}`,
canRepair: false,
repairHint: "Replace with valid key material or delete it and run doctor --repair",
};
}
return withStrictModeNote(
{
name: "Secrets adapter",
status: "pass",
message: `Local encrypted provider configured with key file ${keyFilePath}`,
},
config,
);
}
@@ -0,0 +1,51 @@
import fs from "node:fs";
import type { PaperclipConfig } from "../config/schema.js";
import type { CheckResult } from "./index.js";
import { resolveRuntimeLikePath } from "./path-resolver.js";
export function storageCheck(config: PaperclipConfig, configPath?: string): CheckResult {
if (config.storage.provider === "local_disk") {
const baseDir = resolveRuntimeLikePath(config.storage.localDisk.baseDir, configPath);
if (!fs.existsSync(baseDir)) {
fs.mkdirSync(baseDir, { recursive: true });
}
try {
fs.accessSync(baseDir, fs.constants.W_OK);
return {
name: "Storage",
status: "pass",
message: `Local disk storage is writable: ${baseDir}`,
};
} catch {
return {
name: "Storage",
status: "fail",
message: `Local storage directory is not writable: ${baseDir}`,
canRepair: false,
repairHint: "Check file permissions for storage.localDisk.baseDir",
};
}
}
const bucket = config.storage.s3.bucket.trim();
const region = config.storage.s3.region.trim();
if (!bucket || !region) {
return {
name: "Storage",
status: "fail",
message: "S3 storage requires non-empty bucket and region",
canRepair: false,
repairHint: "Run `paperclipai configure --section storage`",
};
}
return {
name: "Storage",
status: "warn",
message: `S3 storage configured (bucket=${bucket}, region=${region}). Reachability check is skipped in doctor.`,
canRepair: false,
repairHint: "Verify credentials and endpoint in deployment environment",
};
}
@@ -0,0 +1,282 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import pc from "picocolors";
import { buildCliCommandLabel } from "./command-label.js";
import { resolveDefaultCliAuthPath } from "../config/home.js";
type RequestedAccess = "board" | "instance_admin_required";
interface BoardAuthCredential {
apiBase: string;
token: string;
createdAt: string;
updatedAt: string;
userId?: string | null;
}
interface BoardAuthStore {
version: 1;
credentials: Record<string, BoardAuthCredential>;
}
interface CreateChallengeResponse {
id: string;
token: string;
boardApiToken: string;
approvalPath: string;
approvalUrl: string | null;
pollPath: string;
expiresAt: string;
suggestedPollIntervalMs: number;
}
interface ChallengeStatusResponse {
id: string;
status: "pending" | "approved" | "cancelled" | "expired";
command: string;
clientName: string | null;
requestedAccess: RequestedAccess;
requestedCompanyId: string | null;
requestedCompanyName: string | null;
approvedAt: string | null;
cancelledAt: string | null;
expiresAt: string;
approvedByUser: { id: string; name: string; email: string } | null;
}
function defaultBoardAuthStore(): BoardAuthStore {
return {
version: 1,
credentials: {},
};
}
function toStringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function normalizeApiBase(apiBase: string): string {
return apiBase.trim().replace(/\/+$/, "");
}
export function resolveBoardAuthStorePath(overridePath?: string): string {
if (overridePath?.trim()) return path.resolve(overridePath.trim());
if (process.env.PAPERCLIP_AUTH_STORE?.trim()) return path.resolve(process.env.PAPERCLIP_AUTH_STORE.trim());
return resolveDefaultCliAuthPath();
}
export function readBoardAuthStore(storePath?: string): BoardAuthStore {
const filePath = resolveBoardAuthStorePath(storePath);
if (!fs.existsSync(filePath)) return defaultBoardAuthStore();
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<BoardAuthStore> | null;
const credentials = raw?.credentials && typeof raw.credentials === "object" ? raw.credentials : {};
const normalized: Record<string, BoardAuthCredential> = {};
for (const [key, value] of Object.entries(credentials)) {
if (typeof value !== "object" || value === null) continue;
const record = value as unknown as Record<string, unknown>;
const apiBase = toStringOrNull(record.apiBase);
const token = toStringOrNull(record.token);
const createdAt = toStringOrNull(record.createdAt);
const updatedAt = toStringOrNull(record.updatedAt);
if (!apiBase || !token || !createdAt || !updatedAt) continue;
normalized[normalizeApiBase(key)] = {
apiBase,
token,
createdAt,
updatedAt,
userId: toStringOrNull(record.userId),
};
}
return {
version: 1,
credentials: normalized,
};
}
export function writeBoardAuthStore(store: BoardAuthStore, storePath?: string): void {
const filePath = resolveBoardAuthStorePath(storePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
}
export function getStoredBoardCredential(apiBase: string, storePath?: string): BoardAuthCredential | null {
const store = readBoardAuthStore(storePath);
return store.credentials[normalizeApiBase(apiBase)] ?? null;
}
export function setStoredBoardCredential(input: {
apiBase: string;
token: string;
userId?: string | null;
storePath?: string;
}): BoardAuthCredential {
const normalizedApiBase = normalizeApiBase(input.apiBase);
const store = readBoardAuthStore(input.storePath);
const now = new Date().toISOString();
const existing = store.credentials[normalizedApiBase];
const credential: BoardAuthCredential = {
apiBase: normalizedApiBase,
token: input.token.trim(),
createdAt: existing?.createdAt ?? now,
updatedAt: now,
userId: input.userId ?? existing?.userId ?? null,
};
store.credentials[normalizedApiBase] = credential;
writeBoardAuthStore(store, input.storePath);
return credential;
}
export function removeStoredBoardCredential(apiBase: string, storePath?: string): boolean {
const normalizedApiBase = normalizeApiBase(apiBase);
const store = readBoardAuthStore(storePath);
if (!store.credentials[normalizedApiBase]) return false;
delete store.credentials[normalizedApiBase];
writeBoardAuthStore(store, storePath);
return true;
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers ?? undefined);
if (init?.body !== undefined && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
if (!headers.has("accept")) {
headers.set("accept", "application/json");
}
const response = await fetch(url, {
...init,
headers,
});
if (!response.ok) {
const body = await response.json().catch(() => null);
const message =
body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
? (body as { error: string }).error
: `Request failed: ${response.status}`;
throw new Error(message);
}
return response.json() as Promise<T>;
}
export function openUrl(url: string): boolean {
const platform = process.platform;
try {
if (platform === "darwin") {
const child = spawn("open", [url], { detached: true, stdio: "ignore" });
child.unref();
return true;
}
if (platform === "win32") {
const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
child.unref();
return true;
}
const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
child.unref();
return true;
} catch {
return false;
}
}
export async function loginBoardCli(params: {
apiBase: string;
requestedAccess: RequestedAccess;
requestedCompanyId?: string | null;
clientName?: string | null;
command?: string;
storePath?: string;
print?: boolean;
}): Promise<{ token: string; approvalUrl: string; userId?: string | null }> {
const apiBase = normalizeApiBase(params.apiBase);
const createUrl = `${apiBase}/api/cli-auth/challenges`;
const command = params.command?.trim() || buildCliCommandLabel();
const challenge = await requestJson<CreateChallengeResponse>(createUrl, {
method: "POST",
body: JSON.stringify({
command,
clientName: params.clientName?.trim() || "paperclipai cli",
requestedAccess: params.requestedAccess,
requestedCompanyId: params.requestedCompanyId?.trim() || null,
}),
});
const approvalUrl = challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
if (params.print !== false) {
console.error(pc.bold("Board authentication required"));
console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
}
const opened = openUrl(approvalUrl);
if (params.print !== false && opened) {
console.error(pc.dim("Opened the approval page in your browser."));
}
const expiresAtMs = Date.parse(challenge.expiresAt);
const pollMs = Math.max(500, challenge.suggestedPollIntervalMs || 1000);
while (Number.isFinite(expiresAtMs) ? Date.now() < expiresAtMs : true) {
const status = await requestJson<ChallengeStatusResponse>(
`${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`,
);
if (status.status === "approved") {
const me = await requestJson<{ userId: string; user?: { id: string } | null }>(
`${apiBase}/api/cli-auth/me`,
{
headers: {
authorization: `Bearer ${challenge.boardApiToken}`,
},
},
);
setStoredBoardCredential({
apiBase,
token: challenge.boardApiToken,
userId: me.userId ?? me.user?.id ?? null,
storePath: params.storePath,
});
return {
token: challenge.boardApiToken,
approvalUrl,
userId: me.userId ?? me.user?.id ?? null,
};
}
if (status.status === "cancelled") {
throw new Error("CLI auth challenge was cancelled.");
}
if (status.status === "expired") {
throw new Error("CLI auth challenge expired before approval.");
}
await sleep(pollMs);
}
throw new Error("CLI auth challenge expired before approval.");
}
export async function revokeStoredBoardCredential(params: {
apiBase: string;
token: string;
}): Promise<void> {
const apiBase = normalizeApiBase(params.apiBase);
await requestJson<{ revoked: boolean }>(`${apiBase}/api/cli-auth/revoke-current`, {
method: "POST",
headers: {
authorization: `Bearer ${params.token}`,
},
body: JSON.stringify({}),
});
}
@@ -0,0 +1,4 @@
export function buildCliCommandLabel(): string {
const args = process.argv.slice(2);
return args.length > 0 ? `paperclipai ${args.join(" ")}` : "paperclipai";
}
@@ -0,0 +1,175 @@
import fs from "node:fs";
import path from "node:path";
import { resolveDefaultContextPath } from "../config/home.js";
const DEFAULT_CONTEXT_BASENAME = "context.json";
const DEFAULT_PROFILE = "default";
export interface ClientContextProfile {
apiBase?: string;
companyId?: string;
apiKeyEnvVarName?: string;
}
export interface ClientContext {
version: 1;
currentProfile: string;
profiles: Record<string, ClientContextProfile>;
}
function findContextFileFromAncestors(startDir: string): string | null {
const absoluteStartDir = path.resolve(startDir);
let currentDir = absoluteStartDir;
while (true) {
const candidate = path.resolve(currentDir, ".paperclip", DEFAULT_CONTEXT_BASENAME);
if (fs.existsSync(candidate)) {
return candidate;
}
const nextDir = path.resolve(currentDir, "..");
if (nextDir === currentDir) break;
currentDir = nextDir;
}
return null;
}
export function resolveContextPath(overridePath?: string): string {
if (overridePath) return path.resolve(overridePath);
if (process.env.PAPERCLIP_CONTEXT) return path.resolve(process.env.PAPERCLIP_CONTEXT);
return findContextFileFromAncestors(process.cwd()) ?? resolveDefaultContextPath();
}
export function defaultClientContext(): ClientContext {
return {
version: 1,
currentProfile: DEFAULT_PROFILE,
profiles: {
[DEFAULT_PROFILE]: {},
},
};
}
function parseJson(filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch (err) {
throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
function toStringOrUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function normalizeProfile(value: unknown): ClientContextProfile {
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
const profile = value as Record<string, unknown>;
return {
apiBase: toStringOrUndefined(profile.apiBase),
companyId: toStringOrUndefined(profile.companyId),
apiKeyEnvVarName: toStringOrUndefined(profile.apiKeyEnvVarName),
};
}
function normalizeContext(raw: unknown): ClientContext {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return defaultClientContext();
}
const record = raw as Record<string, unknown>;
const version = record.version === 1 ? 1 : 1;
const currentProfile = toStringOrUndefined(record.currentProfile) ?? DEFAULT_PROFILE;
const rawProfiles = record.profiles;
const profiles: Record<string, ClientContextProfile> = {};
if (typeof rawProfiles === "object" && rawProfiles !== null && !Array.isArray(rawProfiles)) {
for (const [name, profile] of Object.entries(rawProfiles as Record<string, unknown>)) {
if (!name.trim()) continue;
profiles[name] = normalizeProfile(profile);
}
}
if (!profiles[currentProfile]) {
profiles[currentProfile] = {};
}
if (Object.keys(profiles).length === 0) {
profiles[DEFAULT_PROFILE] = {};
}
return {
version,
currentProfile,
profiles,
};
}
export function readContext(contextPath?: string): ClientContext {
const filePath = resolveContextPath(contextPath);
if (!fs.existsSync(filePath)) {
return defaultClientContext();
}
const raw = parseJson(filePath);
return normalizeContext(raw);
}
export function writeContext(context: ClientContext, contextPath?: string): void {
const filePath = resolveContextPath(contextPath);
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
const normalized = normalizeContext(context);
fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
}
export function upsertProfile(
profileName: string,
patch: Partial<ClientContextProfile>,
contextPath?: string,
): ClientContext {
const context = readContext(contextPath);
const existing = context.profiles[profileName] ?? {};
const merged: ClientContextProfile = {
...existing,
...patch,
};
if (patch.apiBase !== undefined && patch.apiBase.trim().length === 0) {
delete merged.apiBase;
}
if (patch.companyId !== undefined && patch.companyId.trim().length === 0) {
delete merged.companyId;
}
if (patch.apiKeyEnvVarName !== undefined && patch.apiKeyEnvVarName.trim().length === 0) {
delete merged.apiKeyEnvVarName;
}
context.profiles[profileName] = merged;
context.currentProfile = context.currentProfile || profileName;
writeContext(context, contextPath);
return context;
}
export function setCurrentProfile(profileName: string, contextPath?: string): ClientContext {
const context = readContext(contextPath);
if (!context.profiles[profileName]) {
context.profiles[profileName] = {};
}
context.currentProfile = profileName;
writeContext(context, contextPath);
return context;
}
export function resolveProfile(
context: ClientContext,
profileName?: string,
): { name: string; profile: ClientContextProfile } {
const name = profileName?.trim() || context.currentProfile || DEFAULT_PROFILE;
const profile = context.profiles[name] ?? {};
return { name, profile };
}
+255
View File
@@ -0,0 +1,255 @@
import { URL } from "node:url";
export class ApiRequestError extends Error {
status: number;
details?: unknown;
body?: unknown;
constructor(status: number, message: string, details?: unknown, body?: unknown) {
super(message);
this.status = status;
this.details = details;
this.body = body;
}
}
export class ApiConnectionError extends Error {
url: string;
method: string;
causeMessage?: string;
constructor(input: {
apiBase: string;
path: string;
method: string;
cause?: unknown;
}) {
const url = buildUrl(input.apiBase, input.path);
const causeMessage = formatConnectionCause(input.cause);
super(buildConnectionErrorMessage({ apiBase: input.apiBase, url, method: input.method, causeMessage }));
this.url = url;
this.method = input.method;
this.causeMessage = causeMessage;
}
}
interface RequestOptions {
ignoreNotFound?: boolean;
}
interface RecoverAuthInput {
path: string;
method: string;
error: ApiRequestError;
}
interface ApiClientOptions {
apiBase: string;
apiKey?: string;
runId?: string;
recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
}
export class PaperclipApiClient {
readonly apiBase: string;
apiKey?: string;
readonly runId?: string;
readonly recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
constructor(opts: ApiClientOptions) {
this.apiBase = opts.apiBase.replace(/\/+$/, "");
this.apiKey = opts.apiKey?.trim() || undefined;
this.runId = opts.runId?.trim() || undefined;
this.recoverAuth = opts.recoverAuth;
}
get<T>(path: string, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, { method: "GET" }, opts);
}
post<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, {
method: "POST",
body: body === undefined ? undefined : JSON.stringify(body),
}, opts);
}
patch<T>(path: string, body?: unknown, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, {
method: "PATCH",
body: body === undefined ? undefined : JSON.stringify(body),
}, opts);
}
delete<T>(path: string, opts?: RequestOptions): Promise<T | null> {
return this.request<T>(path, { method: "DELETE" }, opts);
}
setApiKey(apiKey: string | undefined) {
this.apiKey = apiKey?.trim() || undefined;
}
private async request<T>(
path: string,
init: RequestInit,
opts?: RequestOptions,
hasRetriedAuth = false,
): Promise<T | null> {
const url = buildUrl(this.apiBase, path);
const method = String(init.method ?? "GET").toUpperCase();
const headers: Record<string, string> = {
accept: "application/json",
...toStringRecord(init.headers),
};
if (init.body !== undefined) {
headers["content-type"] = headers["content-type"] ?? "application/json";
}
if (this.apiKey) {
headers.authorization = `Bearer ${this.apiKey}`;
}
if (this.runId) {
headers["x-paperclip-run-id"] = this.runId;
}
let response: Response;
try {
response = await fetch(url, {
...init,
headers,
});
} catch (error) {
throw new ApiConnectionError({
apiBase: this.apiBase,
path,
method,
cause: error,
});
}
if (opts?.ignoreNotFound && response.status === 404) {
return null;
}
if (!response.ok) {
const apiError = await toApiError(response);
if (!hasRetriedAuth && this.recoverAuth) {
const recoveredToken = await this.recoverAuth({
path,
method,
error: apiError,
});
if (recoveredToken) {
this.setApiKey(recoveredToken);
return this.request<T>(path, init, opts, true);
}
}
throw apiError;
}
if (response.status === 204) {
return null;
}
const text = await response.text();
if (!text.trim()) {
return null;
}
return safeParseJson(text) as T;
}
}
function buildUrl(apiBase: string, path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const [pathname, query] = normalizedPath.split("?");
const url = new URL(apiBase);
url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`;
if (query) url.search = query;
return url.toString();
}
function safeParseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function toApiError(response: Response): Promise<ApiRequestError> {
const text = await response.text();
const parsed = safeParseJson(text);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
const body = parsed as Record<string, unknown>;
const message =
(typeof body.error === "string" && body.error.trim()) ||
(typeof body.message === "string" && body.message.trim()) ||
`Request failed with status ${response.status}`;
return new ApiRequestError(response.status, message, body.details, parsed);
}
return new ApiRequestError(response.status, `Request failed with status ${response.status}`, undefined, parsed);
}
function buildConnectionErrorMessage(input: {
apiBase: string;
url: string;
method: string;
causeMessage?: string;
}): string {
const healthUrl = buildHealthCheckUrl(input.url);
const lines = [
"Could not reach the Paperclip API.",
"",
`Request: ${input.method} ${input.url}`,
];
if (input.causeMessage) {
lines.push(`Cause: ${input.causeMessage}`);
}
lines.push(
"",
"This usually means the Paperclip server is not running, the configured URL is wrong, or the request is being blocked before it reaches Paperclip.",
"",
"Try:",
"- Start Paperclip with `pnpm dev` or `pnpm paperclipai run`.",
`- Verify the server is reachable with \`curl ${healthUrl}\`.`,
`- If Paperclip is running elsewhere, pass \`--api-base ${input.apiBase.replace(/\/+$/, "")}\` or set \`PAPERCLIP_API_URL\`.`,
);
return lines.join("\n");
}
function buildHealthCheckUrl(requestUrl: string): string {
const url = new URL(requestUrl);
url.pathname = `${url.pathname.replace(/\/+$/, "").replace(/\/api(?:\/.*)?$/, "")}/api/health`;
url.search = "";
url.hash = "";
return url.toString();
}
function formatConnectionCause(error: unknown): string | undefined {
if (!error) return undefined;
if (error instanceof Error) {
return error.message.trim() || error.name;
}
const message = String(error).trim();
return message || undefined;
}
function toStringRecord(headers: HeadersInit | undefined): Record<string, string> {
if (!headers) return {};
if (Array.isArray(headers)) {
return Object.fromEntries(headers.map(([key, value]) => [key, String(value)]));
}
if (headers instanceof Headers) {
return Object.fromEntries(headers.entries());
}
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, String(value)]),
);
}
@@ -0,0 +1,40 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { normalizeHostnameInput } from "../config/hostnames.js";
import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
export async function addAllowedHostname(host: string, opts: { config?: string }): Promise<void> {
const configPath = resolveConfigPath(opts.config);
const config = readConfig(opts.config);
if (!config) {
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
return;
}
const normalized = normalizeHostnameInput(host);
const current = new Set((config.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean));
const existed = current.has(normalized);
current.add(normalized);
config.server.allowedHostnames = Array.from(current).sort();
config.$meta.updatedAt = new Date().toISOString();
config.$meta.source = "configure";
writeConfig(config, opts.config);
if (existed) {
p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`);
} else {
p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`);
p.log.message(
pc.dim("Restart the Paperclip server for this change to take effect."),
);
}
if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) {
p.log.message(
pc.dim("Note: allowed hostnames are enforced only in authenticated/private mode."),
);
}
}
@@ -0,0 +1,133 @@
import { createHash, randomBytes } from "node:crypto";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
}
function createInviteToken() {
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
}
function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
if (explicitDbUrl) return explicitDbUrl;
const config = readConfig(configPath);
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
if (config?.database.mode === "postgres" && config.database.connectionString) {
return config.database.connectionString;
}
if (config?.database.mode === "embedded-postgres") {
const port = config.database.embeddedPostgresPort ?? 54329;
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
}
return null;
}
function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, "");
const fromEnv =
process.env.PAPERCLIP_PUBLIC_URL ??
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ??
process.env.BETTER_AUTH_BASE_URL;
if (fromEnv?.trim()) return fromEnv.trim().replace(/\/+$/, "");
const config = readConfig(configPath);
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
return config.auth.publicBaseUrl.replace(/\/+$/, "");
}
const host = config?.server.host ?? "localhost";
const port = config?.server.port ?? 3100;
const publicHost = host === "0.0.0.0" ? "localhost" : host;
return `http://${publicHost}:${port}`;
}
export async function bootstrapCeoInvite(opts: {
config?: string;
force?: boolean;
expiresHours?: number;
baseUrl?: string;
dbUrl?: string;
}) {
const configPath = resolveConfigPath(opts.config);
loadPaperclipEnvFile(configPath);
const config = readConfig(configPath);
if (!config) {
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
return;
}
if (config.server.deploymentMode !== "authenticated") {
p.log.info("Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode.");
return;
}
const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
if (!dbUrl) {
p.log.error(
"Could not resolve database connection for bootstrap.",
);
return;
}
const db = createDb(dbUrl);
const closableDb = db as typeof db & {
$client?: {
end?: (options?: { timeout?: number }) => Promise<void>;
};
};
try {
const existingAdminCount = await db
.select()
.from(instanceUserRoles)
.where(eq(instanceUserRoles.role, "instance_admin"))
.then((rows) => rows.length);
if (existingAdminCount > 0 && !opts.force) {
p.log.info("Instance already has an admin user. Use --force to generate a new bootstrap invite.");
return;
}
const now = new Date();
await db
.update(invites)
.set({ revokedAt: now, updatedAt: now })
.where(
and(
eq(invites.inviteType, "bootstrap_ceo"),
isNull(invites.revokedAt),
isNull(invites.acceptedAt),
gt(invites.expiresAt, now),
),
);
const token = createInviteToken();
const expiresHours = Math.max(1, Math.min(24 * 30, opts.expiresHours ?? 72));
const created = await db
.insert(invites)
.values({
inviteType: "bootstrap_ceo",
tokenHash: hashToken(token),
allowedJoinTypes: "human",
expiresAt: new Date(Date.now() + expiresHours * 60 * 60 * 1000),
invitedByUserId: "system",
})
.returning()
.then((rows) => rows[0]);
const baseUrl = resolveBaseUrl(configPath, opts.baseUrl);
const inviteUrl = `${baseUrl}/invite/${token}`;
p.log.success("Created bootstrap CEO invite.");
p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`);
p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`);
} catch (err) {
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
} finally {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}
@@ -0,0 +1,71 @@
import { Command } from "commander";
import type { ActivityEvent } from "@paperclipai/shared";
import {
addCommonClientOptions,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface ActivityListOptions extends BaseClientOptions {
companyId?: string;
agentId?: string;
entityType?: string;
entityId?: string;
}
export function registerActivityCommands(program: Command): void {
const activity = program.command("activity").description("Activity log operations");
addCommonClientOptions(
activity
.command("list")
.description("List company activity log entries")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--agent-id <id>", "Filter by agent ID")
.option("--entity-type <type>", "Filter by entity type")
.option("--entity-id <id>", "Filter by entity ID")
.action(async (opts: ActivityListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const params = new URLSearchParams();
if (opts.agentId) params.set("agentId", opts.agentId);
if (opts.entityType) params.set("entityType", opts.entityType);
if (opts.entityId) params.set("entityId", opts.entityId);
const query = params.toString();
const path = `/api/companies/${ctx.companyId}/activity${query ? `?${query}` : ""}`;
const rows = (await ctx.api.get<ActivityEvent[]>(path)) ?? [];
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
for (const row of rows) {
console.log(
formatInlineRecord({
id: row.id,
action: row.action,
actorType: row.actorType,
actorId: row.actorId,
entityType: row.entityType,
entityId: row.entityId,
createdAt: String(row.createdAt),
}),
);
}
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
@@ -0,0 +1,315 @@
import { Command } from "commander";
import type { Agent } from "@paperclipai/shared";
import {
removeMaintainerOnlySkillSymlinks,
resolvePaperclipSkillsDir,
} from "@paperclipai/adapter-utils/server-utils";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
addCommonClientOptions,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface AgentListOptions extends BaseClientOptions {
companyId?: string;
}
interface AgentLocalCliOptions extends BaseClientOptions {
companyId?: string;
keyName?: string;
installSkills?: boolean;
}
interface CreatedAgentKey {
id: string;
name: string;
token: string;
createdAt: string;
}
interface SkillsInstallSummary {
tool: "codex" | "claude";
target: string;
linked: string[];
removed: string[];
skipped: string[];
failed: Array<{ name: string; error: string }>;
}
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
function codexSkillsHome(): string {
const fromEnv = process.env.CODEX_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex");
return path.join(base, "skills");
}
function claudeSkillsHome(): string {
const fromEnv = process.env.CLAUDE_HOME?.trim();
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
return path.join(base, "skills");
}
async function installSkillsForTarget(
sourceSkillsDir: string,
targetSkillsDir: string,
tool: "codex" | "claude",
): Promise<SkillsInstallSummary> {
const summary: SkillsInstallSummary = {
tool,
target: targetSkillsDir,
linked: [],
removed: [],
skipped: [],
failed: [],
};
await fs.mkdir(targetSkillsDir, { recursive: true });
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
summary.removed = await removeMaintainerOnlySkillSymlinks(
targetSkillsDir,
entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
);
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const source = path.join(sourceSkillsDir, entry.name);
const target = path.join(targetSkillsDir, entry.name);
const existing = await fs.lstat(target).catch(() => null);
if (existing) {
if (existing.isSymbolicLink()) {
let linkedPath: string | null = null;
try {
linkedPath = await fs.readlink(target);
} catch (err) {
await fs.unlink(target);
try {
await fs.symlink(source, target);
summary.linked.push(entry.name);
continue;
} catch (linkErr) {
summary.failed.push({
name: entry.name,
error:
err instanceof Error && linkErr instanceof Error
? `${err.message}; then ${linkErr.message}`
: err instanceof Error
? err.message
: `Failed to recover broken symlink: ${String(err)}`,
});
continue;
}
}
const resolvedLinkedPath = path.isAbsolute(linkedPath)
? linkedPath
: path.resolve(path.dirname(target), linkedPath);
const linkedTargetExists = await fs
.stat(resolvedLinkedPath)
.then(() => true)
.catch(() => false);
if (!linkedTargetExists) {
await fs.unlink(target);
} else {
summary.skipped.push(entry.name);
continue;
}
} else {
summary.skipped.push(entry.name);
continue;
}
}
try {
await fs.symlink(source, target);
summary.linked.push(entry.name);
} catch (err) {
summary.failed.push({
name: entry.name,
error: err instanceof Error ? err.message : String(err),
});
}
}
return summary;
}
function buildAgentEnvExports(input: {
apiBase: string;
companyId: string;
agentId: string;
apiKey: string;
}): string {
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
return [
`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`,
`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`,
`export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`,
`export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`,
].join("\n");
}
export function registerAgentCommands(program: Command): void {
const agent = program.command("agent").description("Agent operations");
addCommonClientOptions(
agent
.command("list")
.description("List agents for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.action(async (opts: AgentListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const rows = (await ctx.api.get<Agent[]>(`/api/companies/${ctx.companyId}/agents`)) ?? [];
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
for (const row of rows) {
console.log(
formatInlineRecord({
id: row.id,
name: row.name,
role: row.role,
status: row.status,
reportsTo: row.reportsTo,
budgetMonthlyCents: row.budgetMonthlyCents,
spentMonthlyCents: row.spentMonthlyCents,
}),
);
}
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
agent
.command("get")
.description("Get one agent")
.argument("<agentId>", "Agent ID")
.action(async (agentId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Agent>(`/api/agents/${agentId}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
agent
.command("local-cli")
.description(
"Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
)
.argument("<agentRef>", "Agent ID or shortname/url-key")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--key-name <name>", "API key label", "local-cli")
.option(
"--no-install-skills",
"Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills",
)
.action(async (agentRef: string, opts: AgentLocalCliOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const query = new URLSearchParams({ companyId: ctx.companyId ?? "" });
const agentRow = await ctx.api.get<Agent>(
`/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
);
if (!agentRow) {
throw new Error(`Agent not found: ${agentRef}`);
}
const now = new Date().toISOString().replaceAll(":", "-");
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
if (!key) {
throw new Error("Failed to create API key");
}
const installSummaries: SkillsInstallSummary[] = [];
if (opts.installSkills !== false) {
const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]);
if (!skillsDir) {
throw new Error(
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
);
}
installSummaries.push(
await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"),
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
);
}
const exportsText = buildAgentEnvExports({
apiBase: ctx.api.apiBase,
companyId: agentRow.companyId,
agentId: agentRow.id,
apiKey: key.token,
});
if (ctx.json) {
printOutput(
{
agent: {
id: agentRow.id,
name: agentRow.name,
urlKey: agentRow.urlKey,
companyId: agentRow.companyId,
},
key: {
id: key.id,
name: key.name,
createdAt: key.createdAt,
token: key.token,
},
skills: installSummaries,
exports: exportsText,
},
{ json: true },
);
return;
}
console.log(`Agent: ${agentRow.name} (${agentRow.id})`);
console.log(`API key created: ${key.name} (${key.id})`);
if (installSummaries.length > 0) {
for (const summary of installSummaries) {
console.log(
`${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
);
for (const failed of summary.failed) {
console.log(` failed ${failed.name}: ${failed.error}`);
}
}
}
console.log("");
console.log("# Run this in your shell before launching codex/claude:");
console.log(exportsText);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
@@ -0,0 +1,259 @@
import { Command } from "commander";
import {
createApprovalSchema,
requestApprovalRevisionSchema,
resolveApprovalSchema,
resubmitApprovalSchema,
type Approval,
type ApprovalComment,
} from "@paperclipai/shared";
import {
addCommonClientOptions,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface ApprovalListOptions extends BaseClientOptions {
companyId?: string;
status?: string;
}
interface ApprovalDecisionOptions extends BaseClientOptions {
decisionNote?: string;
decidedByUserId?: string;
}
interface ApprovalCreateOptions extends BaseClientOptions {
companyId?: string;
type: string;
requestedByAgentId?: string;
payload: string;
issueIds?: string;
}
interface ApprovalResubmitOptions extends BaseClientOptions {
payload?: string;
}
interface ApprovalCommentOptions extends BaseClientOptions {
body: string;
}
export function registerApprovalCommands(program: Command): void {
const approval = program.command("approval").description("Approval operations");
addCommonClientOptions(
approval
.command("list")
.description("List approvals for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.option("--status <status>", "Status filter")
.action(async (opts: ApprovalListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const params = new URLSearchParams();
if (opts.status) params.set("status", opts.status);
const query = params.toString();
const rows =
(await ctx.api.get<Approval[]>(
`/api/companies/${ctx.companyId}/approvals${query ? `?${query}` : ""}`,
)) ?? [];
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
for (const row of rows) {
console.log(
formatInlineRecord({
id: row.id,
type: row.type,
status: row.status,
requestedByAgentId: row.requestedByAgentId,
requestedByUserId: row.requestedByUserId,
}),
);
}
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
approval
.command("get")
.description("Get one approval")
.argument("<approvalId>", "Approval ID")
.action(async (approvalId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Approval>(`/api/approvals/${approvalId}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("create")
.description("Create an approval request")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("--type <type>", "Approval type (hire_agent|approve_ceo_strategy)")
.requiredOption("--payload <json>", "Approval payload as JSON object")
.option("--requested-by-agent-id <id>", "Requesting agent ID")
.option("--issue-ids <csv>", "Comma-separated linked issue IDs")
.action(async (opts: ApprovalCreateOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const payloadJson = parseJsonObject(opts.payload, "payload");
const payload = createApprovalSchema.parse({
type: opts.type,
payload: payloadJson,
requestedByAgentId: opts.requestedByAgentId,
issueIds: parseCsv(opts.issueIds),
});
const created = await ctx.api.post<Approval>(`/api/companies/${ctx.companyId}/approvals`, payload);
printOutput(created, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
approval
.command("approve")
.description("Approve an approval request")
.argument("<approvalId>", "Approval ID")
.option("--decision-note <text>", "Decision note")
.option("--decided-by-user-id <id>", "Decision actor user ID")
.action(async (approvalId: string, opts: ApprovalDecisionOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = resolveApprovalSchema.parse({
decisionNote: opts.decisionNote,
decidedByUserId: opts.decidedByUserId,
});
const updated = await ctx.api.post<Approval>(`/api/approvals/${approvalId}/approve`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("reject")
.description("Reject an approval request")
.argument("<approvalId>", "Approval ID")
.option("--decision-note <text>", "Decision note")
.option("--decided-by-user-id <id>", "Decision actor user ID")
.action(async (approvalId: string, opts: ApprovalDecisionOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = resolveApprovalSchema.parse({
decisionNote: opts.decisionNote,
decidedByUserId: opts.decidedByUserId,
});
const updated = await ctx.api.post<Approval>(`/api/approvals/${approvalId}/reject`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("request-revision")
.description("Request revision for an approval")
.argument("<approvalId>", "Approval ID")
.option("--decision-note <text>", "Decision note")
.option("--decided-by-user-id <id>", "Decision actor user ID")
.action(async (approvalId: string, opts: ApprovalDecisionOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = requestApprovalRevisionSchema.parse({
decisionNote: opts.decisionNote,
decidedByUserId: opts.decidedByUserId,
});
const updated = await ctx.api.post<Approval>(`/api/approvals/${approvalId}/request-revision`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("resubmit")
.description("Resubmit an approval (optionally with new payload)")
.argument("<approvalId>", "Approval ID")
.option("--payload <json>", "Payload JSON object")
.action(async (approvalId: string, opts: ApprovalResubmitOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = resubmitApprovalSchema.parse({
payload: opts.payload ? parseJsonObject(opts.payload, "payload") : undefined,
});
const updated = await ctx.api.post<Approval>(`/api/approvals/${approvalId}/resubmit`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
approval
.command("comment")
.description("Add comment to an approval")
.argument("<approvalId>", "Approval ID")
.requiredOption("--body <text>", "Comment body")
.action(async (approvalId: string, opts: ApprovalCommentOptions) => {
try {
const ctx = resolveCommandContext(opts);
const created = await ctx.api.post<ApprovalComment>(`/api/approvals/${approvalId}/comments`, {
body: opts.body,
});
printOutput(created, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function parseCsv(value: string | undefined): string[] | undefined {
if (!value) return undefined;
const rows = value.split(",").map((v) => v.trim()).filter(Boolean);
return rows.length > 0 ? rows : undefined;
}
function parseJsonObject(value: string, name: string): Record<string, unknown> {
try {
const parsed = JSON.parse(value) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new Error(`${name} must be a JSON object`);
}
return parsed as Record<string, unknown>;
} catch (err) {
throw new Error(`Invalid ${name} JSON: ${err instanceof Error ? err.message : String(err)}`);
}
}
@@ -0,0 +1,113 @@
import type { Command } from "commander";
import {
getStoredBoardCredential,
loginBoardCli,
removeStoredBoardCredential,
revokeStoredBoardCredential,
} from "../../client/board-auth.js";
import {
addCommonClientOptions,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface AuthLoginOptions extends BaseClientOptions {
instanceAdmin?: boolean;
}
interface AuthLogoutOptions extends BaseClientOptions {}
interface AuthWhoamiOptions extends BaseClientOptions {}
export function registerClientAuthCommands(auth: Command): void {
addCommonClientOptions(
auth
.command("login")
.description("Authenticate the CLI for board-user access")
.option("--instance-admin", "Request instance-admin approval instead of plain board access", false)
.action(async (opts: AuthLoginOptions) => {
try {
const ctx = resolveCommandContext(opts);
const login = await loginBoardCli({
apiBase: ctx.api.apiBase,
requestedAccess: opts.instanceAdmin ? "instance_admin_required" : "board",
requestedCompanyId: ctx.companyId ?? null,
command: "paperclipai auth login",
});
printOutput(
{
ok: true,
apiBase: ctx.api.apiBase,
userId: login.userId ?? null,
approvalUrl: login.approvalUrl,
},
{ json: ctx.json },
);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
addCommonClientOptions(
auth
.command("logout")
.description("Remove the stored board-user credential for this API base")
.action(async (opts: AuthLogoutOptions) => {
try {
const ctx = resolveCommandContext(opts);
const credential = getStoredBoardCredential(ctx.api.apiBase);
if (!credential) {
printOutput({ ok: true, apiBase: ctx.api.apiBase, revoked: false, removedLocalCredential: false }, { json: ctx.json });
return;
}
let revoked = false;
try {
await revokeStoredBoardCredential({
apiBase: ctx.api.apiBase,
token: credential.token,
});
revoked = true;
} catch {
// Remove the local credential even if the server-side revoke fails.
}
const removedLocalCredential = removeStoredBoardCredential(ctx.api.apiBase);
printOutput(
{
ok: true,
apiBase: ctx.api.apiBase,
revoked,
removedLocalCredential,
},
{ json: ctx.json },
);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
auth
.command("whoami")
.description("Show the current board-user identity for this API base")
.action(async (opts: AuthWhoamiOptions) => {
try {
const ctx = resolveCommandContext(opts);
const me = await ctx.api.get<{
user: { id: string; name: string; email: string } | null;
userId: string;
isInstanceAdmin: boolean;
companyIds: string[];
source: string;
keyId: string | null;
}>("/api/cli-auth/me");
printOutput(me, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
@@ -0,0 +1,221 @@
import pc from "picocolors";
import type { Command } from "commander";
import { getStoredBoardCredential, loginBoardCli } from "../../client/board-auth.js";
import { buildCliCommandLabel } from "../../client/command-label.js";
import { readConfig } from "../../config/store.js";
import { readContext, resolveProfile, type ClientContextProfile } from "../../client/context.js";
import { ApiRequestError, PaperclipApiClient } from "../../client/http.js";
export interface BaseClientOptions {
config?: string;
dataDir?: string;
context?: string;
profile?: string;
apiBase?: string;
apiKey?: string;
companyId?: string;
json?: boolean;
}
export interface ResolvedClientContext {
api: PaperclipApiClient;
companyId?: string;
profileName: string;
profile: ClientContextProfile;
json: boolean;
}
export function addCommonClientOptions(command: Command, opts?: { includeCompany?: boolean }): Command {
command
.option("-c, --config <path>", "Path to Paperclip config file")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "CLI context profile name")
.option("--api-base <url>", "Base URL for the Paperclip API")
.option("--api-key <token>", "Bearer token for agent-authenticated calls")
.option("--json", "Output raw JSON");
if (opts?.includeCompany) {
command.option("-C, --company-id <id>", "Company ID (overrides context default)");
}
return command;
}
export function resolveCommandContext(
options: BaseClientOptions,
opts?: { requireCompany?: boolean },
): ResolvedClientContext {
const context = readContext(options.context);
const { name: profileName, profile } = resolveProfile(context, options.profile);
const apiBase =
options.apiBase?.trim() ||
process.env.PAPERCLIP_API_URL?.trim() ||
profile.apiBase ||
inferApiBaseFromConfig(options.config);
const explicitApiKey =
options.apiKey?.trim() ||
process.env.PAPERCLIP_API_KEY?.trim() ||
readKeyFromProfileEnv(profile);
const storedBoardCredential = explicitApiKey ? null : getStoredBoardCredential(apiBase);
const apiKey = explicitApiKey || storedBoardCredential?.token;
const companyId =
options.companyId?.trim() ||
process.env.PAPERCLIP_COMPANY_ID?.trim() ||
profile.companyId;
if (opts?.requireCompany && !companyId) {
throw new Error(
"Company ID is required. Pass --company-id, set PAPERCLIP_COMPANY_ID, or set context profile companyId via `paperclipai context set`.",
);
}
const api = new PaperclipApiClient({
apiBase,
apiKey,
recoverAuth: explicitApiKey || !canAttemptInteractiveBoardAuth()
? undefined
: async ({ error }) => {
const requestedAccess = error.message.includes("Instance admin required")
? "instance_admin_required"
: "board";
if (!shouldRecoverBoardAuth(error)) {
return null;
}
const login = await loginBoardCli({
apiBase,
requestedAccess,
requestedCompanyId: companyId ?? null,
command: buildCliCommandLabel(),
});
return login.token;
},
});
return {
api,
companyId,
profileName,
profile,
json: Boolean(options.json),
};
}
function shouldRecoverBoardAuth(error: ApiRequestError): boolean {
if (error.status === 401) return true;
if (error.status !== 403) return false;
return error.message.includes("Board access required") || error.message.includes("Instance admin required");
}
function canAttemptInteractiveBoardAuth(): boolean {
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
}
export function printOutput(data: unknown, opts: { json?: boolean; label?: string } = {}): void {
if (opts.json) {
console.log(JSON.stringify(data, null, 2));
return;
}
if (opts.label) {
console.log(pc.bold(opts.label));
}
if (Array.isArray(data)) {
if (data.length === 0) {
console.log(pc.dim("(empty)"));
return;
}
for (const item of data) {
if (typeof item === "object" && item !== null) {
console.log(formatInlineRecord(item as Record<string, unknown>));
} else {
console.log(String(item));
}
}
return;
}
if (typeof data === "object" && data !== null) {
console.log(JSON.stringify(data, null, 2));
return;
}
if (data === undefined || data === null) {
console.log(pc.dim("(null)"));
return;
}
console.log(String(data));
}
export function formatInlineRecord(record: Record<string, unknown>): string {
const keyOrder = ["identifier", "id", "name", "status", "priority", "title", "action"];
const seen = new Set<string>();
const parts: string[] = [];
for (const key of keyOrder) {
if (!(key in record)) continue;
parts.push(`${key}=${renderValue(record[key])}`);
seen.add(key);
}
for (const [key, value] of Object.entries(record)) {
if (seen.has(key)) continue;
if (typeof value === "object") continue;
parts.push(`${key}=${renderValue(value)}`);
}
return parts.join(" ");
}
function renderValue(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "string") {
const compact = value.replace(/\s+/g, " ").trim();
return compact.length > 90 ? `${compact.slice(0, 87)}...` : compact;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return "[object]";
}
function inferApiBaseFromConfig(configPath?: string): string {
const envHost = process.env.PAPERCLIP_SERVER_HOST?.trim() || "localhost";
let port = Number(process.env.PAPERCLIP_SERVER_PORT || "");
if (!Number.isFinite(port) || port <= 0) {
try {
const config = readConfig(configPath);
port = Number(config?.server?.port ?? 3100);
} catch {
port = 3100;
}
}
if (!Number.isFinite(port) || port <= 0) {
port = 3100;
}
return `http://${envHost}:${port}`;
}
function readKeyFromProfileEnv(profile: ClientContextProfile): string | undefined {
if (!profile.apiKeyEnvVarName) return undefined;
return process.env[profile.apiKeyEnvVarName]?.trim() || undefined;
}
export function handleCommandError(error: unknown): never {
if (error instanceof ApiRequestError) {
const detailSuffix = error.details !== undefined ? ` details=${JSON.stringify(error.details)}` : "";
console.error(pc.red(`API error ${error.status}: ${error.message}${detailSuffix}`));
process.exit(1);
}
const message = error instanceof Error ? error.message : String(error);
console.error(pc.red(message));
process.exit(1);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,125 @@
import { Command } from "commander";
import pc from "picocolors";
import {
readContext,
resolveContextPath,
resolveProfile,
setCurrentProfile,
upsertProfile,
} from "../../client/context.js";
import { printOutput } from "./common.js";
interface ContextOptions {
dataDir?: string;
context?: string;
profile?: string;
json?: boolean;
}
interface ContextSetOptions extends ContextOptions {
apiBase?: string;
companyId?: string;
apiKeyEnvVarName?: string;
use?: boolean;
}
export function registerContextCommands(program: Command): void {
const context = program.command("context").description("Manage CLI client context profiles");
context
.command("show")
.description("Show current context and active profile")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "Profile to inspect")
.option("--json", "Output raw JSON")
.action((opts: ContextOptions) => {
const contextPath = resolveContextPath(opts.context);
const store = readContext(opts.context);
const resolved = resolveProfile(store, opts.profile);
const payload = {
contextPath,
currentProfile: store.currentProfile,
profileName: resolved.name,
profile: resolved.profile,
profiles: store.profiles,
};
printOutput(payload, { json: opts.json });
});
context
.command("list")
.description("List available context profiles")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.option("--json", "Output raw JSON")
.action((opts: ContextOptions) => {
const store = readContext(opts.context);
const rows = Object.entries(store.profiles).map(([name, profile]) => ({
name,
current: name === store.currentProfile,
apiBase: profile.apiBase ?? null,
companyId: profile.companyId ?? null,
apiKeyEnvVarName: profile.apiKeyEnvVarName ?? null,
}));
printOutput(rows, { json: opts.json });
});
context
.command("use")
.description("Set active context profile")
.argument("<profile>", "Profile name")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.action((profile: string, opts: ContextOptions) => {
setCurrentProfile(profile, opts.context);
console.log(pc.green(`Active profile set to '${profile}'.`));
});
context
.command("set")
.description("Set values on a profile")
.option("-d, --data-dir <path>", "Paperclip data directory root (isolates state from ~/.paperclip)")
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "Profile name (default: current profile)")
.option("--api-base <url>", "Default API base URL")
.option("--company-id <id>", "Default company ID")
.option("--api-key-env-var-name <name>", "Env var containing API key (recommended)")
.option("--use", "Set this profile as active")
.option("--json", "Output raw JSON")
.action((opts: ContextSetOptions) => {
const existing = readContext(opts.context);
const targetProfile = opts.profile?.trim() || existing.currentProfile || "default";
upsertProfile(
targetProfile,
{
apiBase: opts.apiBase,
companyId: opts.companyId,
apiKeyEnvVarName: opts.apiKeyEnvVarName,
},
opts.context,
);
if (opts.use) {
setCurrentProfile(targetProfile, opts.context);
}
const updated = readContext(opts.context);
const resolved = resolveProfile(updated, targetProfile);
const payload = {
contextPath: resolveContextPath(opts.context),
currentProfile: updated.currentProfile,
profileName: resolved.name,
profile: resolved.profile,
};
if (!opts.json) {
console.log(pc.green(`Updated profile '${targetProfile}'.`));
if (opts.use) {
console.log(pc.green(`Set '${targetProfile}' as active profile.`));
}
}
printOutput(payload, { json: opts.json });
});
}
@@ -0,0 +1,34 @@
import { Command } from "commander";
import type { DashboardSummary } from "@paperclipai/shared";
import {
addCommonClientOptions,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface DashboardGetOptions extends BaseClientOptions {
companyId?: string;
}
export function registerDashboardCommands(program: Command): void {
const dashboard = program.command("dashboard").description("Dashboard summary operations");
addCommonClientOptions(
dashboard
.command("get")
.description("Get dashboard summary for a company")
.requiredOption("-C, --company-id <id>", "Company ID")
.action(async (opts: DashboardGetOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const row = await ctx.api.get<DashboardSummary>(`/api/companies/${ctx.companyId}/dashboard`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
}
@@ -0,0 +1,313 @@
import { Command } from "commander";
import {
addIssueCommentSchema,
checkoutIssueSchema,
createIssueSchema,
updateIssueSchema,
type Issue,
type IssueComment,
} from "@paperclipai/shared";
import {
addCommonClientOptions,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface IssueBaseOptions extends BaseClientOptions {
status?: string;
assigneeAgentId?: string;
projectId?: string;
match?: string;
}
interface IssueCreateOptions extends BaseClientOptions {
title: string;
description?: string;
status?: string;
priority?: string;
assigneeAgentId?: string;
projectId?: string;
goalId?: string;
parentId?: string;
requestDepth?: string;
billingCode?: string;
}
interface IssueUpdateOptions extends BaseClientOptions {
title?: string;
description?: string;
status?: string;
priority?: string;
assigneeAgentId?: string;
projectId?: string;
goalId?: string;
parentId?: string;
requestDepth?: string;
billingCode?: string;
comment?: string;
hiddenAt?: string;
}
interface IssueCommentOptions extends BaseClientOptions {
body: string;
reopen?: boolean;
}
interface IssueCheckoutOptions extends BaseClientOptions {
agentId: string;
expectedStatuses?: string;
}
export function registerIssueCommands(program: Command): void {
const issue = program.command("issue").description("Issue operations");
addCommonClientOptions(
issue
.command("list")
.description("List issues for a company")
.option("-C, --company-id <id>", "Company ID")
.option("--status <csv>", "Comma-separated statuses")
.option("--assignee-agent-id <id>", "Filter by assignee agent ID")
.option("--project-id <id>", "Filter by project ID")
.option("--match <text>", "Local text match on identifier/title/description")
.action(async (opts: IssueBaseOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const params = new URLSearchParams();
if (opts.status) params.set("status", opts.status);
if (opts.assigneeAgentId) params.set("assigneeAgentId", opts.assigneeAgentId);
if (opts.projectId) params.set("projectId", opts.projectId);
const query = params.toString();
const path = `/api/companies/${ctx.companyId}/issues${query ? `?${query}` : ""}`;
const rows = (await ctx.api.get<Issue[]>(path)) ?? [];
const filtered = filterIssueRows(rows, opts.match);
if (ctx.json) {
printOutput(filtered, { json: true });
return;
}
if (filtered.length === 0) {
printOutput([], { json: false });
return;
}
for (const item of filtered) {
console.log(
formatInlineRecord({
identifier: item.identifier,
id: item.id,
status: item.status,
priority: item.priority,
assigneeAgentId: item.assigneeAgentId,
title: item.title,
projectId: item.projectId,
}),
);
}
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
issue
.command("get")
.description("Get an issue by UUID or identifier (e.g. PC-12)")
.argument("<idOrIdentifier>", "Issue ID or identifier")
.action(async (idOrIdentifier: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Issue>(`/api/issues/${idOrIdentifier}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
issue
.command("create")
.description("Create an issue")
.requiredOption("-C, --company-id <id>", "Company ID")
.requiredOption("--title <title>", "Issue title")
.option("--description <text>", "Issue description")
.option("--status <status>", "Issue status")
.option("--priority <priority>", "Issue priority")
.option("--assignee-agent-id <id>", "Assignee agent ID")
.option("--project-id <id>", "Project ID")
.option("--goal-id <id>", "Goal ID")
.option("--parent-id <id>", "Parent issue ID")
.option("--request-depth <n>", "Request depth integer")
.option("--billing-code <code>", "Billing code")
.action(async (opts: IssueCreateOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const payload = createIssueSchema.parse({
title: opts.title,
description: opts.description,
status: opts.status,
priority: opts.priority,
assigneeAgentId: opts.assigneeAgentId,
projectId: opts.projectId,
goalId: opts.goalId,
parentId: opts.parentId,
requestDepth: parseOptionalInt(opts.requestDepth),
billingCode: opts.billingCode,
});
const created = await ctx.api.post<Issue>(`/api/companies/${ctx.companyId}/issues`, payload);
printOutput(created, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: false },
);
addCommonClientOptions(
issue
.command("update")
.description("Update an issue")
.argument("<issueId>", "Issue ID")
.option("--title <title>", "Issue title")
.option("--description <text>", "Issue description")
.option("--status <status>", "Issue status")
.option("--priority <priority>", "Issue priority")
.option("--assignee-agent-id <id>", "Assignee agent ID")
.option("--project-id <id>", "Project ID")
.option("--goal-id <id>", "Goal ID")
.option("--parent-id <id>", "Parent issue ID")
.option("--request-depth <n>", "Request depth integer")
.option("--billing-code <code>", "Billing code")
.option("--comment <text>", "Optional comment to add with update")
.option("--hidden-at <iso8601|null>", "Set hiddenAt timestamp or literal 'null'")
.action(async (issueId: string, opts: IssueUpdateOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = updateIssueSchema.parse({
title: opts.title,
description: opts.description,
status: opts.status,
priority: opts.priority,
assigneeAgentId: opts.assigneeAgentId,
projectId: opts.projectId,
goalId: opts.goalId,
parentId: opts.parentId,
requestDepth: parseOptionalInt(opts.requestDepth),
billingCode: opts.billingCode,
comment: opts.comment,
hiddenAt: parseHiddenAt(opts.hiddenAt),
});
const updated = await ctx.api.patch<Issue & { comment?: IssueComment | null }>(`/api/issues/${issueId}`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
issue
.command("comment")
.description("Add comment to issue")
.argument("<issueId>", "Issue ID")
.requiredOption("--body <text>", "Comment body")
.option("--reopen", "Reopen if issue is done/cancelled")
.action(async (issueId: string, opts: IssueCommentOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = addIssueCommentSchema.parse({
body: opts.body,
reopen: opts.reopen,
});
const comment = await ctx.api.post<IssueComment>(`/api/issues/${issueId}/comments`, payload);
printOutput(comment, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
issue
.command("checkout")
.description("Checkout issue for an agent")
.argument("<issueId>", "Issue ID")
.requiredOption("--agent-id <id>", "Agent ID")
.option(
"--expected-statuses <csv>",
"Expected current statuses",
"todo,backlog,blocked",
)
.action(async (issueId: string, opts: IssueCheckoutOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = checkoutIssueSchema.parse({
agentId: opts.agentId,
expectedStatuses: parseCsv(opts.expectedStatuses),
});
const updated = await ctx.api.post<Issue>(`/api/issues/${issueId}/checkout`, payload);
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
issue
.command("release")
.description("Release issue back to todo and clear assignee")
.argument("<issueId>", "Issue ID")
.action(async (issueId: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const updated = await ctx.api.post<Issue>(`/api/issues/${issueId}/release`, {});
printOutput(updated, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
}
function parseCsv(value: string | undefined): string[] {
if (!value) return [];
return value.split(",").map((v) => v.trim()).filter(Boolean);
}
function parseOptionalInt(value: string | undefined): number | undefined {
if (value === undefined) return undefined;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) {
throw new Error(`Invalid integer value: ${value}`);
}
return parsed;
}
function parseHiddenAt(value: string | undefined): string | null | undefined {
if (value === undefined) return undefined;
if (value.trim().toLowerCase() === "null") return null;
return value;
}
function filterIssueRows(rows: Issue[], match: string | undefined): Issue[] {
if (!match?.trim()) return rows;
const needle = match.trim().toLowerCase();
return rows.filter((row) => {
const text = [row.identifier, row.title, row.description]
.filter((part): part is string => Boolean(part))
.join("\n")
.toLowerCase();
return text.includes(needle);
});
}
@@ -0,0 +1,374 @@
import path from "node:path";
import { Command } from "commander";
import pc from "picocolors";
import {
addCommonClientOptions,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
// ---------------------------------------------------------------------------
// Types mirroring server-side shapes
// ---------------------------------------------------------------------------
interface PluginRecord {
id: string;
pluginKey: string;
packageName: string;
version: string;
status: string;
displayName?: string;
lastError?: string | null;
installedAt: string;
updatedAt: string;
}
// ---------------------------------------------------------------------------
// Option types
// ---------------------------------------------------------------------------
interface PluginListOptions extends BaseClientOptions {
status?: string;
}
interface PluginInstallOptions extends BaseClientOptions {
local?: boolean;
version?: string;
}
interface PluginUninstallOptions extends BaseClientOptions {
force?: boolean;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Resolve a local path argument to an absolute path so the server can find the
* plugin on disk regardless of where the user ran the CLI.
*/
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
if (!isLocal) return packageArg;
// Already absolute
if (path.isAbsolute(packageArg)) return packageArg;
// Expand leading ~ to home directory
if (packageArg.startsWith("~")) {
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
}
return path.resolve(process.cwd(), packageArg);
}
function formatPlugin(p: PluginRecord): string {
const statusColor =
p.status === "ready"
? pc.green(p.status)
: p.status === "error"
? pc.red(p.status)
: p.status === "disabled"
? pc.dim(p.status)
: pc.yellow(p.status);
const parts = [
`key=${pc.bold(p.pluginKey)}`,
`status=${statusColor}`,
`version=${p.version}`,
`id=${pc.dim(p.id)}`,
];
if (p.lastError) {
parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`);
}
return parts.join(" ");
}
// ---------------------------------------------------------------------------
// Command registration
// ---------------------------------------------------------------------------
export function registerPluginCommands(program: Command): void {
const plugin = program.command("plugin").description("Plugin lifecycle management");
// -------------------------------------------------------------------------
// plugin list
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("list")
.description("List installed plugins")
.option("--status <status>", "Filter by status (ready, error, disabled, installed, upgrade_pending)")
.action(async (opts: PluginListOptions) => {
try {
const ctx = resolveCommandContext(opts);
const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : "";
const plugins = await ctx.api.get<PluginRecord[]>(`/api/plugins${qs}`);
if (ctx.json) {
printOutput(plugins, { json: true });
return;
}
const rows = plugins ?? [];
if (rows.length === 0) {
console.log(pc.dim("No plugins installed."));
return;
}
for (const p of rows) {
console.log(formatPlugin(p));
}
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin install <package-or-path>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("install <package>")
.description(
"Install a plugin from a local path or npm package.\n" +
" Examples:\n" +
" paperclipai plugin install ./my-plugin # local path\n" +
" paperclipai plugin install @acme/plugin-linear # npm package\n" +
" paperclipai plugin install @acme/plugin-linear@1.2 # pinned version",
)
.option("-l, --local", "Treat <package> as a local filesystem path", false)
.option("--version <version>", "Specific npm version to install (npm packages only)")
.action(async (packageArg: string, opts: PluginInstallOptions) => {
try {
const ctx = resolveCommandContext(opts);
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
const isLocal =
opts.local ||
packageArg.startsWith("./") ||
packageArg.startsWith("../") ||
packageArg.startsWith("/") ||
packageArg.startsWith("~");
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
if (!ctx.json) {
console.log(
pc.dim(
isLocal
? `Installing plugin from local path: ${resolvedPackage}`
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
),
);
}
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
packageName: resolvedPackage,
version: opts.version,
isLocalPath: isLocal,
});
if (ctx.json) {
printOutput(installedPlugin, { json: true });
return;
}
if (!installedPlugin) {
console.log(pc.dim("Install returned no plugin record."));
return;
}
console.log(
pc.green(
`✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`,
),
);
if (installedPlugin.lastError) {
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
}
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin uninstall <plugin-key-or-id>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("uninstall <pluginKey>")
.description(
"Uninstall a plugin by its plugin key or database ID.\n" +
" Use --force to hard-purge all state and config.",
)
.option("--force", "Purge all plugin state and config (hard delete)", false)
.action(async (pluginKey: string, opts: PluginUninstallOptions) => {
try {
const ctx = resolveCommandContext(opts);
const purge = opts.force === true;
const qs = purge ? "?purge=true" : "";
if (!ctx.json) {
console.log(
pc.dim(
purge
? `Uninstalling and purging plugin: ${pluginKey}`
: `Uninstalling plugin: ${pluginKey}`,
),
);
}
const result = await ctx.api.delete<PluginRecord | null>(
`/api/plugins/${encodeURIComponent(pluginKey)}${qs}`,
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`));
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin enable <plugin-key-or-id>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("enable <pluginKey>")
.description("Enable a disabled or errored plugin")
.action(async (pluginKey: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const result = await ctx.api.post<PluginRecord>(
`/api/plugins/${encodeURIComponent(pluginKey)}/enable`,
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin disable <plugin-key-or-id>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("disable <pluginKey>")
.description("Disable a running plugin without uninstalling it")
.action(async (pluginKey: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const result = await ctx.api.post<PluginRecord>(
`/api/plugins/${encodeURIComponent(pluginKey)}/disable`,
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin inspect <plugin-key-or-id>
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("inspect <pluginKey>")
.description("Show full details for an installed plugin")
.action(async (pluginKey: string, opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const result = await ctx.api.get<PluginRecord>(
`/api/plugins/${encodeURIComponent(pluginKey)}`,
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
if (!result) {
console.log(pc.red(`Plugin not found: ${pluginKey}`));
process.exit(1);
}
console.log(formatPlugin(result));
if (result.lastError) {
console.log(`\n${pc.red("Last error:")}\n${result.lastError}`);
}
} catch (err) {
handleCommandError(err);
}
}),
);
// -------------------------------------------------------------------------
// plugin examples
// -------------------------------------------------------------------------
addCommonClientOptions(
plugin
.command("examples")
.description("List bundled example plugins available for local install")
.action(async (opts: BaseClientOptions) => {
try {
const ctx = resolveCommandContext(opts);
const examples = await ctx.api.get<
Array<{
packageName: string;
pluginKey: string;
displayName: string;
description: string;
localPath: string;
tag: string;
}>
>("/api/plugins/examples");
if (ctx.json) {
printOutput(examples, { json: true });
return;
}
const rows = examples ?? [];
if (rows.length === 0) {
console.log(pc.dim("No bundled examples available."));
return;
}
for (const ex of rows) {
console.log(
`${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` +
` ${ex.description}\n` +
` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`,
);
}
} catch (err) {
handleCommandError(err);
}
}),
);
}
@@ -0,0 +1,129 @@
import { inflateRawSync } from "node:zlib";
import path from "node:path";
import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
const textDecoder = new TextDecoder();
export const binaryContentTypeByExtension: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
function normalizeArchivePath(pathValue: string) {
return pathValue
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.join("/");
}
function readUint16(source: Uint8Array, offset: number) {
return source[offset]! | (source[offset + 1]! << 8);
}
function readUint32(source: Uint8Array, offset: number) {
return (
source[offset]! |
(source[offset + 1]! << 8) |
(source[offset + 2]! << 16) |
(source[offset + 3]! << 24)
) >>> 0;
}
function sharedArchiveRoot(paths: string[]) {
if (paths.length === 0) return null;
const firstSegments = paths
.map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean))
.filter((parts) => parts.length > 0);
if (firstSegments.length === 0) return null;
const candidate = firstSegments[0]![0]!;
return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate)
? candidate
: null;
}
function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry {
const contentType = binaryContentTypeByExtension[path.extname(pathValue).toLowerCase()];
if (!contentType) return textDecoder.decode(bytes);
return {
encoding: "base64",
data: Buffer.from(bytes).toString("base64"),
contentType,
};
}
async function inflateZipEntry(compressionMethod: number, bytes: Uint8Array) {
if (compressionMethod === 0) return bytes;
if (compressionMethod !== 8) {
throw new Error("Unsupported zip archive: only STORE and DEFLATE entries are supported.");
}
return new Uint8Array(inflateRawSync(bytes));
}
export async function readZipArchive(source: ArrayBuffer | Uint8Array): Promise<{
rootPath: string | null;
files: Record<string, CompanyPortabilityFileEntry>;
}> {
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = [];
let offset = 0;
while (offset + 4 <= bytes.length) {
const signature = readUint32(bytes, offset);
if (signature === 0x02014b50 || signature === 0x06054b50) break;
if (signature !== 0x04034b50) {
throw new Error("Invalid zip archive: unsupported local file header.");
}
if (offset + 30 > bytes.length) {
throw new Error("Invalid zip archive: truncated local file header.");
}
const generalPurposeFlag = readUint16(bytes, offset + 6);
const compressionMethod = readUint16(bytes, offset + 8);
const compressedSize = readUint32(bytes, offset + 18);
const fileNameLength = readUint16(bytes, offset + 26);
const extraFieldLength = readUint16(bytes, offset + 28);
if ((generalPurposeFlag & 0x0008) !== 0) {
throw new Error("Unsupported zip archive: data descriptors are not supported.");
}
const nameOffset = offset + 30;
const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
const bodyEnd = bodyOffset + compressedSize;
if (bodyEnd > bytes.length) {
throw new Error("Invalid zip archive: truncated file contents.");
}
const rawArchivePath = textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength));
const archivePath = normalizeArchivePath(rawArchivePath);
const isDirectoryEntry = /\/$/.test(rawArchivePath.replace(/\\/g, "/"));
if (archivePath && !isDirectoryEntry) {
const entryBytes = await inflateZipEntry(compressionMethod, bytes.slice(bodyOffset, bodyEnd));
entries.push({
path: archivePath,
body: bytesToPortableFileEntry(archivePath, entryBytes),
});
}
offset = bodyEnd;
}
const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
const files: Record<string, CompanyPortabilityFileEntry> = {};
for (const entry of entries) {
const normalizedPath =
rootPath && entry.path.startsWith(`${rootPath}/`)
? entry.path.slice(rootPath.length + 1)
: entry.path;
if (!normalizedPath) continue;
files[normalizedPath] = entry.body;
}
return { rootPath, files };
}
@@ -0,0 +1,197 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { readConfig, writeConfig, configExists, resolveConfigPath } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js";
import { promptDatabase } from "../prompts/database.js";
import { promptLlm } from "../prompts/llm.js";
import { promptLogging } from "../prompts/logging.js";
import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js";
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
import { promptServer } from "../prompts/server.js";
import {
resolveDefaultBackupDir,
resolveDefaultEmbeddedPostgresDir,
resolveDefaultLogsDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
type Section = "llm" | "database" | "logging" | "server" | "storage" | "secrets";
const SECTION_LABELS: Record<Section, string> = {
llm: "LLM Provider",
database: "Database",
logging: "Logging",
server: "Server",
storage: "Storage",
secrets: "Secrets",
};
function defaultConfig(): PaperclipConfig {
const instanceId = resolvePaperclipInstanceId();
return {
$meta: {
version: 1,
updatedAt: new Date().toISOString(),
source: "configure",
},
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: resolveDefaultBackupDir(instanceId),
},
},
logging: {
mode: "file",
logDir: resolveDefaultLogsDir(instanceId),
},
server: {
deploymentMode: "local_trusted",
exposure: "private",
host: "127.0.0.1",
port: 3100,
allowedHostnames: [],
serveUi: true,
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: defaultStorageConfig(),
secrets: defaultSecretsConfig(),
};
}
export async function configure(opts: {
config?: string;
section?: string;
}): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclip configure ")));
const configPath = resolveConfigPath(opts.config);
if (!configExists(opts.config)) {
p.log.error("No config file found. Run `paperclipai onboard` first.");
p.outro("");
return;
}
let config: PaperclipConfig;
try {
config = readConfig(opts.config) ?? defaultConfig();
} catch (err) {
p.log.message(
pc.yellow(
`Existing config is invalid. Loading defaults so you can repair it now.\n${err instanceof Error ? err.message : String(err)}`,
),
);
config = defaultConfig();
}
let section: Section | undefined = opts.section as Section | undefined;
if (section && !SECTION_LABELS[section]) {
p.log.error(`Unknown section: ${section}. Choose from: ${Object.keys(SECTION_LABELS).join(", ")}`);
p.outro("");
return;
}
// Section selection loop
let continueLoop = true;
while (continueLoop) {
if (!section) {
const choice = await p.select({
message: "Which section do you want to configure?",
options: Object.entries(SECTION_LABELS).map(([value, label]) => ({
value: value as Section,
label,
})),
});
if (p.isCancel(choice)) {
p.cancel("Configuration cancelled.");
return;
}
section = choice;
}
p.log.step(pc.bold(SECTION_LABELS[section]));
switch (section) {
case "database":
config.database = await promptDatabase(config.database);
break;
case "llm": {
const llm = await promptLlm();
if (llm) {
config.llm = llm;
} else {
delete config.llm;
}
break;
}
case "logging":
config.logging = await promptLogging();
break;
case "server":
{
const { server, auth } = await promptServer({
currentServer: config.server,
currentAuth: config.auth,
});
config.server = server;
config.auth = auth;
}
break;
case "storage":
config.storage = await promptStorage(config.storage);
break;
case "secrets":
config.secrets = await promptSecrets(config.secrets);
{
const keyResult = ensureLocalSecretsKeyFile(config, configPath);
if (keyResult.status === "created") {
p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`);
} else if (keyResult.status === "existing") {
p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
} else if (keyResult.status === "skipped_provider") {
p.log.message(pc.dim("Skipping local key file management for non-local provider"));
} else {
p.log.message(pc.dim("Skipping local key file management because PAPERCLIP_SECRETS_MASTER_KEY is set"));
}
}
break;
}
config.$meta.updatedAt = new Date().toISOString();
config.$meta.source = "configure";
writeConfig(config, opts.config);
p.log.success(`${SECTION_LABELS[section]} configuration updated.`);
// If section was provided via CLI flag, don't loop
if (opts.section) {
continueLoop = false;
} else {
const another = await p.confirm({
message: "Configure another section?",
initialValue: false,
});
if (p.isCancel(another) || !another) {
continueLoop = false;
} else {
section = undefined; // Reset to show picker again
}
}
}
p.outro("Configuration saved.");
}
@@ -0,0 +1,102 @@
import path from "node:path";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { formatDatabaseBackupResult, runDatabaseBackup } from "@paperclipai/db";
import {
expandHomePrefix,
resolveDefaultBackupDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
type DbBackupOptions = {
config?: string;
dir?: string;
retentionDays?: number;
filenamePrefix?: string;
json?: boolean;
};
function resolveConnectionString(configPath?: string): { value: string; source: string } {
const envUrl = process.env.DATABASE_URL?.trim();
if (envUrl) return { value: envUrl, source: "DATABASE_URL" };
const config = readConfig(configPath);
if (config?.database.mode === "postgres" && config.database.connectionString?.trim()) {
return { value: config.database.connectionString.trim(), source: "config.database.connectionString" };
}
const port = config?.database.embeddedPostgresPort ?? 54329;
return {
value: `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`,
source: `embedded-postgres@${port}`,
};
}
function normalizeRetentionDays(value: number | undefined, fallback: number): number {
const candidate = value ?? fallback;
if (!Number.isInteger(candidate) || candidate < 1) {
throw new Error(`Invalid retention days '${String(candidate)}'. Use a positive integer.`);
}
return candidate;
}
function resolveBackupDir(raw: string): string {
return path.resolve(expandHomePrefix(raw.trim()));
}
export async function dbBackupCommand(opts: DbBackupOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclip db:backup ")));
const configPath = resolveConfigPath(opts.config);
const config = readConfig(opts.config);
const connection = resolveConnectionString(opts.config);
const defaultDir = resolveDefaultBackupDir(resolvePaperclipInstanceId());
const configuredDir = opts.dir?.trim() || config?.database.backup.dir || defaultDir;
const backupDir = resolveBackupDir(configuredDir);
const retentionDays = normalizeRetentionDays(
opts.retentionDays,
config?.database.backup.retentionDays ?? 30,
);
const filenamePrefix = opts.filenamePrefix?.trim() || "paperclip";
p.log.message(pc.dim(`Config: ${configPath}`));
p.log.message(pc.dim(`Connection source: ${connection.source}`));
p.log.message(pc.dim(`Backup dir: ${backupDir}`));
p.log.message(pc.dim(`Retention: ${retentionDays} day(s)`));
const spinner = p.spinner();
spinner.start("Creating database backup...");
try {
const result = await runDatabaseBackup({
connectionString: connection.value,
backupDir,
retentionDays,
filenamePrefix,
});
spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`);
if (opts.json) {
console.log(
JSON.stringify(
{
backupFile: result.backupFile,
sizeBytes: result.sizeBytes,
prunedCount: result.prunedCount,
backupDir,
retentionDays,
connectionSource: connection.source,
},
null,
2,
),
);
}
p.outro(pc.green("Backup completed."));
} catch (err) {
spinner.stop(pc.red("Backup failed."));
throw err;
}
}
@@ -0,0 +1,203 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import type { PaperclipConfig } from "../config/schema.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
import {
agentJwtSecretCheck,
configCheck,
databaseCheck,
deploymentAuthCheck,
llmCheck,
logCheck,
portCheck,
secretsCheck,
storageCheck,
type CheckResult,
} from "../checks/index.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
const STATUS_ICON = {
pass: pc.green("✓"),
warn: pc.yellow("!"),
fail: pc.red("✗"),
} as const;
export async function doctor(opts: {
config?: string;
repair?: boolean;
yes?: boolean;
}): Promise<{ passed: number; warned: number; failed: number }> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
const configPath = resolveConfigPath(opts.config);
loadPaperclipEnvFile(configPath);
const results: CheckResult[] = [];
// 1. Config check (must pass before others)
const cfgResult = configCheck(opts.config);
results.push(cfgResult);
printResult(cfgResult);
if (cfgResult.status === "fail") {
return printSummary(results);
}
let config: PaperclipConfig;
try {
config = readConfig(opts.config)!;
} catch (err) {
const readResult: CheckResult = {
name: "Config file",
status: "fail",
message: `Could not read config: ${err instanceof Error ? err.message : String(err)}`,
canRepair: false,
repairHint: "Run `paperclipai configure --section database` or `paperclipai onboard`",
};
results.push(readResult);
printResult(readResult);
return printSummary(results);
}
// 2. Deployment/auth mode check
const deploymentAuthResult = deploymentAuthCheck(config);
results.push(deploymentAuthResult);
printResult(deploymentAuthResult);
// 3. Agent JWT check
results.push(
await runRepairableCheck({
run: () => agentJwtSecretCheck(opts.config),
configPath,
opts,
}),
);
// 4. Secrets adapter check
results.push(
await runRepairableCheck({
run: () => secretsCheck(config, configPath),
configPath,
opts,
}),
);
// 5. Storage check
results.push(
await runRepairableCheck({
run: () => storageCheck(config, configPath),
configPath,
opts,
}),
);
// 6. Database check
results.push(
await runRepairableCheck({
run: () => databaseCheck(config, configPath),
configPath,
opts,
}),
);
// 7. LLM check
const llmResult = await llmCheck(config);
results.push(llmResult);
printResult(llmResult);
// 8. Log directory check
results.push(
await runRepairableCheck({
run: () => logCheck(config, configPath),
configPath,
opts,
}),
);
// 9. Port check
const portResult = await portCheck(config);
results.push(portResult);
printResult(portResult);
// Summary
return printSummary(results);
}
function printResult(result: CheckResult): void {
const icon = STATUS_ICON[result.status];
p.log.message(`${icon} ${pc.bold(result.name)}: ${result.message}`);
if (result.status !== "pass" && result.repairHint) {
p.log.message(` ${pc.dim(result.repairHint)}`);
}
}
async function maybeRepair(
result: CheckResult,
opts: { repair?: boolean; yes?: boolean },
): Promise<boolean> {
if (result.status === "pass" || !result.canRepair || !result.repair) return false;
if (!opts.repair) return false;
let shouldRepair = opts.yes;
if (!shouldRepair) {
const answer = await p.confirm({
message: `Repair "${result.name}"?`,
initialValue: true,
});
if (p.isCancel(answer)) return false;
shouldRepair = answer;
}
if (shouldRepair) {
try {
await result.repair();
p.log.success(`Repaired: ${result.name}`);
return true;
} catch (err) {
p.log.error(`Repair failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
return false;
}
async function runRepairableCheck(input: {
run: () => CheckResult | Promise<CheckResult>;
configPath: string;
opts: { repair?: boolean; yes?: boolean };
}): Promise<CheckResult> {
let result = await input.run();
printResult(result);
const repaired = await maybeRepair(result, input.opts);
if (!repaired) return result;
// Repairs may create/update the adjacent .env file or other local resources.
loadPaperclipEnvFile(input.configPath);
result = await input.run();
printResult(result);
return result;
}
function printSummary(results: CheckResult[]): { passed: number; warned: number; failed: number } {
const passed = results.filter((r) => r.status === "pass").length;
const warned = results.filter((r) => r.status === "warn").length;
const failed = results.filter((r) => r.status === "fail").length;
const parts: string[] = [];
parts.push(pc.green(`${passed} passed`));
if (warned) parts.push(pc.yellow(`${warned} warnings`));
if (failed) parts.push(pc.red(`${failed} failed`));
p.note(parts.join(", "), "Summary");
if (failed > 0) {
p.outro(pc.red("Some checks failed. Fix the issues above and re-run doctor."));
} else if (warned > 0) {
p.outro(pc.yellow("All critical checks passed with some warnings."));
} else {
p.outro(pc.green("All checks passed!"));
}
return { passed, warned, failed };
}
+411
View File
@@ -0,0 +1,411 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import type { PaperclipConfig } from "../config/schema.js";
import { configExists, readConfig, resolveConfigPath } from "../config/store.js";
import {
readAgentJwtSecretFromEnv,
readAgentJwtSecretFromEnvFile,
resolveAgentJwtEnvFile,
} from "../config/env.js";
import {
resolveDefaultSecretsKeyFilePath,
resolveDefaultStorageDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
type EnvSource = "env" | "config" | "file" | "default" | "missing";
type EnvVarRow = {
key: string;
value: string;
source: EnvSource;
required: boolean;
note: string;
};
const DEFAULT_AGENT_JWT_TTL_SECONDS = "172800";
const DEFAULT_AGENT_JWT_ISSUER = "paperclip";
const DEFAULT_AGENT_JWT_AUDIENCE = "paperclip-api";
const DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS = "30000";
const DEFAULT_SECRETS_PROVIDER = "local_encrypted";
const DEFAULT_STORAGE_PROVIDER = "local_disk";
function defaultSecretsKeyFilePath(): string {
return resolveDefaultSecretsKeyFilePath(resolvePaperclipInstanceId());
}
function defaultStorageBaseDir(): string {
return resolveDefaultStorageDir(resolvePaperclipInstanceId());
}
export async function envCommand(opts: { config?: string }): Promise<void> {
p.intro(pc.bgCyan(pc.black(" paperclip env ")));
const configPath = resolveConfigPath(opts.config);
let config: PaperclipConfig | null = null;
let configReadError: string | null = null;
if (configExists(opts.config)) {
p.log.message(pc.dim(`Config file: ${configPath}`));
try {
config = readConfig(opts.config);
} catch (err) {
configReadError = err instanceof Error ? err.message : String(err);
p.log.message(pc.yellow(`Could not parse config: ${configReadError}`));
}
} else {
p.log.message(pc.dim(`Config file missing: ${configPath}`));
}
const rows = collectDeploymentEnvRows(config, configPath);
const missingRequired = rows.filter((row) => row.required && row.source === "missing");
const sortedRows = rows.sort((a, b) => Number(b.required) - Number(a.required) || a.key.localeCompare(b.key));
const requiredRows = sortedRows.filter((row) => row.required);
const optionalRows = sortedRows.filter((row) => !row.required);
const formatSection = (title: string, entries: EnvVarRow[]) => {
if (entries.length === 0) return;
p.log.message(pc.bold(title));
for (const entry of entries) {
const status = entry.source === "missing" ? pc.red("missing") : entry.source === "default" ? pc.yellow("default") : pc.green("set");
const sourceNote = {
env: "environment",
config: "config",
file: "file",
default: "default",
missing: "missing",
}[entry.source];
p.log.message(
`${pc.cyan(entry.key)} ${status.padEnd(7)} ${pc.dim(`[${sourceNote}] ${entry.note}`)}${entry.source === "missing" ? "" : ` ${pc.dim("=>")} ${pc.white(quoteShellValue(entry.value))}`}`,
);
}
};
formatSection("Required environment variables", requiredRows);
formatSection("Optional environment variables", optionalRows);
const exportRows = rows.map((row) => (row.source === "missing" ? { ...row, value: "<set-this-value>" } : row));
const uniqueRows = uniqueByKey(exportRows);
const exportBlock = uniqueRows.map((row) => `export ${row.key}=${quoteShellValue(row.value)}`).join("\n");
if (configReadError) {
p.log.error(`Could not load config cleanly: ${configReadError}`);
}
p.note(
exportBlock || "No values detected. Set required variables manually.",
"Deployment export block",
);
if (missingRequired.length > 0) {
p.log.message(
pc.yellow(
`Missing required values: ${missingRequired.map((row) => row.key).join(", ")}. Set these before deployment.`,
),
);
} else {
p.log.message(pc.green("All required deployment variables are present."));
}
p.outro("Done");
}
function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: string): EnvVarRow[] {
const agentJwtEnvFile = resolveAgentJwtEnvFile(configPath);
const jwtEnv = readAgentJwtSecretFromEnv(configPath);
const jwtFile = jwtEnv ? null : readAgentJwtSecretFromEnvFile(agentJwtEnvFile);
const jwtSource = jwtEnv ? "env" : jwtFile ? "file" : "missing";
const dbUrl = process.env.DATABASE_URL ?? config?.database?.connectionString ?? "";
const databaseMode = config?.database?.mode ?? "embedded-postgres";
const dbUrlSource: EnvSource = process.env.DATABASE_URL ? "env" : config?.database?.connectionString ? "config" : "missing";
const publicUrl =
process.env.PAPERCLIP_PUBLIC_URL ??
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ??
process.env.BETTER_AUTH_BASE_URL ??
config?.auth?.publicBaseUrl ??
"";
const publicUrlSource: EnvSource =
process.env.PAPERCLIP_PUBLIC_URL
? "env"
: process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL || process.env.BETTER_AUTH_URL || process.env.BETTER_AUTH_BASE_URL
? "env"
: config?.auth?.publicBaseUrl
? "config"
: "missing";
let trustedOriginsDefault = "";
if (publicUrl) {
try {
trustedOriginsDefault = new URL(publicUrl).origin;
} catch {
trustedOriginsDefault = "";
}
}
const heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS;
const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true";
const secretsProvider =
process.env.PAPERCLIP_SECRETS_PROVIDER ??
config?.secrets?.provider ??
DEFAULT_SECRETS_PROVIDER;
const secretsStrictMode =
process.env.PAPERCLIP_SECRETS_STRICT_MODE ??
String(config?.secrets?.strictMode ?? false);
const secretsKeyFilePath =
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ??
config?.secrets?.localEncrypted?.keyFilePath ??
defaultSecretsKeyFilePath();
const storageProvider =
process.env.PAPERCLIP_STORAGE_PROVIDER ??
config?.storage?.provider ??
DEFAULT_STORAGE_PROVIDER;
const storageLocalDir =
process.env.PAPERCLIP_STORAGE_LOCAL_DIR ??
config?.storage?.localDisk?.baseDir ??
defaultStorageBaseDir();
const storageS3Bucket =
process.env.PAPERCLIP_STORAGE_S3_BUCKET ??
config?.storage?.s3?.bucket ??
"paperclip";
const storageS3Region =
process.env.PAPERCLIP_STORAGE_S3_REGION ??
config?.storage?.s3?.region ??
"us-east-1";
const storageS3Endpoint =
process.env.PAPERCLIP_STORAGE_S3_ENDPOINT ??
config?.storage?.s3?.endpoint ??
"";
const storageS3Prefix =
process.env.PAPERCLIP_STORAGE_S3_PREFIX ??
config?.storage?.s3?.prefix ??
"";
const storageS3ForcePathStyle =
process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE ??
String(config?.storage?.s3?.forcePathStyle ?? false);
const rows: EnvVarRow[] = [
{
key: "PAPERCLIP_AGENT_JWT_SECRET",
value: jwtEnv ?? jwtFile ?? "",
source: jwtSource,
required: true,
note:
jwtSource === "missing"
? "Generate during onboard or set manually (required for local adapter authentication)"
: jwtSource === "env"
? "Set in process environment"
: `Set in ${agentJwtEnvFile}`,
},
{
key: "DATABASE_URL",
value: dbUrl,
source: dbUrlSource,
required: true,
note:
databaseMode === "postgres"
? "Configured for postgres mode (required)"
: "Required for live deployment with managed PostgreSQL",
},
{
key: "PORT",
value:
process.env.PORT ??
(config?.server?.port !== undefined ? String(config.server.port) : "3100"),
source: process.env.PORT ? "env" : config?.server?.port !== undefined ? "config" : "default",
required: false,
note: "HTTP listen port",
},
{
key: "PAPERCLIP_PUBLIC_URL",
value: publicUrl,
source: publicUrlSource,
required: false,
note: "Canonical public URL for auth/callback/invite origin wiring",
},
{
key: "BETTER_AUTH_TRUSTED_ORIGINS",
value: process.env.BETTER_AUTH_TRUSTED_ORIGINS ?? trustedOriginsDefault,
source: process.env.BETTER_AUTH_TRUSTED_ORIGINS
? "env"
: trustedOriginsDefault
? "default"
: "missing",
required: false,
note: "Comma-separated auth origin allowlist (auto-derived from PAPERCLIP_PUBLIC_URL when possible)",
},
{
key: "PAPERCLIP_AGENT_JWT_TTL_SECONDS",
value: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ?? DEFAULT_AGENT_JWT_TTL_SECONDS,
source: process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS ? "env" : "default",
required: false,
note: "JWT lifetime in seconds",
},
{
key: "PAPERCLIP_AGENT_JWT_ISSUER",
value: process.env.PAPERCLIP_AGENT_JWT_ISSUER ?? DEFAULT_AGENT_JWT_ISSUER,
source: process.env.PAPERCLIP_AGENT_JWT_ISSUER ? "env" : "default",
required: false,
note: "JWT issuer",
},
{
key: "PAPERCLIP_AGENT_JWT_AUDIENCE",
value: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ?? DEFAULT_AGENT_JWT_AUDIENCE,
source: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ? "env" : "default",
required: false,
note: "JWT audience",
},
{
key: "HEARTBEAT_SCHEDULER_INTERVAL_MS",
value: heartbeatInterval,
source: process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ? "env" : "default",
required: false,
note: "Heartbeat worker interval in ms",
},
{
key: "HEARTBEAT_SCHEDULER_ENABLED",
value: heartbeatEnabled,
source: process.env.HEARTBEAT_SCHEDULER_ENABLED ? "env" : "default",
required: false,
note: "Set to `false` to disable timer scheduling",
},
{
key: "PAPERCLIP_SECRETS_PROVIDER",
value: secretsProvider,
source: process.env.PAPERCLIP_SECRETS_PROVIDER
? "env"
: config?.secrets?.provider
? "config"
: "default",
required: false,
note: "Default provider for new secrets",
},
{
key: "PAPERCLIP_SECRETS_STRICT_MODE",
value: secretsStrictMode,
source: process.env.PAPERCLIP_SECRETS_STRICT_MODE
? "env"
: config?.secrets?.strictMode !== undefined
? "config"
: "default",
required: false,
note: "Require secret refs for sensitive env keys",
},
{
key: "PAPERCLIP_SECRETS_MASTER_KEY_FILE",
value: secretsKeyFilePath,
source: process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE
? "env"
: config?.secrets?.localEncrypted?.keyFilePath
? "config"
: "default",
required: false,
note: "Path to local encrypted secrets key file",
},
{
key: "PAPERCLIP_STORAGE_PROVIDER",
value: storageProvider,
source: process.env.PAPERCLIP_STORAGE_PROVIDER
? "env"
: config?.storage?.provider
? "config"
: "default",
required: false,
note: "Storage provider (local_disk or s3)",
},
{
key: "PAPERCLIP_STORAGE_LOCAL_DIR",
value: storageLocalDir,
source: process.env.PAPERCLIP_STORAGE_LOCAL_DIR
? "env"
: config?.storage?.localDisk?.baseDir
? "config"
: "default",
required: false,
note: "Local storage base directory for local_disk provider",
},
{
key: "PAPERCLIP_STORAGE_S3_BUCKET",
value: storageS3Bucket,
source: process.env.PAPERCLIP_STORAGE_S3_BUCKET
? "env"
: config?.storage?.s3?.bucket
? "config"
: "default",
required: false,
note: "S3 bucket name for s3 provider",
},
{
key: "PAPERCLIP_STORAGE_S3_REGION",
value: storageS3Region,
source: process.env.PAPERCLIP_STORAGE_S3_REGION
? "env"
: config?.storage?.s3?.region
? "config"
: "default",
required: false,
note: "S3 region for s3 provider",
},
{
key: "PAPERCLIP_STORAGE_S3_ENDPOINT",
value: storageS3Endpoint,
source: process.env.PAPERCLIP_STORAGE_S3_ENDPOINT
? "env"
: config?.storage?.s3?.endpoint
? "config"
: "default",
required: false,
note: "Optional custom endpoint for S3-compatible providers",
},
{
key: "PAPERCLIP_STORAGE_S3_PREFIX",
value: storageS3Prefix,
source: process.env.PAPERCLIP_STORAGE_S3_PREFIX
? "env"
: config?.storage?.s3?.prefix
? "config"
: "default",
required: false,
note: "Optional object key prefix",
},
{
key: "PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE",
value: storageS3ForcePathStyle,
source: process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE
? "env"
: config?.storage?.s3?.forcePathStyle !== undefined
? "config"
: "default",
required: false,
note: "Set true for path-style access on compatible providers",
},
];
const defaultConfigPath = resolveConfigPath();
if (process.env.PAPERCLIP_CONFIG || configPath !== defaultConfigPath) {
rows.push({
key: "PAPERCLIP_CONFIG",
value: process.env.PAPERCLIP_CONFIG ?? configPath,
source: process.env.PAPERCLIP_CONFIG ? "env" : "default",
required: false,
note: "Optional path override for config file",
});
}
return rows;
}
function uniqueByKey(rows: EnvVarRow[]): EnvVarRow[] {
const seen = new Set<string>();
const result: EnvVarRow[] = [];
for (const row of rows) {
if (seen.has(row.key)) continue;
seen.add(row.key);
result.push(row);
}
return result;
}
function quoteShellValue(value: string): string {
if (value === "") return "\"\"";
return `'${value.replaceAll("'", "'\\''")}'`;
}
@@ -0,0 +1,344 @@
import { setTimeout as delay } from "node:timers/promises";
import pc from "picocolors";
import type { Agent, HeartbeatRun, HeartbeatRunEvent, HeartbeatRunStatus } from "@paperclipai/shared";
import { getCLIAdapter } from "../adapters/index.js";
import { resolveCommandContext } from "./client/common.js";
const HEARTBEAT_SOURCES = ["timer", "assignment", "on_demand", "automation"] as const;
const HEARTBEAT_TRIGGERS = ["manual", "ping", "callback", "system"] as const;
const TERMINAL_STATUSES = new Set<HeartbeatRunStatus>(["succeeded", "failed", "cancelled", "timed_out"]);
const POLL_INTERVAL_MS = 200;
type HeartbeatSource = (typeof HEARTBEAT_SOURCES)[number];
type HeartbeatTrigger = (typeof HEARTBEAT_TRIGGERS)[number];
type InvokedHeartbeat = HeartbeatRun | { status: "skipped" };
interface HeartbeatRunEventRecord extends HeartbeatRunEvent {
type?: string | null;
}
interface HeartbeatRunOptions {
config?: string;
context?: string;
profile?: string;
agentId: string;
apiBase?: string;
apiKey?: string;
source: string;
trigger: string;
timeoutMs: string;
debug?: boolean;
json?: boolean;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asErrorText(value: unknown): string {
if (typeof value === "string") return value;
const obj = asRecord(value);
if (!obj) return "";
const message =
(typeof obj.message === "string" && obj.message) ||
(typeof obj.error === "string" && obj.error) ||
(typeof obj.code === "string" && obj.code) ||
"";
if (message) return message;
try {
return JSON.stringify(obj);
} catch {
return "";
}
}
type AdapterType = string;
export async function heartbeatRun(opts: HeartbeatRunOptions): Promise<void> {
const debug = Boolean(opts.debug);
const parsedTimeout = Number.parseInt(opts.timeoutMs, 10);
const timeoutMs = Number.isFinite(parsedTimeout) ? parsedTimeout : 0;
const source = HEARTBEAT_SOURCES.includes(opts.source as HeartbeatSource)
? (opts.source as HeartbeatSource)
: "on_demand";
const triggerDetail = HEARTBEAT_TRIGGERS.includes(opts.trigger as HeartbeatTrigger)
? (opts.trigger as HeartbeatTrigger)
: "manual";
const ctx = resolveCommandContext({
config: opts.config,
context: opts.context,
profile: opts.profile,
apiBase: opts.apiBase,
apiKey: opts.apiKey,
json: opts.json,
});
const api = ctx.api;
const agent = await api.get<Agent>(`/api/agents/${opts.agentId}`);
if (!agent || typeof agent !== "object" || !agent.id) {
console.error(pc.red(`Agent not found: ${opts.agentId}`));
return;
}
const invokeRes = await api.post<InvokedHeartbeat>(
`/api/agents/${opts.agentId}/wakeup`,
{
source: source,
triggerDetail: triggerDetail,
},
);
if (!invokeRes) {
console.error(pc.red("Failed to invoke heartbeat"));
return;
}
if ((invokeRes as { status?: string }).status === "skipped") {
console.log(pc.yellow("Heartbeat invocation was skipped"));
return;
}
const run = invokeRes as HeartbeatRun;
console.log(pc.cyan(`Invoked heartbeat run ${run.id} for agent ${agent.name} (${agent.id})`));
const runId = run.id;
let activeRunId: string | null = null;
let lastEventSeq = 0;
let logOffset = 0;
let stdoutJsonBuffer = "";
const printRawChunk = (stream: "stdout" | "stderr" | "system", chunk: string) => {
if (stream === "stdout") process.stdout.write(pc.green("[stdout] ") + chunk);
else if (stream === "stderr") process.stdout.write(pc.red("[stderr] ") + chunk);
else process.stdout.write(pc.yellow("[system] ") + chunk);
};
const printAdapterInvoke = (payload: Record<string, unknown>) => {
const adapterType = typeof payload.adapterType === "string" ? payload.adapterType : "unknown";
const command = typeof payload.command === "string" ? payload.command : "";
const cwd = typeof payload.cwd === "string" ? payload.cwd : "";
const args =
Array.isArray(payload.commandArgs) &&
(payload.commandArgs as unknown[]).every((v) => typeof v === "string")
? (payload.commandArgs as string[])
: [];
const env =
typeof payload.env === "object" && payload.env !== null && !Array.isArray(payload.env)
? (payload.env as Record<string, unknown>)
: null;
const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
const context =
typeof payload.context === "object" && payload.context !== null && !Array.isArray(payload.context)
? (payload.context as Record<string, unknown>)
: null;
console.log(pc.cyan(`Adapter: ${adapterType}`));
if (cwd) console.log(pc.cyan(`Working dir: ${cwd}`));
if (command) {
const rendered = args.length > 0 ? `${command} ${args.join(" ")}` : command;
console.log(pc.cyan(`Command: ${rendered}`));
}
if (env) {
console.log(pc.cyan("Env:"));
console.log(pc.gray(JSON.stringify(env, null, 2)));
}
if (context) {
console.log(pc.cyan("Context:"));
console.log(pc.gray(JSON.stringify(context, null, 2)));
}
if (prompt) {
console.log(pc.cyan("Prompt:"));
console.log(prompt);
}
};
const adapterType: AdapterType = agent.adapterType ?? "claude_local";
const cliAdapter = getCLIAdapter(adapterType);
const handleStreamChunk = (stream: "stdout" | "stderr" | "system", chunk: string) => {
if (debug) {
printRawChunk(stream, chunk);
return;
}
if (stream !== "stdout") {
printRawChunk(stream, chunk);
return;
}
const combined = stdoutJsonBuffer + chunk;
const lines = combined.split(/\r?\n/);
stdoutJsonBuffer = lines.pop() ?? "";
for (const line of lines) {
cliAdapter.formatStdoutEvent(line, debug);
}
};
const handleEvent = (event: HeartbeatRunEventRecord) => {
const payload = normalizePayload(event.payload);
if (event.runId !== runId) return;
const eventType = typeof event.eventType === "string"
? event.eventType
: typeof event.type === "string"
? event.type
: "";
if (eventType === "heartbeat.run.status") {
const status = typeof payload.status === "string" ? payload.status : null;
if (status) {
console.log(pc.blue(`[status] ${status}`));
}
} else if (eventType === "adapter.invoke") {
printAdapterInvoke(payload);
} else if (eventType === "heartbeat.run.log") {
const stream = typeof payload.stream === "string" ? payload.stream : "system";
const chunk = typeof payload.chunk === "string" ? payload.chunk : "";
if (!chunk) return;
if (stream === "stdout" || stream === "stderr" || stream === "system") {
handleStreamChunk(stream, chunk);
}
} else if (typeof event.message === "string") {
console.log(pc.gray(`[event] ${eventType || "heartbeat.run.event"}: ${event.message}`));
}
lastEventSeq = Math.max(lastEventSeq, event.seq ?? 0);
};
activeRunId = runId;
let finalStatus: string | null = null;
let finalError: string | null = null;
let finalRun: HeartbeatRun | null = null;
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : null;
if (!activeRunId) {
console.error(pc.red("Failed to capture heartbeat run id"));
return;
}
while (true) {
const events = await api.get<HeartbeatRunEvent[]>(
`/api/heartbeat-runs/${activeRunId}/events?afterSeq=${lastEventSeq}&limit=100`,
);
for (const event of Array.isArray(events) ? (events as HeartbeatRunEventRecord[]) : []) {
handleEvent(event);
}
const runList = (await api.get<(HeartbeatRun | null)[]>(
`/api/companies/${agent.companyId}/heartbeat-runs?agentId=${agent.id}`,
)) || [];
const currentRun = runList.find((r) => r && r.id === activeRunId) ?? null;
if (!currentRun) {
console.error(pc.red("Heartbeat run disappeared"));
break;
}
const currentStatus = currentRun.status as HeartbeatRunStatus | undefined;
if (currentStatus !== finalStatus && currentStatus) {
finalStatus = currentStatus;
console.log(pc.blue(`Status: ${currentStatus}`));
}
if (currentStatus && TERMINAL_STATUSES.has(currentStatus)) {
finalStatus = currentRun.status;
finalError = currentRun.error;
finalRun = currentRun;
break;
}
if (deadline && Date.now() >= deadline) {
finalError = `CLI timed out after ${timeoutMs}ms`;
finalStatus = "timed_out";
console.error(pc.yellow(finalError));
break;
}
const logResult = await api.get<{ content: string; nextOffset?: number }>(
`/api/heartbeat-runs/${activeRunId}/log?offset=${logOffset}&limitBytes=16384`,
{ ignoreNotFound: true },
);
if (logResult && logResult.content) {
for (const chunk of logResult.content.split(/\r?\n/)) {
if (!chunk) continue;
const parsed = safeParseLogLine(chunk);
if (!parsed) continue;
handleStreamChunk(parsed.stream, parsed.chunk);
}
if (typeof logResult.nextOffset === "number") {
logOffset = logResult.nextOffset;
} else if (logResult.content) {
logOffset += Buffer.byteLength(logResult.content, "utf8");
}
}
await delay(POLL_INTERVAL_MS);
}
if (finalStatus) {
if (!debug && stdoutJsonBuffer.trim()) {
cliAdapter.formatStdoutEvent(stdoutJsonBuffer, debug);
stdoutJsonBuffer = "";
}
const label = `Run ${activeRunId} completed with status ${finalStatus}`;
if (finalStatus === "succeeded") {
console.log(pc.green(label));
return;
}
console.log(pc.red(label));
if (finalError) {
console.log(pc.red(`Error: ${finalError}`));
}
if (finalRun) {
const resultObj = asRecord(finalRun.resultJson);
if (resultObj) {
const subtype = typeof resultObj.subtype === "string" ? resultObj.subtype : "";
const isError = resultObj.is_error === true;
const errors = Array.isArray(resultObj.errors) ? resultObj.errors.map(asErrorText).filter(Boolean) : [];
const resultText = typeof resultObj.result === "string" ? resultObj.result.trim() : "";
if (subtype || isError || errors.length > 0 || resultText) {
console.log(pc.red("Claude result details:"));
if (subtype) console.log(pc.red(` subtype: ${subtype}`));
if (isError) console.log(pc.red(" is_error: true"));
if (errors.length > 0) console.log(pc.red(` errors: ${errors.join(" | ")}`));
if (resultText) console.log(pc.red(` result: ${resultText}`));
}
}
const stderrExcerpt = typeof finalRun.stderrExcerpt === "string" ? finalRun.stderrExcerpt.trim() : "";
const stdoutExcerpt = typeof finalRun.stdoutExcerpt === "string" ? finalRun.stdoutExcerpt.trim() : "";
if (stderrExcerpt) {
console.log(pc.red("stderr excerpt:"));
console.log(stderrExcerpt);
}
if (stdoutExcerpt && (debug || !stderrExcerpt)) {
console.log(pc.gray("stdout excerpt:"));
console.log(stdoutExcerpt);
}
}
process.exitCode = 1;
} else {
process.exitCode = 1;
console.log(pc.gray("Heartbeat stream ended without terminal status"));
}
}
function normalizePayload(payload: unknown): Record<string, unknown> {
return typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
}
function safeParseLogLine(line: string): { stream: "stdout" | "stderr" | "system"; chunk: string } | null {
try {
const parsed = JSON.parse(line) as { stream?: unknown; chunk?: unknown };
const stream =
parsed.stream === "stdout" || parsed.stream === "stderr" || parsed.stream === "system"
? parsed.stream
: "system";
const chunk = typeof parsed.chunk === "string" ? parsed.chunk : "";
if (!chunk) return null;
return { stream, chunk };
} catch {
return null;
}
}
@@ -0,0 +1,491 @@
import * as p from "@clack/prompts";
import path from "node:path";
import pc from "picocolors";
import {
AUTH_BASE_URL_MODES,
DEPLOYMENT_EXPOSURES,
DEPLOYMENT_MODES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
type AuthBaseUrlMode,
type DeploymentExposure,
type DeploymentMode,
type SecretProvider,
type StorageProvider,
} from "@paperclipai/shared";
import { configExists, readConfig, resolveConfigPath, writeConfig } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js";
import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js";
import { promptDatabase } from "../prompts/database.js";
import { promptLlm } from "../prompts/llm.js";
import { promptLogging } from "../prompts/logging.js";
import { defaultSecretsConfig } from "../prompts/secrets.js";
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
import { promptServer } from "../prompts/server.js";
import {
describeLocalInstancePaths,
expandHomePrefix,
resolveDefaultBackupDir,
resolveDefaultEmbeddedPostgresDir,
resolveDefaultLogsDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
type SetupMode = "quickstart" | "advanced";
type OnboardOptions = {
config?: string;
run?: boolean;
yes?: boolean;
invokedByRun?: boolean;
};
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
const ONBOARD_ENV_KEYS = [
"PAPERCLIP_PUBLIC_URL",
"DATABASE_URL",
"PAPERCLIP_DB_BACKUP_ENABLED",
"PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES",
"PAPERCLIP_DB_BACKUP_RETENTION_DAYS",
"PAPERCLIP_DB_BACKUP_DIR",
"PAPERCLIP_DEPLOYMENT_MODE",
"PAPERCLIP_DEPLOYMENT_EXPOSURE",
"HOST",
"PORT",
"SERVE_UI",
"PAPERCLIP_ALLOWED_HOSTNAMES",
"PAPERCLIP_AUTH_BASE_URL_MODE",
"PAPERCLIP_AUTH_PUBLIC_BASE_URL",
"BETTER_AUTH_URL",
"BETTER_AUTH_BASE_URL",
"PAPERCLIP_STORAGE_PROVIDER",
"PAPERCLIP_STORAGE_LOCAL_DIR",
"PAPERCLIP_STORAGE_S3_BUCKET",
"PAPERCLIP_STORAGE_S3_REGION",
"PAPERCLIP_STORAGE_S3_ENDPOINT",
"PAPERCLIP_STORAGE_S3_PREFIX",
"PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE",
"PAPERCLIP_SECRETS_PROVIDER",
"PAPERCLIP_SECRETS_STRICT_MODE",
"PAPERCLIP_SECRETS_MASTER_KEY_FILE",
] as const;
function parseBooleanFromEnv(rawValue: string | undefined): boolean | null {
if (rawValue === undefined) return null;
const lower = rawValue.trim().toLowerCase();
if (lower === "true" || lower === "1" || lower === "yes") return true;
if (lower === "false" || lower === "0" || lower === "no") return false;
return null;
}
function parseNumberFromEnv(rawValue: string | undefined): number | null {
if (!rawValue) return null;
const parsed = Number(rawValue);
if (!Number.isFinite(parsed)) return null;
return parsed;
}
function parseEnumFromEnv<T extends string>(rawValue: string | undefined, allowedValues: readonly T[]): T | null {
if (!rawValue) return null;
return allowedValues.includes(rawValue as T) ? (rawValue as T) : null;
}
function resolvePathFromEnv(rawValue: string | undefined): string | null {
if (!rawValue || rawValue.trim().length === 0) return null;
return path.resolve(expandHomePrefix(rawValue.trim()));
}
function quickstartDefaultsFromEnv(): {
defaults: OnboardDefaults;
usedEnvKeys: string[];
ignoredEnvKeys: Array<{ key: string; reason: string }>;
} {
const instanceId = resolvePaperclipInstanceId();
const defaultStorage = defaultStorageConfig();
const defaultSecrets = defaultSecretsConfig();
const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
const publicUrl =
process.env.PAPERCLIP_PUBLIC_URL?.trim() ||
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() ||
process.env.BETTER_AUTH_URL?.trim() ||
process.env.BETTER_AUTH_BASE_URL?.trim() ||
undefined;
const deploymentMode =
parseEnumFromEnv<DeploymentMode>(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted";
const deploymentExposureFromEnv = parseEnumFromEnv<DeploymentExposure>(
process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE,
DEPLOYMENT_EXPOSURES,
);
const deploymentExposure =
deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private");
const authPublicBaseUrl = publicUrl;
const authBaseUrlModeFromEnv = parseEnumFromEnv<AuthBaseUrlMode>(
process.env.PAPERCLIP_AUTH_BASE_URL_MODE,
AUTH_BASE_URL_MODES,
);
const authBaseUrlMode = authBaseUrlModeFromEnv ?? (authPublicBaseUrl ? "explicit" : "auto");
const allowedHostnamesFromEnv = process.env.PAPERCLIP_ALLOWED_HOSTNAMES
? process.env.PAPERCLIP_ALLOWED_HOSTNAMES
.split(",")
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0)
: [];
const hostnameFromPublicUrl = publicUrl
? (() => {
try {
return new URL(publicUrl).hostname.trim().toLowerCase();
} catch {
return null;
}
})()
: null;
const storageProvider =
parseEnumFromEnv<StorageProvider>(process.env.PAPERCLIP_STORAGE_PROVIDER, STORAGE_PROVIDERS) ??
defaultStorage.provider;
const secretsProvider =
parseEnumFromEnv<SecretProvider>(process.env.PAPERCLIP_SECRETS_PROVIDER, SECRET_PROVIDERS) ??
defaultSecrets.provider;
const databaseBackupEnabled = parseBooleanFromEnv(process.env.PAPERCLIP_DB_BACKUP_ENABLED) ?? true;
const databaseBackupIntervalMinutes = Math.max(
1,
parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES) ?? 60,
);
const databaseBackupRetentionDays = Math.max(
1,
parseNumberFromEnv(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) ?? 30,
);
const defaults: OnboardDefaults = {
database: {
mode: databaseUrl ? "postgres" : "embedded-postgres",
...(databaseUrl ? { connectionString: databaseUrl } : {}),
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(instanceId),
embeddedPostgresPort: 54329,
backup: {
enabled: databaseBackupEnabled,
intervalMinutes: databaseBackupIntervalMinutes,
retentionDays: databaseBackupRetentionDays,
dir: resolvePathFromEnv(process.env.PAPERCLIP_DB_BACKUP_DIR) ?? resolveDefaultBackupDir(instanceId),
},
},
logging: {
mode: "file",
logDir: resolveDefaultLogsDir(instanceId),
},
server: {
deploymentMode,
exposure: deploymentExposure,
host: process.env.HOST ?? "127.0.0.1",
port: Number(process.env.PORT) || 3100,
allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])),
serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true,
},
auth: {
baseUrlMode: authBaseUrlMode,
disableSignUp: false,
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
},
storage: {
provider: storageProvider,
localDisk: {
baseDir:
resolvePathFromEnv(process.env.PAPERCLIP_STORAGE_LOCAL_DIR) ?? defaultStorage.localDisk.baseDir,
},
s3: {
bucket: process.env.PAPERCLIP_STORAGE_S3_BUCKET ?? defaultStorage.s3.bucket,
region: process.env.PAPERCLIP_STORAGE_S3_REGION ?? defaultStorage.s3.region,
endpoint: process.env.PAPERCLIP_STORAGE_S3_ENDPOINT ?? defaultStorage.s3.endpoint,
prefix: process.env.PAPERCLIP_STORAGE_S3_PREFIX ?? defaultStorage.s3.prefix,
forcePathStyle:
parseBooleanFromEnv(process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE) ??
defaultStorage.s3.forcePathStyle,
},
},
secrets: {
provider: secretsProvider,
strictMode: parseBooleanFromEnv(process.env.PAPERCLIP_SECRETS_STRICT_MODE) ?? defaultSecrets.strictMode,
localEncrypted: {
keyFilePath:
resolvePathFromEnv(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ??
defaultSecrets.localEncrypted.keyFilePath,
},
},
};
const ignoredEnvKeys: Array<{ key: string; reason: string }> = [];
if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) {
ignoredEnvKeys.push({
key: "PAPERCLIP_DEPLOYMENT_EXPOSURE",
reason: "Ignored because deployment mode local_trusted always forces private exposure",
});
}
const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key));
const usedEnvKeys = ONBOARD_ENV_KEYS.filter(
(key) => process.env[key] !== undefined && !ignoredKeySet.has(key),
);
return { defaults, usedEnvKeys, ignoredEnvKeys };
}
function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "database" | "server">): boolean {
return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres";
}
export async function onboard(opts: OnboardOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
const configPath = resolveConfigPath(opts.config);
const instance = describeLocalInstancePaths(resolvePaperclipInstanceId());
p.log.message(
pc.dim(
`Local home: ${instance.homeDir} | instance: ${instance.instanceId} | config: ${configPath}`,
),
);
if (configExists(opts.config)) {
p.log.message(pc.dim(`${configPath} exists, updating config`));
try {
readConfig(opts.config);
} catch (err) {
p.log.message(
pc.yellow(
`Existing config appears invalid and will be updated.\n${err instanceof Error ? err.message : String(err)}`,
),
);
}
}
let setupMode: SetupMode = "quickstart";
if (opts.yes) {
p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults."));
} else {
const setupModeChoice = await p.select({
message: "Choose setup path",
options: [
{
value: "quickstart" as const,
label: "Quickstart",
hint: "Recommended: local defaults + ready to run",
},
{
value: "advanced" as const,
label: "Advanced setup",
hint: "Customize database, server, storage, and more",
},
],
initialValue: "quickstart",
});
if (p.isCancel(setupModeChoice)) {
p.cancel("Setup cancelled.");
return;
}
setupMode = setupModeChoice as SetupMode;
}
let llm: PaperclipConfig["llm"] | undefined;
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
let {
database,
logging,
server,
auth,
storage,
secrets,
} = derivedDefaults;
if (setupMode === "advanced") {
p.log.step(pc.bold("Database"));
database = await promptDatabase(database);
if (database.mode === "postgres" && database.connectionString) {
const s = p.spinner();
s.start("Testing database connection...");
try {
const { createDb } = await import("@paperclipai/db");
const db = createDb(database.connectionString);
await db.execute("SELECT 1");
s.stop("Database connection successful");
} catch {
s.stop(pc.yellow("Could not connect to database — you can fix this later with `paperclipai doctor`"));
}
}
p.log.step(pc.bold("LLM Provider"));
llm = await promptLlm();
if (llm?.apiKey) {
const s = p.spinner();
s.start("Validating API key...");
try {
if (llm.provider === "claude") {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": llm.apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({
model: "claude-sonnet-4-5-20250929",
max_tokens: 1,
messages: [{ role: "user", content: "hi" }],
}),
});
if (res.ok || res.status === 400) {
s.stop("API key is valid");
} else if (res.status === 401) {
s.stop(pc.yellow("API key appears invalid — you can update it later"));
} else {
s.stop(pc.yellow("Could not validate API key — continuing anyway"));
}
} else {
const res = await fetch("https://api.openai.com/v1/models", {
headers: { Authorization: `Bearer ${llm.apiKey}` },
});
if (res.ok) {
s.stop("API key is valid");
} else if (res.status === 401) {
s.stop(pc.yellow("API key appears invalid — you can update it later"));
} else {
s.stop(pc.yellow("Could not validate API key — continuing anyway"));
}
}
} catch {
s.stop(pc.yellow("Could not reach API — continuing anyway"));
}
}
p.log.step(pc.bold("Logging"));
logging = await promptLogging();
p.log.step(pc.bold("Server"));
({ server, auth } = await promptServer({ currentServer: server, currentAuth: auth }));
p.log.step(pc.bold("Storage"));
storage = await promptStorage(storage);
p.log.step(pc.bold("Secrets"));
const secretsDefaults = defaultSecretsConfig();
secrets = {
provider: secrets.provider ?? secretsDefaults.provider,
strictMode: secrets.strictMode ?? secretsDefaults.strictMode,
localEncrypted: {
keyFilePath: secrets.localEncrypted?.keyFilePath ?? secretsDefaults.localEncrypted.keyFilePath,
},
};
p.log.message(
pc.dim(
`Using defaults: provider=${secrets.provider}, strictMode=${secrets.strictMode}, keyFile=${secrets.localEncrypted.keyFilePath}`,
),
);
} else {
p.log.step(pc.bold("Quickstart"));
p.log.message(pc.dim("Using quickstart defaults."));
if (usedEnvKeys.length > 0) {
p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`));
} else {
p.log.message(
pc.dim("No environment overrides detected: embedded database, file storage, local encrypted secrets."),
);
}
for (const ignored of ignoredEnvKeys) {
p.log.message(pc.dim(`Ignored ${ignored.key}: ${ignored.reason}`));
}
}
const jwtSecret = ensureAgentJwtSecret(configPath);
const envFilePath = resolveAgentJwtEnvFile(configPath);
if (jwtSecret.created) {
p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
} else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) {
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`);
} else {
p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`);
}
const config: PaperclipConfig = {
$meta: {
version: 1,
updatedAt: new Date().toISOString(),
source: "onboard",
},
...(llm && { llm }),
database,
logging,
server,
auth,
storage,
secrets,
};
const keyResult = ensureLocalSecretsKeyFile(config, configPath);
if (keyResult.status === "created") {
p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`);
} else if (keyResult.status === "existing") {
p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`));
}
writeConfig(config, opts.config);
p.note(
[
`Database: ${database.mode}`,
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
`Logging: ${logging.mode} -> ${logging.logDir}`,
`Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`,
`Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`,
`Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`,
`Storage: ${storage.provider}`,
`Secrets: ${secrets.provider} (strict mode ${secrets.strictMode ? "on" : "off"})`,
"Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured",
].join("\n"),
"Configuration saved",
);
p.note(
[
`Run: ${pc.cyan("paperclipai run")}`,
`Reconfigure later: ${pc.cyan("paperclipai configure")}`,
`Diagnose setup: ${pc.cyan("paperclipai doctor")}`,
].join("\n"),
"Next commands",
);
if (canCreateBootstrapInviteImmediately({ database, server })) {
p.log.step("Generating bootstrap CEO invite");
await bootstrapCeoInvite({ config: configPath });
}
let shouldRunNow = opts.run === true || opts.yes === true;
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
const answer = await p.confirm({
message: "Start Paperclip now?",
initialValue: true,
});
if (!p.isCancel(answer)) {
shouldRunNow = answer;
}
}
if (shouldRunNow && !opts.invokedByRun) {
process.env.PAPERCLIP_OPEN_ON_LISTEN = "true";
const { runCommand } = await import("./run.js");
await runCommand({ config: configPath, repair: true, yes: true });
return;
}
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
p.log.info(
[
"Bootstrap CEO invite will be created after the server starts.",
`Next: ${pc.cyan("paperclipai run")}`,
`Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`,
].join("\n"),
);
}
p.outro("You're all set!");
}
+190
View File
@@ -0,0 +1,190 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import * as p from "@clack/prompts";
import pc from "picocolors";
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
import { onboard } from "./onboard.js";
import { doctor } from "./doctor.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { configExists, resolveConfigPath } from "../config/store.js";
import type { PaperclipConfig } from "../config/schema.js";
import { readConfig } from "../config/store.js";
import {
describeLocalInstancePaths,
resolvePaperclipHomeDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
interface RunOptions {
config?: string;
instance?: string;
repair?: boolean;
yes?: boolean;
}
interface StartedServer {
apiUrl: string;
databaseUrl: string;
host: string;
listenPort: number;
}
export async function runCommand(opts: RunOptions): Promise<void> {
const instanceId = resolvePaperclipInstanceId(opts.instance);
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
const homeDir = resolvePaperclipHomeDir();
fs.mkdirSync(homeDir, { recursive: true });
const paths = describeLocalInstancePaths(instanceId);
fs.mkdirSync(paths.instanceRoot, { recursive: true });
const configPath = resolveConfigPath(opts.config);
process.env.PAPERCLIP_CONFIG = configPath;
loadPaperclipEnvFile(configPath);
p.intro(pc.bgCyan(pc.black(" 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) {
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 });
}
p.log.step("Running doctor checks...");
const summary = await doctor({
config: configPath,
repair: opts.repair ?? true,
yes: opts.yes ?? true,
});
if (summary.failed > 0) {
p.log.error("Doctor found blocking issues. Not starting server.");
process.exit(1);
}
const config = readConfig(configPath);
if (!config) {
p.log.error(`No config found at ${configPath}.`);
process.exit(1);
}
p.log.step("Starting Paperclip server...");
const startedServer = await importServerEntry();
if (shouldGenerateBootstrapInviteAfterStart(config)) {
p.log.step("Generating bootstrap CEO invite");
await bootstrapCeoInvite({
config: configPath,
dbUrl: startedServer.databaseUrl,
baseUrl: resolveBootstrapInviteBaseUrl(config, startedServer),
});
}
}
function resolveBootstrapInviteBaseUrl(
config: PaperclipConfig,
startedServer: StartedServer,
): string {
const explicitBaseUrl =
process.env.PAPERCLIP_PUBLIC_URL ??
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
process.env.BETTER_AUTH_URL ??
process.env.BETTER_AUTH_BASE_URL ??
(config.auth.baseUrlMode === "explicit" ? config.auth.publicBaseUrl : undefined);
if (typeof explicitBaseUrl === "string" && explicitBaseUrl.trim().length > 0) {
return explicitBaseUrl.trim().replace(/\/+$/, "");
}
return startedServer.apiUrl.replace(/\/api$/, "");
}
function formatError(err: unknown): string {
if (err instanceof Error) {
if (err.message && err.message.trim().length > 0) return err.message;
return err.name;
}
if (typeof err === "string") return err;
try {
return JSON.stringify(err);
} catch {
return String(err);
}
}
function isModuleNotFoundError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const code = (err as { code?: unknown }).code;
if (code === "ERR_MODULE_NOT_FOUND") return true;
return err.message.includes("Cannot find module");
}
function getMissingModuleSpecifier(err: unknown): string | null {
if (!(err instanceof Error)) return null;
const packageMatch = err.message.match(/Cannot find package '([^']+)' imported from/);
if (packageMatch?.[1]) return packageMatch[1];
const moduleMatch = err.message.match(/Cannot find module '([^']+)'/);
if (moduleMatch?.[1]) return moduleMatch[1];
return null;
}
function maybeEnableUiDevMiddleware(entrypoint: string): void {
if (process.env.PAPERCLIP_UI_DEV_MIDDLEWARE !== undefined) return;
const normalized = entrypoint.replaceAll("\\", "/");
if (normalized.endsWith("/server/src/index.ts") || normalized.endsWith("@paperclipai/server/src/index.ts")) {
process.env.PAPERCLIP_UI_DEV_MIDDLEWARE = "true";
}
}
async function importServerEntry(): Promise<StartedServer> {
// Dev mode: try local workspace path (monorepo with tsx)
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
if (fs.existsSync(devEntry)) {
maybeEnableUiDevMiddleware(devEntry);
const mod = await import(pathToFileURL(devEntry).href);
return await startServerFromModule(mod, devEntry);
}
// Production mode: import the published @paperclipai/server package
try {
const mod = await import("@paperclipai/server");
return await startServerFromModule(mod, "@paperclipai/server");
} catch (err) {
const missingSpecifier = getMissingModuleSpecifier(err);
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
if (isModuleNotFoundError(err) && missingServerEntrypoint) {
throw new Error(
`Could not locate a Paperclip server entrypoint.\n` +
`Tried: ${devEntry}, @paperclipai/server\n` +
`${formatError(err)}`,
);
}
throw new Error(
`Paperclip server failed to start.\n` +
`${formatError(err)}`,
);
}
}
function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean {
return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres";
}
async function startServerFromModule(mod: unknown, label: string): Promise<StartedServer> {
const startServer = (mod as { startServer?: () => Promise<StartedServer> }).startServer;
if (typeof startServer !== "function") {
throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`);
}
return await startServer();
}
@@ -0,0 +1,274 @@
import { randomInt } from "node:crypto";
import path from "node:path";
import type { PaperclipConfig } from "../config/schema.js";
import { expandHomePrefix } from "../config/home.js";
export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees";
export const WORKTREE_SEED_MODES = ["minimal", "full"] as const;
export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number];
export type WorktreeSeedPlan = {
mode: WorktreeSeedMode;
excludedTables: string[];
nullifyColumns: Record<string, string[]>;
};
const MINIMAL_WORKTREE_EXCLUDED_TABLES = [
"activity_log",
"agent_runtime_state",
"agent_task_sessions",
"agent_wakeup_requests",
"cost_events",
"heartbeat_run_events",
"heartbeat_runs",
"workspace_runtime_services",
];
const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record<string, string[]> = {
issues: ["checkout_run_id", "execution_run_id"],
};
export type WorktreeLocalPaths = {
cwd: string;
repoConfigDir: string;
configPath: string;
envPath: string;
homeDir: string;
instanceId: string;
instanceRoot: string;
contextPath: string;
embeddedPostgresDataDir: string;
backupDir: string;
logDir: string;
secretsKeyFilePath: string;
storageDir: string;
};
export type WorktreeUiBranding = {
name: string;
color: string;
};
export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode {
return (WORKTREE_SEED_MODES as readonly string[]).includes(value);
}
export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan {
if (mode === "full") {
return {
mode,
excludedTables: [],
nullifyColumns: {},
};
}
return {
mode,
excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES],
nullifyColumns: {
...MINIMAL_WORKTREE_NULLIFIED_COLUMNS,
},
};
}
function nonEmpty(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isLoopbackHost(hostname: string): boolean {
const value = hostname.trim().toLowerCase();
return value === "127.0.0.1" || value === "localhost" || value === "::1";
}
export function sanitizeWorktreeInstanceId(rawValue: string): string {
const trimmed = rawValue.trim().toLowerCase();
const normalized = trimmed
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
return normalized || "worktree";
}
export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string {
return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd));
}
function hslComponentToHex(n: number): string {
return Math.round(Math.max(0, Math.min(255, n)))
.toString(16)
.padStart(2, "0");
}
function hslToHex(hue: number, saturation: number, lightness: number): string {
const s = Math.max(0, Math.min(100, saturation)) / 100;
const l = Math.max(0, Math.min(100, lightness)) / 100;
const c = (1 - Math.abs((2 * l) - 1)) * s;
const h = ((hue % 360) + 360) % 360;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - (c / 2);
let r = 0;
let g = 0;
let b = 0;
if (h < 60) {
r = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`;
}
export function generateWorktreeColor(): string {
return hslToHex(randomInt(0, 360), 68, 56);
}
export function resolveWorktreeLocalPaths(opts: {
cwd: string;
homeDir?: string;
instanceId: string;
}): WorktreeLocalPaths {
const cwd = path.resolve(opts.cwd);
const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME));
const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId);
const repoConfigDir = path.resolve(cwd, ".paperclip");
return {
cwd,
repoConfigDir,
configPath: path.resolve(repoConfigDir, "config.json"),
envPath: path.resolve(repoConfigDir, ".env"),
homeDir,
instanceId: opts.instanceId,
instanceRoot,
contextPath: path.resolve(homeDir, "context.json"),
embeddedPostgresDataDir: path.resolve(instanceRoot, "db"),
backupDir: path.resolve(instanceRoot, "data", "backups"),
logDir: path.resolve(instanceRoot, "logs"),
secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"),
storageDir: path.resolve(instanceRoot, "data", "storage"),
};
}
export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined {
if (!rawUrl) return undefined;
try {
const parsed = new URL(rawUrl);
if (!isLoopbackHost(parsed.hostname)) return rawUrl;
parsed.port = String(port);
return parsed.toString();
} catch {
return rawUrl;
}
}
export function buildWorktreeConfig(input: {
sourceConfig: PaperclipConfig | null;
paths: WorktreeLocalPaths;
serverPort: number;
databasePort: number;
now?: Date;
}): PaperclipConfig {
const { sourceConfig, paths, serverPort, databasePort } = input;
const nowIso = (input.now ?? new Date()).toISOString();
const source = sourceConfig;
const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort);
return {
$meta: {
version: 1,
updatedAt: nowIso,
source: "configure",
},
...(source?.llm ? { llm: source.llm } : {}),
database: {
mode: "embedded-postgres",
embeddedPostgresDataDir: paths.embeddedPostgresDataDir,
embeddedPostgresPort: databasePort,
backup: {
enabled: source?.database.backup.enabled ?? true,
intervalMinutes: source?.database.backup.intervalMinutes ?? 60,
retentionDays: source?.database.backup.retentionDays ?? 30,
dir: paths.backupDir,
},
},
logging: {
mode: source?.logging.mode ?? "file",
logDir: paths.logDir,
},
server: {
deploymentMode: source?.server.deploymentMode ?? "local_trusted",
exposure: source?.server.exposure ?? "private",
host: source?.server.host ?? "127.0.0.1",
port: serverPort,
allowedHostnames: source?.server.allowedHostnames ?? [],
serveUi: source?.server.serveUi ?? true,
},
auth: {
baseUrlMode: source?.auth.baseUrlMode ?? "auto",
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
disableSignUp: source?.auth.disableSignUp ?? false,
},
storage: {
provider: source?.storage.provider ?? "local_disk",
localDisk: {
baseDir: paths.storageDir,
},
s3: {
bucket: source?.storage.s3.bucket ?? "paperclip",
region: source?.storage.s3.region ?? "us-east-1",
endpoint: source?.storage.s3.endpoint,
prefix: source?.storage.s3.prefix ?? "",
forcePathStyle: source?.storage.s3.forcePathStyle ?? false,
},
},
secrets: {
provider: source?.secrets.provider ?? "local_encrypted",
strictMode: source?.secrets.strictMode ?? false,
localEncrypted: {
keyFilePath: paths.secretsKeyFilePath,
},
},
};
}
export function buildWorktreeEnvEntries(
paths: WorktreeLocalPaths,
branding?: WorktreeUiBranding,
): Record<string, string> {
return {
PAPERCLIP_HOME: paths.homeDir,
PAPERCLIP_INSTANCE_ID: paths.instanceId,
PAPERCLIP_CONFIG: paths.configPath,
PAPERCLIP_CONTEXT: paths.contextPath,
PAPERCLIP_IN_WORKTREE: "true",
...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}),
...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}),
};
}
function shellEscape(value: string): string {
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
}
export function formatShellExports(entries: Record<string, string>): string {
return Object.entries(entries)
.filter(([, value]) => typeof value === "string" && value.trim().length > 0)
.map(([key, value]) => `export ${key}=${shellEscape(value)}`)
.join("\n");
}
@@ -0,0 +1,764 @@
import {
agents,
assets,
documentRevisions,
goals,
issueAttachments,
issueComments,
issueDocuments,
issues,
projects,
projectWorkspaces,
} from "@paperclipai/db";
type IssueRow = typeof issues.$inferSelect;
type CommentRow = typeof issueComments.$inferSelect;
type AgentRow = typeof agents.$inferSelect;
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
type GoalRow = typeof goals.$inferSelect;
type IssueDocumentLinkRow = typeof issueDocuments.$inferSelect;
type DocumentRevisionTableRow = typeof documentRevisions.$inferSelect;
type IssueAttachmentTableRow = typeof issueAttachments.$inferSelect;
type AssetRow = typeof assets.$inferSelect;
export const WORKTREE_MERGE_SCOPES = ["issues", "comments"] as const;
export type WorktreeMergeScope = (typeof WORKTREE_MERGE_SCOPES)[number];
export type ImportAdjustment =
| "clear_assignee_agent"
| "clear_project"
| "clear_project_workspace"
| "clear_goal"
| "clear_author_agent"
| "coerce_in_progress_to_todo"
| "clear_document_agent"
| "clear_document_revision_agent"
| "clear_attachment_agent";
export type IssueMergeAction = "skip_existing" | "insert";
export type CommentMergeAction = "skip_existing" | "skip_missing_parent" | "insert";
export type PlannedIssueInsert = {
source: IssueRow;
action: "insert";
previewIssueNumber: number;
previewIdentifier: string;
targetStatus: string;
targetAssigneeAgentId: string | null;
targetCreatedByAgentId: string | null;
targetProjectId: string | null;
targetProjectWorkspaceId: string | null;
targetGoalId: string | null;
projectResolution: "preserved" | "cleared" | "mapped" | "imported";
mappedProjectName: string | null;
adjustments: ImportAdjustment[];
};
export type PlannedIssueSkip = {
source: IssueRow;
action: "skip_existing";
driftKeys: string[];
};
export type PlannedCommentInsert = {
source: CommentRow;
action: "insert";
targetAuthorAgentId: string | null;
adjustments: ImportAdjustment[];
};
export type PlannedCommentSkip = {
source: CommentRow;
action: "skip_existing" | "skip_missing_parent";
};
export type IssueDocumentRow = {
id: IssueDocumentLinkRow["id"];
companyId: IssueDocumentLinkRow["companyId"];
issueId: IssueDocumentLinkRow["issueId"];
documentId: IssueDocumentLinkRow["documentId"];
key: IssueDocumentLinkRow["key"];
linkCreatedAt: IssueDocumentLinkRow["createdAt"];
linkUpdatedAt: IssueDocumentLinkRow["updatedAt"];
title: string | null;
format: string;
latestBody: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
createdByAgentId: string | null;
createdByUserId: string | null;
updatedByAgentId: string | null;
updatedByUserId: string | null;
documentCreatedAt: Date;
documentUpdatedAt: Date;
};
export type DocumentRevisionRow = {
id: DocumentRevisionTableRow["id"];
companyId: DocumentRevisionTableRow["companyId"];
documentId: DocumentRevisionTableRow["documentId"];
revisionNumber: DocumentRevisionTableRow["revisionNumber"];
body: DocumentRevisionTableRow["body"];
changeSummary: DocumentRevisionTableRow["changeSummary"];
createdByAgentId: string | null;
createdByUserId: string | null;
createdAt: Date;
};
export type IssueAttachmentRow = {
id: IssueAttachmentTableRow["id"];
companyId: IssueAttachmentTableRow["companyId"];
issueId: IssueAttachmentTableRow["issueId"];
issueCommentId: IssueAttachmentTableRow["issueCommentId"];
assetId: IssueAttachmentTableRow["assetId"];
provider: AssetRow["provider"];
objectKey: AssetRow["objectKey"];
contentType: AssetRow["contentType"];
byteSize: AssetRow["byteSize"];
sha256: AssetRow["sha256"];
originalFilename: AssetRow["originalFilename"];
createdByAgentId: string | null;
createdByUserId: string | null;
assetCreatedAt: Date;
assetUpdatedAt: Date;
attachmentCreatedAt: Date;
attachmentUpdatedAt: Date;
};
export type PlannedDocumentRevisionInsert = {
source: DocumentRevisionRow;
targetRevisionNumber: number;
targetCreatedByAgentId: string | null;
adjustments: ImportAdjustment[];
};
export type PlannedIssueDocumentInsert = {
source: IssueDocumentRow;
action: "insert";
targetCreatedByAgentId: string | null;
targetUpdatedByAgentId: string | null;
latestRevisionId: string | null;
latestRevisionNumber: number;
revisionsToInsert: PlannedDocumentRevisionInsert[];
adjustments: ImportAdjustment[];
};
export type PlannedIssueDocumentMerge = {
source: IssueDocumentRow;
action: "merge_existing";
targetCreatedByAgentId: string | null;
targetUpdatedByAgentId: string | null;
latestRevisionId: string | null;
latestRevisionNumber: number;
revisionsToInsert: PlannedDocumentRevisionInsert[];
adjustments: ImportAdjustment[];
};
export type PlannedIssueDocumentSkip = {
source: IssueDocumentRow;
action: "skip_existing" | "skip_missing_parent" | "skip_conflicting_key";
};
export type PlannedAttachmentInsert = {
source: IssueAttachmentRow;
action: "insert";
targetIssueCommentId: string | null;
targetCreatedByAgentId: string | null;
adjustments: ImportAdjustment[];
};
export type PlannedAttachmentSkip = {
source: IssueAttachmentRow;
action: "skip_existing" | "skip_missing_parent";
};
export type PlannedProjectImport = {
source: ProjectRow;
targetLeadAgentId: string | null;
targetGoalId: string | null;
workspaces: ProjectWorkspaceRow[];
};
export type WorktreeMergePlan = {
companyId: string;
companyName: string;
issuePrefix: string;
previewIssueCounterStart: number;
scopes: WorktreeMergeScope[];
projectImports: PlannedProjectImport[];
issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip>;
commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip>;
documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip>;
attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip>;
counts: {
projectsToImport: number;
issuesToInsert: number;
issuesExisting: number;
issueDrift: number;
commentsToInsert: number;
commentsExisting: number;
commentsMissingParent: number;
documentsToInsert: number;
documentsToMerge: number;
documentsExisting: number;
documentsConflictingKey: number;
documentsMissingParent: number;
documentRevisionsToInsert: number;
attachmentsToInsert: number;
attachmentsExisting: number;
attachmentsMissingParent: number;
};
adjustments: Record<ImportAdjustment, number>;
};
function compareIssueCoreFields(source: IssueRow, target: IssueRow): string[] {
const driftKeys: string[] = [];
if (source.title !== target.title) driftKeys.push("title");
if ((source.description ?? null) !== (target.description ?? null)) driftKeys.push("description");
if (source.status !== target.status) driftKeys.push("status");
if (source.priority !== target.priority) driftKeys.push("priority");
if ((source.parentId ?? null) !== (target.parentId ?? null)) driftKeys.push("parentId");
if ((source.projectId ?? null) !== (target.projectId ?? null)) driftKeys.push("projectId");
if ((source.projectWorkspaceId ?? null) !== (target.projectWorkspaceId ?? null)) driftKeys.push("projectWorkspaceId");
if ((source.goalId ?? null) !== (target.goalId ?? null)) driftKeys.push("goalId");
if ((source.assigneeAgentId ?? null) !== (target.assigneeAgentId ?? null)) driftKeys.push("assigneeAgentId");
if ((source.assigneeUserId ?? null) !== (target.assigneeUserId ?? null)) driftKeys.push("assigneeUserId");
return driftKeys;
}
function incrementAdjustment(
counts: Record<ImportAdjustment, number>,
adjustment: ImportAdjustment,
): void {
counts[adjustment] += 1;
}
function groupBy<T>(rows: T[], keyFor: (row: T) => string): Map<string, T[]> {
const out = new Map<string, T[]>();
for (const row of rows) {
const key = keyFor(row);
const existing = out.get(key);
if (existing) {
existing.push(row);
} else {
out.set(key, [row]);
}
}
return out;
}
function sameDate(left: Date, right: Date): boolean {
return left.getTime() === right.getTime();
}
function sortDocumentRows(rows: IssueDocumentRow[]): IssueDocumentRow[] {
return [...rows].sort((left, right) => {
const createdDelta = left.documentCreatedAt.getTime() - right.documentCreatedAt.getTime();
if (createdDelta !== 0) return createdDelta;
const linkDelta = left.linkCreatedAt.getTime() - right.linkCreatedAt.getTime();
if (linkDelta !== 0) return linkDelta;
return left.documentId.localeCompare(right.documentId);
});
}
function sortDocumentRevisions(rows: DocumentRevisionRow[]): DocumentRevisionRow[] {
return [...rows].sort((left, right) => {
const revisionDelta = left.revisionNumber - right.revisionNumber;
if (revisionDelta !== 0) return revisionDelta;
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
});
}
function sortAttachments(rows: IssueAttachmentRow[]): IssueAttachmentRow[] {
return [...rows].sort((left, right) => {
const createdDelta = left.attachmentCreatedAt.getTime() - right.attachmentCreatedAt.getTime();
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
});
}
function sortIssuesForImport(sourceIssues: IssueRow[]): IssueRow[] {
const byId = new Map(sourceIssues.map((issue) => [issue.id, issue]));
const memoDepth = new Map<string, number>();
const depthFor = (issue: IssueRow, stack = new Set<string>()): number => {
const memoized = memoDepth.get(issue.id);
if (memoized !== undefined) return memoized;
if (!issue.parentId) {
memoDepth.set(issue.id, 0);
return 0;
}
if (stack.has(issue.id)) {
memoDepth.set(issue.id, 0);
return 0;
}
const parent = byId.get(issue.parentId);
if (!parent) {
memoDepth.set(issue.id, 0);
return 0;
}
stack.add(issue.id);
const depth = depthFor(parent, stack) + 1;
stack.delete(issue.id);
memoDepth.set(issue.id, depth);
return depth;
};
return [...sourceIssues].sort((left, right) => {
const depthDelta = depthFor(left) - depthFor(right);
if (depthDelta !== 0) return depthDelta;
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
});
}
export function parseWorktreeMergeScopes(rawValue: string | undefined): WorktreeMergeScope[] {
if (!rawValue || rawValue.trim().length === 0) {
return ["issues", "comments"];
}
const parsed = rawValue
.split(",")
.map((value) => value.trim().toLowerCase())
.filter((value): value is WorktreeMergeScope =>
(WORKTREE_MERGE_SCOPES as readonly string[]).includes(value),
);
if (parsed.length === 0) {
throw new Error(
`Invalid scope "${rawValue}". Expected a comma-separated list of: ${WORKTREE_MERGE_SCOPES.join(", ")}.`,
);
}
return [...new Set(parsed)];
}
export function buildWorktreeMergePlan(input: {
companyId: string;
companyName: string;
issuePrefix: string;
previewIssueCounterStart: number;
scopes: WorktreeMergeScope[];
sourceIssues: IssueRow[];
targetIssues: IssueRow[];
sourceComments: CommentRow[];
targetComments: CommentRow[];
sourceProjects?: ProjectRow[];
sourceProjectWorkspaces?: ProjectWorkspaceRow[];
sourceDocuments?: IssueDocumentRow[];
targetDocuments?: IssueDocumentRow[];
sourceDocumentRevisions?: DocumentRevisionRow[];
targetDocumentRevisions?: DocumentRevisionRow[];
sourceAttachments?: IssueAttachmentRow[];
targetAttachments?: IssueAttachmentRow[];
targetAgents: AgentRow[];
targetProjects: ProjectRow[];
targetProjectWorkspaces: ProjectWorkspaceRow[];
targetGoals: GoalRow[];
importProjectIds?: Iterable<string>;
projectIdOverrides?: Record<string, string | null | undefined>;
}): WorktreeMergePlan {
const targetIssuesById = new Map(input.targetIssues.map((issue) => [issue.id, issue]));
const targetCommentIds = new Set(input.targetComments.map((comment) => comment.id));
const targetAgentIds = new Set(input.targetAgents.map((agent) => agent.id));
const targetProjectIds = new Set(input.targetProjects.map((project) => project.id));
const targetProjectsById = new Map(input.targetProjects.map((project) => [project.id, project]));
const targetProjectWorkspaceIds = new Set(input.targetProjectWorkspaces.map((workspace) => workspace.id));
const targetGoalIds = new Set(input.targetGoals.map((goal) => goal.id));
const sourceProjectsById = new Map((input.sourceProjects ?? []).map((project) => [project.id, project]));
const sourceProjectWorkspaces = input.sourceProjectWorkspaces ?? [];
const sourceProjectWorkspacesByProjectId = groupBy(sourceProjectWorkspaces, (workspace) => workspace.projectId);
const importProjectIds = new Set(input.importProjectIds ?? []);
const scopes = new Set(input.scopes);
const adjustmentCounts: Record<ImportAdjustment, number> = {
clear_assignee_agent: 0,
clear_project: 0,
clear_project_workspace: 0,
clear_goal: 0,
clear_author_agent: 0,
coerce_in_progress_to_todo: 0,
clear_document_agent: 0,
clear_document_revision_agent: 0,
clear_attachment_agent: 0,
};
const projectImports: PlannedProjectImport[] = [];
for (const projectId of importProjectIds) {
if (targetProjectIds.has(projectId)) continue;
const sourceProject = sourceProjectsById.get(projectId);
if (!sourceProject) continue;
projectImports.push({
source: sourceProject,
targetLeadAgentId:
sourceProject.leadAgentId && targetAgentIds.has(sourceProject.leadAgentId)
? sourceProject.leadAgentId
: null,
targetGoalId:
sourceProject.goalId && targetGoalIds.has(sourceProject.goalId)
? sourceProject.goalId
: null,
workspaces: [...(sourceProjectWorkspacesByProjectId.get(projectId) ?? [])].sort((left, right) => {
const primaryDelta = Number(right.isPrimary) - Number(left.isPrimary);
if (primaryDelta !== 0) return primaryDelta;
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
}),
});
}
const importedProjectWorkspaceIds = new Set(
projectImports.flatMap((project) => project.workspaces.map((workspace) => workspace.id)),
);
const issuePlans: Array<PlannedIssueInsert | PlannedIssueSkip> = [];
let nextPreviewIssueNumber = input.previewIssueCounterStart;
for (const issue of sortIssuesForImport(input.sourceIssues)) {
const existing = targetIssuesById.get(issue.id);
if (existing) {
issuePlans.push({
source: issue,
action: "skip_existing",
driftKeys: compareIssueCoreFields(issue, existing),
});
continue;
}
nextPreviewIssueNumber += 1;
const adjustments: ImportAdjustment[] = [];
const targetAssigneeAgentId =
issue.assigneeAgentId && targetAgentIds.has(issue.assigneeAgentId) ? issue.assigneeAgentId : null;
if (issue.assigneeAgentId && !targetAssigneeAgentId) {
adjustments.push("clear_assignee_agent");
incrementAdjustment(adjustmentCounts, "clear_assignee_agent");
}
const targetCreatedByAgentId =
issue.createdByAgentId && targetAgentIds.has(issue.createdByAgentId) ? issue.createdByAgentId : null;
let targetProjectId =
issue.projectId && targetProjectIds.has(issue.projectId) ? issue.projectId : null;
let projectResolution: PlannedIssueInsert["projectResolution"] = targetProjectId ? "preserved" : "cleared";
let mappedProjectName: string | null = null;
const overrideProjectId =
issue.projectId && input.projectIdOverrides
? input.projectIdOverrides[issue.projectId] ?? null
: null;
if (!targetProjectId && overrideProjectId && targetProjectIds.has(overrideProjectId)) {
targetProjectId = overrideProjectId;
projectResolution = "mapped";
mappedProjectName = targetProjectsById.get(overrideProjectId)?.name ?? null;
}
if (!targetProjectId && issue.projectId && importProjectIds.has(issue.projectId)) {
const sourceProject = sourceProjectsById.get(issue.projectId);
if (sourceProject) {
targetProjectId = sourceProject.id;
projectResolution = "imported";
mappedProjectName = sourceProject.name;
}
}
if (issue.projectId && !targetProjectId) {
adjustments.push("clear_project");
incrementAdjustment(adjustmentCounts, "clear_project");
}
const targetProjectWorkspaceId =
targetProjectId
&& targetProjectId === issue.projectId
&& issue.projectWorkspaceId
&& (targetProjectWorkspaceIds.has(issue.projectWorkspaceId)
|| importedProjectWorkspaceIds.has(issue.projectWorkspaceId))
? issue.projectWorkspaceId
: null;
if (issue.projectWorkspaceId && !targetProjectWorkspaceId) {
adjustments.push("clear_project_workspace");
incrementAdjustment(adjustmentCounts, "clear_project_workspace");
}
const targetGoalId =
issue.goalId && targetGoalIds.has(issue.goalId) ? issue.goalId : null;
if (issue.goalId && !targetGoalId) {
adjustments.push("clear_goal");
incrementAdjustment(adjustmentCounts, "clear_goal");
}
let targetStatus = issue.status;
if (
targetStatus === "in_progress"
&& !targetAssigneeAgentId
&& !(issue.assigneeUserId && issue.assigneeUserId.trim().length > 0)
) {
targetStatus = "todo";
adjustments.push("coerce_in_progress_to_todo");
incrementAdjustment(adjustmentCounts, "coerce_in_progress_to_todo");
}
issuePlans.push({
source: issue,
action: "insert",
previewIssueNumber: nextPreviewIssueNumber,
previewIdentifier: `${input.issuePrefix}-${nextPreviewIssueNumber}`,
targetStatus,
targetAssigneeAgentId,
targetCreatedByAgentId,
targetProjectId,
targetProjectWorkspaceId,
targetGoalId,
projectResolution,
mappedProjectName,
adjustments,
});
}
const issueIdsAvailableAfterImport = new Set<string>([
...input.targetIssues.map((issue) => issue.id),
...issuePlans.filter((plan): plan is PlannedIssueInsert => plan.action === "insert").map((plan) => plan.source.id),
]);
const commentPlans: Array<PlannedCommentInsert | PlannedCommentSkip> = [];
if (scopes.has("comments")) {
const sortedComments = [...input.sourceComments].sort((left, right) => {
const createdDelta = left.createdAt.getTime() - right.createdAt.getTime();
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
});
for (const comment of sortedComments) {
if (targetCommentIds.has(comment.id)) {
commentPlans.push({ source: comment, action: "skip_existing" });
continue;
}
if (!issueIdsAvailableAfterImport.has(comment.issueId)) {
commentPlans.push({ source: comment, action: "skip_missing_parent" });
continue;
}
const adjustments: ImportAdjustment[] = [];
const targetAuthorAgentId =
comment.authorAgentId && targetAgentIds.has(comment.authorAgentId) ? comment.authorAgentId : null;
if (comment.authorAgentId && !targetAuthorAgentId) {
adjustments.push("clear_author_agent");
incrementAdjustment(adjustmentCounts, "clear_author_agent");
}
commentPlans.push({
source: comment,
action: "insert",
targetAuthorAgentId,
adjustments,
});
}
}
const sourceDocuments = input.sourceDocuments ?? [];
const targetDocuments = input.targetDocuments ?? [];
const sourceDocumentRevisions = input.sourceDocumentRevisions ?? [];
const targetDocumentRevisions = input.targetDocumentRevisions ?? [];
const targetDocumentsById = new Map(targetDocuments.map((document) => [document.documentId, document]));
const targetDocumentsByIssueKey = new Map(targetDocuments.map((document) => [`${document.issueId}:${document.key}`, document]));
const sourceRevisionsByDocumentId = groupBy(sourceDocumentRevisions, (revision) => revision.documentId);
const targetRevisionsByDocumentId = groupBy(targetDocumentRevisions, (revision) => revision.documentId);
const commentIdsAvailableAfterImport = new Set<string>([
...input.targetComments.map((comment) => comment.id),
...commentPlans.filter((plan): plan is PlannedCommentInsert => plan.action === "insert").map((plan) => plan.source.id),
]);
const documentPlans: Array<PlannedIssueDocumentInsert | PlannedIssueDocumentMerge | PlannedIssueDocumentSkip> = [];
for (const document of sortDocumentRows(sourceDocuments)) {
if (!issueIdsAvailableAfterImport.has(document.issueId)) {
documentPlans.push({ source: document, action: "skip_missing_parent" });
continue;
}
const existingDocument = targetDocumentsById.get(document.documentId);
const conflictingIssueKeyDocument = targetDocumentsByIssueKey.get(`${document.issueId}:${document.key}`);
if (!existingDocument && conflictingIssueKeyDocument && conflictingIssueKeyDocument.documentId !== document.documentId) {
documentPlans.push({ source: document, action: "skip_conflicting_key" });
continue;
}
const adjustments: ImportAdjustment[] = [];
const targetCreatedByAgentId =
document.createdByAgentId && targetAgentIds.has(document.createdByAgentId) ? document.createdByAgentId : null;
const targetUpdatedByAgentId =
document.updatedByAgentId && targetAgentIds.has(document.updatedByAgentId) ? document.updatedByAgentId : null;
if (
(document.createdByAgentId && !targetCreatedByAgentId)
|| (document.updatedByAgentId && !targetUpdatedByAgentId)
) {
adjustments.push("clear_document_agent");
incrementAdjustment(adjustmentCounts, "clear_document_agent");
}
const sourceRevisions = sortDocumentRevisions(sourceRevisionsByDocumentId.get(document.documentId) ?? []);
const targetRevisions = sortDocumentRevisions(targetRevisionsByDocumentId.get(document.documentId) ?? []);
const existingRevisionIds = new Set(targetRevisions.map((revision) => revision.id));
const usedRevisionNumbers = new Set(targetRevisions.map((revision) => revision.revisionNumber));
let nextRevisionNumber = targetRevisions.reduce(
(maxValue, revision) => Math.max(maxValue, revision.revisionNumber),
0,
) + 1;
const targetRevisionNumberById = new Map<string, number>(
targetRevisions.map((revision) => [revision.id, revision.revisionNumber]),
);
const revisionsToInsert: PlannedDocumentRevisionInsert[] = [];
for (const revision of sourceRevisions) {
if (existingRevisionIds.has(revision.id)) continue;
let targetRevisionNumber = revision.revisionNumber;
if (usedRevisionNumbers.has(targetRevisionNumber)) {
while (usedRevisionNumbers.has(nextRevisionNumber)) {
nextRevisionNumber += 1;
}
targetRevisionNumber = nextRevisionNumber;
nextRevisionNumber += 1;
}
usedRevisionNumbers.add(targetRevisionNumber);
targetRevisionNumberById.set(revision.id, targetRevisionNumber);
const revisionAdjustments: ImportAdjustment[] = [];
const targetCreatedByAgentId =
revision.createdByAgentId && targetAgentIds.has(revision.createdByAgentId) ? revision.createdByAgentId : null;
if (revision.createdByAgentId && !targetCreatedByAgentId) {
revisionAdjustments.push("clear_document_revision_agent");
incrementAdjustment(adjustmentCounts, "clear_document_revision_agent");
}
revisionsToInsert.push({
source: revision,
targetRevisionNumber,
targetCreatedByAgentId,
adjustments: revisionAdjustments,
});
}
const latestRevisionId = document.latestRevisionId ?? existingDocument?.latestRevisionId ?? null;
const latestRevisionNumber =
(latestRevisionId ? targetRevisionNumberById.get(latestRevisionId) : undefined)
?? document.latestRevisionNumber
?? existingDocument?.latestRevisionNumber
?? 0;
if (!existingDocument) {
documentPlans.push({
source: document,
action: "insert",
targetCreatedByAgentId,
targetUpdatedByAgentId,
latestRevisionId,
latestRevisionNumber,
revisionsToInsert,
adjustments,
});
continue;
}
const documentAlreadyMatches =
existingDocument.key === document.key
&& existingDocument.title === document.title
&& existingDocument.format === document.format
&& existingDocument.latestBody === document.latestBody
&& (existingDocument.latestRevisionId ?? null) === latestRevisionId
&& existingDocument.latestRevisionNumber === latestRevisionNumber
&& (existingDocument.updatedByAgentId ?? null) === targetUpdatedByAgentId
&& (existingDocument.updatedByUserId ?? null) === (document.updatedByUserId ?? null)
&& sameDate(existingDocument.documentUpdatedAt, document.documentUpdatedAt)
&& sameDate(existingDocument.linkUpdatedAt, document.linkUpdatedAt)
&& revisionsToInsert.length === 0;
if (documentAlreadyMatches) {
documentPlans.push({ source: document, action: "skip_existing" });
continue;
}
documentPlans.push({
source: document,
action: "merge_existing",
targetCreatedByAgentId,
targetUpdatedByAgentId,
latestRevisionId,
latestRevisionNumber,
revisionsToInsert,
adjustments,
});
}
const sourceAttachments = input.sourceAttachments ?? [];
const targetAttachmentIds = new Set((input.targetAttachments ?? []).map((attachment) => attachment.id));
const attachmentPlans: Array<PlannedAttachmentInsert | PlannedAttachmentSkip> = [];
for (const attachment of sortAttachments(sourceAttachments)) {
if (targetAttachmentIds.has(attachment.id)) {
attachmentPlans.push({ source: attachment, action: "skip_existing" });
continue;
}
if (!issueIdsAvailableAfterImport.has(attachment.issueId)) {
attachmentPlans.push({ source: attachment, action: "skip_missing_parent" });
continue;
}
const adjustments: ImportAdjustment[] = [];
const targetCreatedByAgentId =
attachment.createdByAgentId && targetAgentIds.has(attachment.createdByAgentId)
? attachment.createdByAgentId
: null;
if (attachment.createdByAgentId && !targetCreatedByAgentId) {
adjustments.push("clear_attachment_agent");
incrementAdjustment(adjustmentCounts, "clear_attachment_agent");
}
attachmentPlans.push({
source: attachment,
action: "insert",
targetIssueCommentId:
attachment.issueCommentId && commentIdsAvailableAfterImport.has(attachment.issueCommentId)
? attachment.issueCommentId
: null,
targetCreatedByAgentId,
adjustments,
});
}
const counts = {
projectsToImport: projectImports.length,
issuesToInsert: issuePlans.filter((plan) => plan.action === "insert").length,
issuesExisting: issuePlans.filter((plan) => plan.action === "skip_existing").length,
issueDrift: issuePlans.filter((plan) => plan.action === "skip_existing" && plan.driftKeys.length > 0).length,
commentsToInsert: commentPlans.filter((plan) => plan.action === "insert").length,
commentsExisting: commentPlans.filter((plan) => plan.action === "skip_existing").length,
commentsMissingParent: commentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
documentsToInsert: documentPlans.filter((plan) => plan.action === "insert").length,
documentsToMerge: documentPlans.filter((plan) => plan.action === "merge_existing").length,
documentsExisting: documentPlans.filter((plan) => plan.action === "skip_existing").length,
documentsConflictingKey: documentPlans.filter((plan) => plan.action === "skip_conflicting_key").length,
documentsMissingParent: documentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
documentRevisionsToInsert: documentPlans.reduce(
(sum, plan) =>
sum + (plan.action === "insert" || plan.action === "merge_existing" ? plan.revisionsToInsert.length : 0),
0,
),
attachmentsToInsert: attachmentPlans.filter((plan) => plan.action === "insert").length,
attachmentsExisting: attachmentPlans.filter((plan) => plan.action === "skip_existing").length,
attachmentsMissingParent: attachmentPlans.filter((plan) => plan.action === "skip_missing_parent").length,
};
return {
companyId: input.companyId,
companyName: input.companyName,
issuePrefix: input.issuePrefix,
previewIssueCounterStart: input.previewIssueCounterStart,
scopes: input.scopes,
projectImports,
issuePlans,
commentPlans,
documentPlans,
attachmentPlans,
counts,
adjustments: adjustmentCounts,
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,48 @@
import path from "node:path";
import {
expandHomePrefix,
resolveDefaultConfigPath,
resolveDefaultContextPath,
resolvePaperclipInstanceId,
} from "./home.js";
export interface DataDirOptionLike {
dataDir?: string;
config?: string;
context?: string;
instance?: string;
}
export interface DataDirCommandSupport {
hasConfigOption?: boolean;
hasContextOption?: boolean;
}
export function applyDataDirOverride(
options: DataDirOptionLike,
support: DataDirCommandSupport = {},
): string | null {
const rawDataDir = options.dataDir?.trim();
if (!rawDataDir) return null;
const resolvedDataDir = path.resolve(expandHomePrefix(rawDataDir));
process.env.PAPERCLIP_HOME = resolvedDataDir;
if (support.hasConfigOption) {
const hasConfigOverride = Boolean(options.config?.trim()) || Boolean(process.env.PAPERCLIP_CONFIG?.trim());
if (!hasConfigOverride) {
const instanceId = resolvePaperclipInstanceId(options.instance);
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
process.env.PAPERCLIP_CONFIG = resolveDefaultConfigPath(instanceId);
}
}
if (support.hasContextOption) {
const hasContextOverride = Boolean(options.context?.trim()) || Boolean(process.env.PAPERCLIP_CONTEXT?.trim());
if (!hasContextOverride) {
process.env.PAPERCLIP_CONTEXT = resolveDefaultContextPath();
}
}
return resolvedDataDir;
}
+125
View File
@@ -0,0 +1,125 @@
import fs from "node:fs";
import path from "node:path";
import { randomBytes } from "node:crypto";
import { config as loadDotenv, parse as parseEnvFileContents } from "dotenv";
import { resolveConfigPath } from "./store.js";
const JWT_SECRET_ENV_KEY = "PAPERCLIP_AGENT_JWT_SECRET";
function resolveEnvFilePath(configPath?: string) {
return path.resolve(path.dirname(resolveConfigPath(configPath)), ".env");
}
const loadedEnvFiles = new Set<string>();
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function parseEnvFile(contents: string) {
try {
return parseEnvFileContents(contents);
} catch {
return {};
}
}
function formatEnvValue(value: string): string {
if (/^[A-Za-z0-9_./:@-]+$/.test(value)) {
return value;
}
return JSON.stringify(value);
}
function renderEnvFile(entries: Record<string, string>) {
const lines = [
"# Paperclip environment variables",
"# Generated by Paperclip CLI commands",
...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`),
"",
];
return lines.join("\n");
}
export function resolvePaperclipEnvFile(configPath?: string): string {
return resolveEnvFilePath(configPath);
}
export function resolveAgentJwtEnvFile(configPath?: string): string {
return resolveEnvFilePath(configPath);
}
export function loadPaperclipEnvFile(configPath?: string): void {
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
}
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
if (loadedEnvFiles.has(filePath)) return;
if (!fs.existsSync(filePath)) return;
loadedEnvFiles.add(filePath);
loadDotenv({ path: filePath, override: false, quiet: true });
}
export function readAgentJwtSecretFromEnv(configPath?: string): string | null {
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
const raw = process.env[JWT_SECRET_ENV_KEY];
return isNonEmpty(raw) ? raw!.trim() : null;
}
export function readAgentJwtSecretFromEnvFile(filePath = resolveEnvFilePath()): string | null {
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf-8");
const values = parseEnvFile(raw);
const value = values[JWT_SECRET_ENV_KEY];
return isNonEmpty(value) ? value!.trim() : null;
}
export function ensureAgentJwtSecret(configPath?: string): { secret: string; created: boolean } {
const existingEnv = readAgentJwtSecretFromEnv(configPath);
if (existingEnv) {
return { secret: existingEnv, created: false };
}
const envFilePath = resolveEnvFilePath(configPath);
const existingFile = readAgentJwtSecretFromEnvFile(envFilePath);
const secret = existingFile ?? randomBytes(32).toString("hex");
const created = !existingFile;
if (!existingFile) {
writeAgentJwtEnv(secret, envFilePath);
}
return { secret, created };
}
export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void {
mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath);
}
export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record<string, string> {
if (!fs.existsSync(filePath)) return {};
return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
}
export function writePaperclipEnvEntries(entries: Record<string, string>, filePath = resolveEnvFilePath()): void {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, renderEnvFile(entries), {
mode: 0o600,
});
}
export function mergePaperclipEnvEntries(
entries: Record<string, string>,
filePath = resolveEnvFilePath(),
): Record<string, string> {
const current = readPaperclipEnvEntries(filePath);
const next = {
...current,
...Object.fromEntries(
Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0),
),
};
writePaperclipEnvEntries(next, filePath);
return next;
}
@@ -0,0 +1,80 @@
import os from "node:os";
import path from "node:path";
const DEFAULT_INSTANCE_ID = "default";
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
export function resolvePaperclipHomeDir(): string {
const envHome = process.env.PAPERCLIP_HOME?.trim();
if (envHome) return path.resolve(expandHomePrefix(envHome));
return path.resolve(os.homedir(), ".paperclip");
}
export function resolvePaperclipInstanceId(override?: string): string {
const raw = override?.trim() || process.env.PAPERCLIP_INSTANCE_ID?.trim() || DEFAULT_INSTANCE_ID;
if (!INSTANCE_ID_RE.test(raw)) {
throw new Error(
`Invalid instance id '${raw}'. Allowed characters: letters, numbers, '_' and '-'.`,
);
}
return raw;
}
export function resolvePaperclipInstanceRoot(instanceId?: string): string {
const id = resolvePaperclipInstanceId(instanceId);
return path.resolve(resolvePaperclipHomeDir(), "instances", id);
}
export function resolveDefaultConfigPath(instanceId?: string): string {
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "config.json");
}
export function resolveDefaultContextPath(): string {
return path.resolve(resolvePaperclipHomeDir(), "context.json");
}
export function resolveDefaultCliAuthPath(): string {
return path.resolve(resolvePaperclipHomeDir(), "auth.json");
}
export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db");
}
export function resolveDefaultLogsDir(instanceId?: string): string {
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "logs");
}
export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string {
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "secrets", "master.key");
}
export function resolveDefaultStorageDir(instanceId?: string): string {
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage");
}
export function resolveDefaultBackupDir(instanceId?: string): string {
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "backups");
}
export function expandHomePrefix(value: string): string {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
return value;
}
export function describeLocalInstancePaths(instanceId?: string) {
const resolvedInstanceId = resolvePaperclipInstanceId(instanceId);
const instanceRoot = resolvePaperclipInstanceRoot(resolvedInstanceId);
return {
homeDir: resolvePaperclipHomeDir(),
instanceId: resolvedInstanceId,
instanceRoot,
configPath: resolveDefaultConfigPath(resolvedInstanceId),
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId),
backupDir: resolveDefaultBackupDir(resolvedInstanceId),
logDir: resolveDefaultLogsDir(resolvedInstanceId),
secretsKeyFilePath: resolveDefaultSecretsKeyFilePath(resolvedInstanceId),
storageDir: resolveDefaultStorageDir(resolvedInstanceId),
};
}
@@ -0,0 +1,26 @@
export function normalizeHostnameInput(raw: string): string {
const input = raw.trim();
if (!input) {
throw new Error("Hostname is required");
}
try {
const url = input.includes("://") ? new URL(input) : new URL(`http://${input}`);
const hostname = url.hostname.trim().toLowerCase();
if (!hostname) throw new Error("Hostname is required");
return hostname;
} catch {
throw new Error(`Invalid hostname: ${raw}`);
}
}
export function parseHostnameCsv(raw: string): string[] {
if (!raw.trim()) return [];
const unique = new Set<string>();
for (const part of raw.split(",")) {
const hostname = normalizeHostnameInput(part);
unique.add(hostname);
}
return Array.from(unique);
}
@@ -0,0 +1,28 @@
export {
paperclipConfigSchema,
configMetaSchema,
llmConfigSchema,
databaseBackupConfigSchema,
databaseConfigSchema,
loggingConfigSchema,
serverConfigSchema,
authConfigSchema,
storageConfigSchema,
storageLocalDiskConfigSchema,
storageS3ConfigSchema,
secretsConfigSchema,
secretsLocalEncryptedConfigSchema,
type PaperclipConfig,
type LlmConfig,
type DatabaseBackupConfig,
type DatabaseConfig,
type LoggingConfig,
type ServerConfig,
type AuthConfig,
type StorageConfig,
type StorageLocalDiskConfig,
type StorageS3Config,
type SecretsConfig,
type SecretsLocalEncryptedConfig,
type ConfigMeta,
} from "@paperclipai/shared";
@@ -0,0 +1,48 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { PaperclipConfig } from "./schema.js";
import { resolveRuntimeLikePath } from "../utils/path-resolver.js";
export type EnsureSecretsKeyResult =
| { status: "created"; path: string }
| { status: "existing"; path: string }
| { status: "skipped_env"; path: null }
| { status: "skipped_provider"; path: null };
export function ensureLocalSecretsKeyFile(
config: Pick<PaperclipConfig, "secrets">,
configPath?: string,
): EnsureSecretsKeyResult {
if (config.secrets.provider !== "local_encrypted") {
return { status: "skipped_provider", path: null };
}
const envMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY;
if (envMasterKey && envMasterKey.trim().length > 0) {
return { status: "skipped_env", path: null };
}
const keyFileOverride = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
const configuredPath =
keyFileOverride && keyFileOverride.trim().length > 0
? keyFileOverride.trim()
: config.secrets.localEncrypted.keyFilePath;
const keyFilePath = resolveRuntimeLikePath(configuredPath, configPath);
if (fs.existsSync(keyFilePath)) {
return { status: "existing", path: keyFilePath };
}
fs.mkdirSync(path.dirname(keyFilePath), { recursive: true });
fs.writeFileSync(keyFilePath, randomBytes(32).toString("base64"), {
encoding: "utf8",
mode: 0o600,
});
try {
fs.chmodSync(keyFilePath, 0o600);
} catch {
// best effort
}
return { status: "created", path: keyFilePath };
}
+120
View File
@@ -0,0 +1,120 @@
import fs from "node:fs";
import path from "node:path";
import { paperclipConfigSchema, type PaperclipConfig } from "./schema.js";
import {
resolveDefaultConfigPath,
resolvePaperclipInstanceId,
} from "./home.js";
const DEFAULT_CONFIG_BASENAME = "config.json";
function findConfigFileFromAncestors(startDir: string): string | null {
const absoluteStartDir = path.resolve(startDir);
let currentDir = absoluteStartDir;
while (true) {
const candidate = path.resolve(currentDir, ".paperclip", DEFAULT_CONFIG_BASENAME);
if (fs.existsSync(candidate)) {
return candidate;
}
const nextDir = path.resolve(currentDir, "..");
if (nextDir === currentDir) break;
currentDir = nextDir;
}
return null;
}
export function resolveConfigPath(overridePath?: string): string {
if (overridePath) return path.resolve(overridePath);
if (process.env.PAPERCLIP_CONFIG) return path.resolve(process.env.PAPERCLIP_CONFIG);
return findConfigFileFromAncestors(process.cwd()) ?? resolveDefaultConfigPath(resolvePaperclipInstanceId());
}
function parseJson(filePath: string): unknown {
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
} catch (err) {
throw new Error(`Failed to parse JSON at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
function migrateLegacyConfig(raw: unknown): unknown {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
const config = { ...(raw as Record<string, unknown>) };
const databaseRaw = config.database;
if (typeof databaseRaw !== "object" || databaseRaw === null || Array.isArray(databaseRaw)) {
return config;
}
const database = { ...(databaseRaw as Record<string, unknown>) };
if (database.mode === "pglite") {
database.mode = "embedded-postgres";
if (typeof database.embeddedPostgresDataDir !== "string" && typeof database.pgliteDataDir === "string") {
database.embeddedPostgresDataDir = database.pgliteDataDir;
}
if (
typeof database.embeddedPostgresPort !== "number" &&
typeof database.pglitePort === "number" &&
Number.isFinite(database.pglitePort)
) {
database.embeddedPostgresPort = database.pglitePort;
}
}
config.database = database;
return config;
}
function formatValidationError(err: unknown): string {
const issues = (err as { issues?: Array<{ path?: unknown; message?: unknown }> })?.issues;
if (Array.isArray(issues) && issues.length > 0) {
return issues
.map((issue) => {
const pathParts = Array.isArray(issue.path) ? issue.path.map(String) : [];
const issuePath = pathParts.length > 0 ? pathParts.join(".") : "config";
const message = typeof issue.message === "string" ? issue.message : "Invalid value";
return `${issuePath}: ${message}`;
})
.join("; ");
}
return err instanceof Error ? err.message : String(err);
}
export function readConfig(configPath?: string): PaperclipConfig | null {
const filePath = resolveConfigPath(configPath);
if (!fs.existsSync(filePath)) return null;
const raw = parseJson(filePath);
const migrated = migrateLegacyConfig(raw);
const parsed = paperclipConfigSchema.safeParse(migrated);
if (!parsed.success) {
throw new Error(`Invalid config at ${filePath}: ${formatValidationError(parsed.error)}`);
}
return parsed.data;
}
export function writeConfig(
config: PaperclipConfig,
configPath?: string,
): void {
const filePath = resolveConfigPath(configPath);
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
// Backup existing config before overwriting
if (fs.existsSync(filePath)) {
const backupPath = filePath + ".backup";
fs.copyFileSync(filePath, backupPath);
fs.chmodSync(backupPath, 0o600);
}
fs.writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", {
mode: 0o600,
});
}
export function configExists(configPath?: string): boolean {
return fs.existsSync(resolveConfigPath(configPath));
}
+160
View File
@@ -0,0 +1,160 @@
import { Command } from "commander";
import { onboard } from "./commands/onboard.js";
import { doctor } from "./commands/doctor.js";
import { envCommand } from "./commands/env.js";
import { configure } from "./commands/configure.js";
import { addAllowedHostname } from "./commands/allowed-hostname.js";
import { heartbeatRun } from "./commands/heartbeat-run.js";
import { runCommand } from "./commands/run.js";
import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js";
import { dbBackupCommand } from "./commands/db-backup.js";
import { registerContextCommands } from "./commands/client/context.js";
import { registerCompanyCommands } from "./commands/client/company.js";
import { registerIssueCommands } from "./commands/client/issue.js";
import { registerAgentCommands } from "./commands/client/agent.js";
import { registerApprovalCommands } from "./commands/client/approval.js";
import { registerActivityCommands } from "./commands/client/activity.js";
import { registerDashboardCommands } from "./commands/client/dashboard.js";
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
import { loadPaperclipEnvFile } from "./config/env.js";
import { registerWorktreeCommands } from "./commands/worktree.js";
import { registerPluginCommands } from "./commands/client/plugin.js";
import { registerClientAuthCommands } from "./commands/client/auth.js";
const program = new Command();
const DATA_DIR_OPTION_HELP =
"Paperclip data directory root (isolates state from ~/.paperclip)";
program
.name("paperclipai")
.description("Paperclip CLI — setup, diagnose, and configure your instance")
.version("0.2.7");
program.hook("preAction", (_thisCommand, actionCommand) => {
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
const optionNames = new Set(actionCommand.options.map((option) => option.attributeName()));
applyDataDirOverride(options, {
hasConfigOption: optionNames.has("config"),
hasContextOption: optionNames.has("context"),
});
loadPaperclipEnvFile(options.config);
});
program
.command("onboard")
.description("Interactive first-run setup wizard")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("-y, --yes", "Accept defaults (quickstart + start immediately)", false)
.option("--run", "Start Paperclip immediately after saving config", false)
.action(onboard);
program
.command("doctor")
.description("Run diagnostic checks on your Paperclip setup")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--repair", "Attempt to repair issues automatically")
.alias("--fix")
.option("-y, --yes", "Skip repair confirmation prompts")
.action(async (opts) => {
await doctor(opts);
});
program
.command("env")
.description("Print environment variables for deployment")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.action(envCommand);
program
.command("configure")
.description("Update configuration sections")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, storage, secrets)")
.action(configure);
program
.command("db:backup")
.description("Create a one-off database backup using current config")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--dir <path>", "Backup output directory (overrides config)")
.option("--retention-days <days>", "Retention window used for pruning", (value) => Number(value))
.option("--filename-prefix <prefix>", "Backup filename prefix", "paperclip")
.option("--json", "Print backup metadata as JSON")
.action(async (opts) => {
await dbBackupCommand(opts);
});
program
.command("allowed-hostname")
.description("Allow a hostname for authenticated/private mode access")
.argument("<host>", "Hostname to allow (for example dotta-macbook-pro)")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.action(addAllowedHostname);
program
.command("run")
.description("Bootstrap local setup (onboard + doctor) and run Paperclip")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("-i, --instance <id>", "Local instance id (default: default)")
.option("--repair", "Attempt automatic repairs during doctor", true)
.option("--no-repair", "Disable automatic repairs during doctor")
.action(runCommand);
const heartbeat = program.command("heartbeat").description("Heartbeat utilities");
heartbeat
.command("run")
.description("Run one agent heartbeat and stream live logs")
.requiredOption("-a, --agent-id <agentId>", "Agent ID to invoke")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--context <path>", "Path to CLI context file")
.option("--profile <name>", "CLI context profile name")
.option("--api-base <url>", "Base URL for the Paperclip server API")
.option("--api-key <token>", "Bearer token for agent-authenticated calls")
.option(
"--source <source>",
"Invocation source (timer | assignment | on_demand | automation)",
"on_demand",
)
.option("--trigger <trigger>", "Trigger detail (manual | ping | callback | system)", "manual")
.option("--timeout-ms <ms>", "Max time to wait before giving up", "0")
.option("--json", "Output raw JSON where applicable")
.option("--debug", "Show raw adapter stdout/stderr JSON chunks")
.action(heartbeatRun);
registerContextCommands(program);
registerCompanyCommands(program);
registerIssueCommands(program);
registerAgentCommands(program);
registerApprovalCommands(program);
registerActivityCommands(program);
registerDashboardCommands(program);
registerWorktreeCommands(program);
registerPluginCommands(program);
const auth = program.command("auth").description("Authentication and bootstrap utilities");
auth
.command("bootstrap-ceo")
.description("Create a one-time bootstrap invite URL for first instance admin")
.option("-c, --config <path>", "Path to config file")
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--force", "Create new invite even if admin already exists", false)
.option("--expires-hours <hours>", "Invite expiration window in hours", (value) => Number(value))
.option("--base-url <url>", "Public base URL used to print invite link")
.action(bootstrapCeoInvite);
registerClientAuthCommands(auth);
program.parseAsync().catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});
@@ -0,0 +1,157 @@
import * as p from "@clack/prompts";
import type { DatabaseConfig } from "../config/schema.js";
import {
resolveDefaultBackupDir,
resolveDefaultEmbeddedPostgresDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
export async function promptDatabase(current?: DatabaseConfig): Promise<DatabaseConfig> {
const instanceId = resolvePaperclipInstanceId();
const defaultEmbeddedDir = resolveDefaultEmbeddedPostgresDir(instanceId);
const defaultBackupDir = resolveDefaultBackupDir(instanceId);
const base: DatabaseConfig = current ?? {
mode: "embedded-postgres",
embeddedPostgresDataDir: defaultEmbeddedDir,
embeddedPostgresPort: 54329,
backup: {
enabled: true,
intervalMinutes: 60,
retentionDays: 30,
dir: defaultBackupDir,
},
};
const mode = await p.select({
message: "Database mode",
options: [
{ value: "embedded-postgres" as const, label: "Embedded PostgreSQL (managed locally)", hint: "recommended" },
{ value: "postgres" as const, label: "PostgreSQL (external server)" },
],
initialValue: base.mode,
});
if (p.isCancel(mode)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
let connectionString: string | undefined = base.connectionString;
let embeddedPostgresDataDir = base.embeddedPostgresDataDir || defaultEmbeddedDir;
let embeddedPostgresPort = base.embeddedPostgresPort || 54329;
if (mode === "postgres") {
const value = await p.text({
message: "PostgreSQL connection string",
defaultValue: base.connectionString ?? "",
placeholder: "postgres://user:pass@localhost:5432/paperclip",
validate: (val) => {
if (!val) return "Connection string is required for PostgreSQL mode";
if (!val.startsWith("postgres")) return "Must be a postgres:// or postgresql:// URL";
},
});
if (p.isCancel(value)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
connectionString = value;
} else {
const dataDir = await p.text({
message: "Embedded PostgreSQL data directory",
defaultValue: base.embeddedPostgresDataDir || defaultEmbeddedDir,
placeholder: defaultEmbeddedDir,
});
if (p.isCancel(dataDir)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
embeddedPostgresDataDir = dataDir || defaultEmbeddedDir;
const portValue = await p.text({
message: "Embedded PostgreSQL port",
defaultValue: String(base.embeddedPostgresPort || 54329),
placeholder: "54329",
validate: (val) => {
const n = Number(val);
if (!Number.isInteger(n) || n < 1 || n > 65535) return "Port must be an integer between 1 and 65535";
},
});
if (p.isCancel(portValue)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
embeddedPostgresPort = Number(portValue || "54329");
connectionString = undefined;
}
const backupEnabled = await p.confirm({
message: "Enable automatic database backups?",
initialValue: base.backup.enabled,
});
if (p.isCancel(backupEnabled)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const backupDirInput = await p.text({
message: "Backup directory",
defaultValue: base.backup.dir || defaultBackupDir,
placeholder: defaultBackupDir,
validate: (val) => (!val || val.trim().length === 0 ? "Backup directory is required" : undefined),
});
if (p.isCancel(backupDirInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const backupIntervalInput = await p.text({
message: "Backup interval (minutes)",
defaultValue: String(base.backup.intervalMinutes || 60),
placeholder: "60",
validate: (val) => {
const n = Number(val);
if (!Number.isInteger(n) || n < 1) return "Interval must be a positive integer";
if (n > 10080) return "Interval must be 10080 minutes (7 days) or less";
return undefined;
},
});
if (p.isCancel(backupIntervalInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const backupRetentionInput = await p.text({
message: "Backup retention (days)",
defaultValue: String(base.backup.retentionDays || 30),
placeholder: "30",
validate: (val) => {
const n = Number(val);
if (!Number.isInteger(n) || n < 1) return "Retention must be a positive integer";
if (n > 3650) return "Retention must be 3650 days or less";
return undefined;
},
});
if (p.isCancel(backupRetentionInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
return {
mode,
connectionString,
embeddedPostgresDataDir,
embeddedPostgresPort,
backup: {
enabled: backupEnabled,
intervalMinutes: Number(backupIntervalInput || "60"),
retentionDays: Number(backupRetentionInput || "30"),
dir: backupDirInput || defaultBackupDir,
},
};
}
@@ -0,0 +1,43 @@
import * as p from "@clack/prompts";
import type { LlmConfig } from "../config/schema.js";
export async function promptLlm(): Promise<LlmConfig | undefined> {
const configureLlm = await p.confirm({
message: "Configure an LLM provider now?",
initialValue: false,
});
if (p.isCancel(configureLlm)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
if (!configureLlm) return undefined;
const provider = await p.select({
message: "LLM provider",
options: [
{ value: "claude" as const, label: "Claude (Anthropic)" },
{ value: "openai" as const, label: "OpenAI" },
],
});
if (p.isCancel(provider)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const apiKey = await p.password({
message: `${provider === "claude" ? "Anthropic" : "OpenAI"} API key`,
validate: (val) => {
if (!val) return "API key is required";
},
});
if (p.isCancel(apiKey)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
return { provider, apiKey };
}
@@ -0,0 +1,37 @@
import * as p from "@clack/prompts";
import type { LoggingConfig } from "../config/schema.js";
import { resolveDefaultLogsDir, resolvePaperclipInstanceId } from "../config/home.js";
export async function promptLogging(): Promise<LoggingConfig> {
const defaultLogDir = resolveDefaultLogsDir(resolvePaperclipInstanceId());
const mode = await p.select({
message: "Logging mode",
options: [
{ value: "file" as const, label: "File-based logging", hint: "recommended" },
{ value: "cloud" as const, label: "Cloud logging", hint: "coming soon" },
],
});
if (p.isCancel(mode)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
if (mode === "file") {
const logDir = await p.text({
message: "Log directory",
defaultValue: defaultLogDir,
placeholder: defaultLogDir,
});
if (p.isCancel(logDir)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
return { mode: "file", logDir: logDir || defaultLogDir };
}
p.note("Cloud logging is coming soon. Using file-based logging for now.");
return { mode: "file", logDir: defaultLogDir };
}
@@ -0,0 +1,99 @@
import * as p from "@clack/prompts";
import type { SecretProvider } from "@paperclipai/shared";
import type { SecretsConfig } from "../config/schema.js";
import { resolveDefaultSecretsKeyFilePath, resolvePaperclipInstanceId } from "../config/home.js";
function defaultKeyFilePath(): string {
return resolveDefaultSecretsKeyFilePath(resolvePaperclipInstanceId());
}
export function defaultSecretsConfig(): SecretsConfig {
const keyFilePath = defaultKeyFilePath();
return {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath,
},
};
}
export async function promptSecrets(current?: SecretsConfig): Promise<SecretsConfig> {
const base = current ?? defaultSecretsConfig();
const provider = await p.select({
message: "Secrets provider",
options: [
{
value: "local_encrypted" as const,
label: "Local encrypted (recommended)",
hint: "best for single-developer installs",
},
{
value: "aws_secrets_manager" as const,
label: "AWS Secrets Manager",
hint: "requires external adapter integration",
},
{
value: "gcp_secret_manager" as const,
label: "GCP Secret Manager",
hint: "requires external adapter integration",
},
{
value: "vault" as const,
label: "HashiCorp Vault",
hint: "requires external adapter integration",
},
],
initialValue: base.provider,
});
if (p.isCancel(provider)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const strictMode = await p.confirm({
message: "Require secret refs for sensitive env vars?",
initialValue: base.strictMode,
});
if (p.isCancel(strictMode)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const fallbackDefault = defaultKeyFilePath();
let keyFilePath = base.localEncrypted.keyFilePath || fallbackDefault;
if (provider === "local_encrypted") {
const keyPath = await p.text({
message: "Local encrypted key file path",
defaultValue: keyFilePath,
placeholder: fallbackDefault,
validate: (value) => {
if (!value || value.trim().length === 0) return "Key file path is required";
},
});
if (p.isCancel(keyPath)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
keyFilePath = keyPath.trim();
}
if (provider !== "local_encrypted") {
p.note(
`${provider} is not fully wired in this build yet. Keep local_encrypted unless you are actively implementing that adapter.`,
"Heads up",
);
}
return {
provider: provider as SecretProvider,
strictMode,
localEncrypted: {
keyFilePath,
},
};
}
@@ -0,0 +1,164 @@
import * as p from "@clack/prompts";
import type { AuthConfig, ServerConfig } from "../config/schema.js";
import { parseHostnameCsv } from "../config/hostnames.js";
export async function promptServer(opts?: {
currentServer?: Partial<ServerConfig>;
currentAuth?: Partial<AuthConfig>;
}): Promise<{ server: ServerConfig; auth: AuthConfig }> {
const currentServer = opts?.currentServer;
const currentAuth = opts?.currentAuth;
const deploymentModeSelection = await p.select({
message: "Deployment mode",
options: [
{
value: "local_trusted",
label: "Local trusted",
hint: "Easiest for local setup (no login, localhost-only)",
},
{
value: "authenticated",
label: "Authenticated",
hint: "Login required; use for private network or public hosting",
},
],
initialValue: currentServer?.deploymentMode ?? "local_trusted",
});
if (p.isCancel(deploymentModeSelection)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"];
let exposure: ServerConfig["exposure"] = "private";
if (deploymentMode === "authenticated") {
const exposureSelection = await p.select({
message: "Exposure profile",
options: [
{
value: "private",
label: "Private network",
hint: "Private access (for example Tailscale), lower setup friction",
},
{
value: "public",
label: "Public internet",
hint: "Internet-facing deployment with stricter requirements",
},
],
initialValue: currentServer?.exposure ?? "private",
});
if (p.isCancel(exposureSelection)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
exposure = exposureSelection as ServerConfig["exposure"];
}
const hostDefault = deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0";
const hostStr = await p.text({
message: "Bind host",
defaultValue: currentServer?.host ?? hostDefault,
placeholder: hostDefault,
validate: (val) => {
if (!val.trim()) return "Host is required";
},
});
if (p.isCancel(hostStr)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const portStr = await p.text({
message: "Server port",
defaultValue: String(currentServer?.port ?? 3100),
placeholder: "3100",
validate: (val) => {
const n = Number(val);
if (isNaN(n) || n < 1 || n > 65535 || !Number.isInteger(n)) {
return "Must be an integer between 1 and 65535";
}
},
});
if (p.isCancel(portStr)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
let allowedHostnames: string[] = [];
if (deploymentMode === "authenticated" && exposure === "private") {
const allowedHostnamesInput = await p.text({
message: "Allowed hostnames (comma-separated, optional)",
defaultValue: (currentServer?.allowedHostnames ?? []).join(", "),
placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net",
validate: (val) => {
try {
parseHostnameCsv(val);
return;
} catch (err) {
return err instanceof Error ? err.message : "Invalid hostname list";
}
},
});
if (p.isCancel(allowedHostnamesInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
allowedHostnames = parseHostnameCsv(allowedHostnamesInput);
}
const port = Number(portStr) || 3100;
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
if (deploymentMode === "authenticated" && exposure === "public") {
const urlInput = await p.text({
message: "Public base URL",
defaultValue: currentAuth?.publicBaseUrl ?? "",
placeholder: "https://paperclip.example.com",
validate: (val) => {
const candidate = val.trim();
if (!candidate) return "Public base URL is required for public exposure";
try {
const url = new URL(candidate);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return "URL must start with http:// or https://";
}
return;
} catch {
return "Enter a valid URL";
}
},
});
if (p.isCancel(urlInput)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
};
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: currentAuth.publicBaseUrl,
};
}
return {
server: {
deploymentMode,
exposure,
host: hostStr.trim(),
port,
allowedHostnames,
serveUi: currentServer?.serveUi ?? true,
},
auth,
};
}
@@ -0,0 +1,146 @@
import * as p from "@clack/prompts";
import type { StorageConfig } from "../config/schema.js";
import { resolveDefaultStorageDir, resolvePaperclipInstanceId } from "../config/home.js";
function defaultStorageBaseDir(): string {
return resolveDefaultStorageDir(resolvePaperclipInstanceId());
}
export function defaultStorageConfig(): StorageConfig {
return {
provider: "local_disk",
localDisk: {
baseDir: defaultStorageBaseDir(),
},
s3: {
bucket: "paperclip",
region: "us-east-1",
endpoint: undefined,
prefix: "",
forcePathStyle: false,
},
};
}
export async function promptStorage(current?: StorageConfig): Promise<StorageConfig> {
const base = current ?? defaultStorageConfig();
const provider = await p.select({
message: "Storage provider",
options: [
{
value: "local_disk" as const,
label: "Local disk (recommended)",
hint: "best for single-user local deployments",
},
{
value: "s3" as const,
label: "S3 compatible",
hint: "for cloud/object storage backends",
},
],
initialValue: base.provider,
});
if (p.isCancel(provider)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
if (provider === "local_disk") {
const baseDir = await p.text({
message: "Local storage base directory",
defaultValue: base.localDisk.baseDir || defaultStorageBaseDir(),
placeholder: defaultStorageBaseDir(),
validate: (value) => {
if (!value || value.trim().length === 0) return "Storage base directory is required";
},
});
if (p.isCancel(baseDir)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
return {
provider: "local_disk",
localDisk: {
baseDir: baseDir.trim(),
},
s3: base.s3,
};
}
const bucket = await p.text({
message: "S3 bucket",
defaultValue: base.s3.bucket || "paperclip",
placeholder: "paperclip",
validate: (value) => {
if (!value || value.trim().length === 0) return "Bucket is required";
},
});
if (p.isCancel(bucket)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const region = await p.text({
message: "S3 region",
defaultValue: base.s3.region || "us-east-1",
placeholder: "us-east-1",
validate: (value) => {
if (!value || value.trim().length === 0) return "Region is required";
},
});
if (p.isCancel(region)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const endpoint = await p.text({
message: "S3 endpoint (optional for compatible backends)",
defaultValue: base.s3.endpoint ?? "",
placeholder: "https://s3.amazonaws.com",
});
if (p.isCancel(endpoint)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const prefix = await p.text({
message: "Object key prefix (optional)",
defaultValue: base.s3.prefix ?? "",
placeholder: "paperclip/",
});
if (p.isCancel(prefix)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
const forcePathStyle = await p.confirm({
message: "Use S3 path-style URLs?",
initialValue: base.s3.forcePathStyle ?? false,
});
if (p.isCancel(forcePathStyle)) {
p.cancel("Setup cancelled.");
process.exit(0);
}
return {
provider: "s3",
localDisk: base.localDisk,
s3: {
bucket: bucket.trim(),
region: region.trim(),
endpoint: endpoint.trim() || undefined,
prefix: prefix.trim(),
forcePathStyle,
},
};
}
@@ -0,0 +1,24 @@
import pc from "picocolors";
const PAPERCLIP_ART = [
"██████╗ █████╗ ██████╗ ███████╗██████╗ ██████╗██╗ ██╗██████╗ ",
"██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝██║ ██║██╔══██╗",
"██████╔╝███████║██████╔╝█████╗ ██████╔╝██║ ██║ ██║██████╔╝",
"██╔═══╝ ██╔══██║██╔═══╝ ██╔══╝ ██╔══██╗██║ ██║ ██║██╔═══╝ ",
"██║ ██║ ██║██║ ███████╗██║ ██║╚██████╗███████╗██║██║ ",
"╚═╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝╚═╝ ",
] as const;
const TAGLINE = "Open-source orchestration for zero-human companies";
export function printPaperclipCliBanner(): void {
const lines = [
"",
...PAPERCLIP_ART.map((line) => pc.cyan(line)),
pc.blue(" ───────────────────────────────────────────────────────"),
pc.bold(pc.white(` ${TAGLINE}`)),
"",
];
console.log(lines.join("\n"));
}
+18
View File
@@ -0,0 +1,18 @@
import net from "node:net";
export function checkPort(port: number): Promise<{ available: boolean; error?: string }> {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
resolve({ available: false, error: `Port ${port} is already in use` });
} else {
resolve({ available: false, error: err.message });
}
});
server.once("listening", () => {
server.close(() => resolve({ available: true }));
});
server.listen(port, "127.0.0.1");
});
}
@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
import { expandHomePrefix } from "../config/home.js";
function unique(items: string[]): string[] {
return Array.from(new Set(items));
}
export function resolveRuntimeLikePath(value: string, configPath?: string): string {
const expanded = expandHomePrefix(value);
if (path.isAbsolute(expanded)) return path.resolve(expanded);
const cwd = process.cwd();
const configDir = configPath ? path.dirname(configPath) : null;
const workspaceRoot = configDir ? path.resolve(configDir, "..") : cwd;
const candidates = unique([
...(configDir ? [path.resolve(configDir, expanded)] : []),
path.resolve(workspaceRoot, "server", expanded),
path.resolve(workspaceRoot, expanded),
path.resolve(cwd, expanded),
]);
return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0];
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});