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,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