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>
This commit is contained in:
@@ -7,6 +7,7 @@ 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 {
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
@@ -14,13 +15,14 @@ import {
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
type Section = "llm" | "database" | "logging" | "server" | "secrets";
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -45,6 +47,7 @@ function defaultConfig(): PaperclipConfig {
|
||||
port: 3100,
|
||||
serveUi: true,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
};
|
||||
}
|
||||
@@ -123,6 +126,9 @@ export async function configure(opts: {
|
||||
case "server":
|
||||
config.server = await promptServer();
|
||||
break;
|
||||
case "storage":
|
||||
config.storage = await promptStorage(config.storage);
|
||||
break;
|
||||
case "secrets":
|
||||
config.secrets = await promptSecrets(config.secrets);
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
logCheck,
|
||||
portCheck,
|
||||
secretsCheck,
|
||||
storageCheck,
|
||||
type CheckResult,
|
||||
} from "../checks/index.js";
|
||||
|
||||
@@ -66,24 +67,30 @@ export async function doctor(opts: {
|
||||
printResult(secretsResult);
|
||||
await maybeRepair(secretsResult, opts);
|
||||
|
||||
// 4. Database check
|
||||
// 4. Storage check
|
||||
const storageResult = storageCheck(config, configPath);
|
||||
results.push(storageResult);
|
||||
printResult(storageResult);
|
||||
await maybeRepair(storageResult, opts);
|
||||
|
||||
// 5. Database check
|
||||
const dbResult = await databaseCheck(config, configPath);
|
||||
results.push(dbResult);
|
||||
printResult(dbResult);
|
||||
await maybeRepair(dbResult, opts);
|
||||
|
||||
// 5. LLM check
|
||||
// 6. LLM check
|
||||
const llmResult = await llmCheck(config);
|
||||
results.push(llmResult);
|
||||
printResult(llmResult);
|
||||
|
||||
// 6. Log directory check
|
||||
// 7. Log directory check
|
||||
const logResult = logCheck(config, configPath);
|
||||
results.push(logResult);
|
||||
printResult(logResult);
|
||||
await maybeRepair(logResult, opts);
|
||||
|
||||
// 7. Port check
|
||||
// 8. Port check
|
||||
const portResult = await portCheck(config);
|
||||
results.push(portResult);
|
||||
printResult(portResult);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../config/env.js";
|
||||
import {
|
||||
resolveDefaultSecretsKeyFilePath,
|
||||
resolveDefaultStorageDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
@@ -27,9 +28,13 @@ 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 ")));
|
||||
@@ -127,6 +132,33 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
||||
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[] = [
|
||||
{
|
||||
@@ -228,6 +260,83 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
||||
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();
|
||||
|
||||
@@ -8,6 +8,7 @@ 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, resolvePaperclipInstanceId } from "../config/home.js";
|
||||
|
||||
@@ -107,6 +108,10 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
||||
p.log.step(pc.bold("Server"));
|
||||
const server = await promptServer();
|
||||
|
||||
// Storage
|
||||
p.log.step(pc.bold("Storage"));
|
||||
const storage = await promptStorage(defaultStorageConfig());
|
||||
|
||||
// Secrets
|
||||
p.log.step(pc.bold("Secrets"));
|
||||
const secrets = defaultSecretsConfig();
|
||||
@@ -137,6 +142,7 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
||||
database,
|
||||
logging,
|
||||
server,
|
||||
storage,
|
||||
secrets,
|
||||
};
|
||||
|
||||
@@ -155,6 +161,7 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
||||
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
||||
`Logging: ${logging.mode} → ${logging.logDir}`,
|
||||
`Server: port ${server.port}`,
|
||||
`Storage: ${storage.provider}`,
|
||||
`Secrets: ${secrets.provider} (strict mode ${secrets.strictMode ? "on" : "off"})`,
|
||||
`Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`,
|
||||
].join("\n"),
|
||||
|
||||
Reference in New Issue
Block a user