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>
371 lines
12 KiB
TypeScript
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("'", "'\\''")}'`;
|
|
}
|