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"; 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"; export async function envCommand(opts: { config?: string }): Promise { 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: "" } : 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(); const jwtEnv = readAgentJwtSecretFromEnv(); 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 heartbeatInterval = process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS ?? DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS; const heartbeatEnabled = process.env.HEARTBEAT_SCHEDULER_ENABLED ?? "true"; 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_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", }, ]; 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(); 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("'", "'\\''")}'`; }