| 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("'", "'\\''")}'`; |
| } |
|
|