Files
paperclip/cli/src/commands/env.ts
Forgotten fdd2ea6157 feat: add storage system with local disk and S3 providers
Introduces a provider-agnostic storage subsystem for file attachments.
Includes local disk and S3 backends, asset/attachment DB schemas, issue
attachment CRUD routes with multer upload, CLI configure/doctor/env
integration, and enriched issue ancestors with project/goal resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:31:56 -06:00

371 lines
12 KiB
TypeScript

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