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>
147 lines
3.5 KiB
TypeScript
147 lines
3.5 KiB
TypeScript
import * as p from "@clack/prompts";
|
|
import type { StorageConfig } from "../config/schema.js";
|
|
import { resolveDefaultStorageDir, resolvePaperclipInstanceId } from "../config/home.js";
|
|
|
|
function defaultStorageBaseDir(): string {
|
|
return resolveDefaultStorageDir(resolvePaperclipInstanceId());
|
|
}
|
|
|
|
export function defaultStorageConfig(): StorageConfig {
|
|
return {
|
|
provider: "local_disk",
|
|
localDisk: {
|
|
baseDir: defaultStorageBaseDir(),
|
|
},
|
|
s3: {
|
|
bucket: "paperclip",
|
|
region: "us-east-1",
|
|
endpoint: undefined,
|
|
prefix: "",
|
|
forcePathStyle: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function promptStorage(current?: StorageConfig): Promise<StorageConfig> {
|
|
const base = current ?? defaultStorageConfig();
|
|
|
|
const provider = await p.select({
|
|
message: "Storage provider",
|
|
options: [
|
|
{
|
|
value: "local_disk" as const,
|
|
label: "Local disk (recommended)",
|
|
hint: "best for single-user local deployments",
|
|
},
|
|
{
|
|
value: "s3" as const,
|
|
label: "S3 compatible",
|
|
hint: "for cloud/object storage backends",
|
|
},
|
|
],
|
|
initialValue: base.provider,
|
|
});
|
|
|
|
if (p.isCancel(provider)) {
|
|
p.cancel("Setup cancelled.");
|
|
process.exit(0);
|
|
}
|
|
|
|
if (provider === "local_disk") {
|
|
const baseDir = await p.text({
|
|
message: "Local storage base directory",
|
|
defaultValue: base.localDisk.baseDir || defaultStorageBaseDir(),
|
|
placeholder: defaultStorageBaseDir(),
|
|
validate: (value) => {
|
|
if (!value || value.trim().length === 0) return "Storage base directory is required";
|
|
},
|
|
});
|
|
|
|
if (p.isCancel(baseDir)) {
|
|
p.cancel("Setup cancelled.");
|
|
process.exit(0);
|
|
}
|
|
|
|
return {
|
|
provider: "local_disk",
|
|
localDisk: {
|
|
baseDir: baseDir.trim(),
|
|
},
|
|
s3: base.s3,
|
|
};
|
|
}
|
|
|
|
const bucket = await p.text({
|
|
message: "S3 bucket",
|
|
defaultValue: base.s3.bucket || "paperclip",
|
|
placeholder: "paperclip",
|
|
validate: (value) => {
|
|
if (!value || value.trim().length === 0) return "Bucket is required";
|
|
},
|
|
});
|
|
|
|
if (p.isCancel(bucket)) {
|
|
p.cancel("Setup cancelled.");
|
|
process.exit(0);
|
|
}
|
|
|
|
const region = await p.text({
|
|
message: "S3 region",
|
|
defaultValue: base.s3.region || "us-east-1",
|
|
placeholder: "us-east-1",
|
|
validate: (value) => {
|
|
if (!value || value.trim().length === 0) return "Region is required";
|
|
},
|
|
});
|
|
|
|
if (p.isCancel(region)) {
|
|
p.cancel("Setup cancelled.");
|
|
process.exit(0);
|
|
}
|
|
|
|
const endpoint = await p.text({
|
|
message: "S3 endpoint (optional for compatible backends)",
|
|
defaultValue: base.s3.endpoint ?? "",
|
|
placeholder: "https://s3.amazonaws.com",
|
|
});
|
|
|
|
if (p.isCancel(endpoint)) {
|
|
p.cancel("Setup cancelled.");
|
|
process.exit(0);
|
|
}
|
|
|
|
const prefix = await p.text({
|
|
message: "Object key prefix (optional)",
|
|
defaultValue: base.s3.prefix ?? "",
|
|
placeholder: "paperclip/",
|
|
});
|
|
|
|
if (p.isCancel(prefix)) {
|
|
p.cancel("Setup cancelled.");
|
|
process.exit(0);
|
|
}
|
|
|
|
const forcePathStyle = await p.confirm({
|
|
message: "Use S3 path-style URLs?",
|
|
initialValue: base.s3.forcePathStyle ?? false,
|
|
});
|
|
|
|
if (p.isCancel(forcePathStyle)) {
|
|
p.cancel("Setup cancelled.");
|
|
process.exit(0);
|
|
}
|
|
|
|
return {
|
|
provider: "s3",
|
|
localDisk: base.localDisk,
|
|
s3: {
|
|
bucket: bucket.trim(),
|
|
region: region.trim(),
|
|
endpoint: endpoint.trim() || undefined,
|
|
prefix: prefix.trim(),
|
|
forcePathStyle,
|
|
},
|
|
};
|
|
}
|
|
|