storage.ts261 lines · main
| 1 | /** |
| 2 | * `briven storage` — project MinIO / S3 object storage (Convex-style DX). |
| 3 | * |
| 4 | * Creates (or reuses) the project's private bucket and can mint a bucket-scoped |
| 5 | * S3 key you can paste into .env or any S3 client. |
| 6 | * |
| 7 | * Auth: project CLI key from `briven setup` / `briven projects use` (brk_…). |
| 8 | */ |
| 9 | |
| 10 | import { appendFile, readFile, writeFile } from 'node:fs/promises'; |
| 11 | import { resolve } from 'node:path'; |
| 12 | |
| 13 | import { ApiCallError, apiCall } from '../api-client.js'; |
| 14 | import { readCredentials } from '../config.js'; |
| 15 | import { readProjectConfig } from '../project-config.js'; |
| 16 | import { banner, blankLine, error as printError, step, success } from '../output.js'; |
| 17 | |
| 18 | interface CreatedStorageKey { |
| 19 | record: { |
| 20 | id: string; |
| 21 | name: string; |
| 22 | accessKeyId: string; |
| 23 | suffix: string; |
| 24 | bucket: string; |
| 25 | }; |
| 26 | endpoint: string; |
| 27 | bucket: string; |
| 28 | accessKey: string; |
| 29 | secretKey: string; |
| 30 | } |
| 31 | |
| 32 | interface ListResponse { |
| 33 | keys: Array<{ |
| 34 | id: string; |
| 35 | name: string; |
| 36 | accessKeyId: string; |
| 37 | suffix: string; |
| 38 | bucket: string; |
| 39 | enabled: boolean; |
| 40 | revokedAt: string | null; |
| 41 | }>; |
| 42 | endpoint: string; |
| 43 | } |
| 44 | |
| 45 | function printUsage(): void { |
| 46 | banner('storage'); |
| 47 | blankLine(); |
| 48 | step('usage:'); |
| 49 | step(' briven storage setup [--name <label>] [--write-env] [--env-file .env.local]'); |
| 50 | step(' ensure MinIO bucket for this project + mint S3 key (secret once)'); |
| 51 | step(' briven storage status list keys + endpoint (no secrets)'); |
| 52 | blankLine(); |
| 53 | step('needs a linked project:'); |
| 54 | step(' briven connect p_… # existing'); |
| 55 | step(' # or: briven setup my-app # brand new'); |
| 56 | step(' # or: briven projects use p_… --link'); |
| 57 | blankLine(); |
| 58 | step('then:'); |
| 59 | step(' briven storage setup --write-env'); |
| 60 | } |
| 61 | |
| 62 | function flagValue(argv: readonly string[], name: string): string | undefined { |
| 63 | for (let i = 0; i < argv.length; i += 1) { |
| 64 | const arg = argv[i]; |
| 65 | if (arg === name && argv[i + 1]) return argv[i + 1]; |
| 66 | if (arg?.startsWith(`${name}=`)) return arg.slice(name.length + 1); |
| 67 | } |
| 68 | return undefined; |
| 69 | } |
| 70 | |
| 71 | function hasFlag(argv: readonly string[], name: string): boolean { |
| 72 | return argv.includes(name); |
| 73 | } |
| 74 | |
| 75 | async function resolveProjectCred(projectFlag?: string): Promise<{ |
| 76 | projectId: string; |
| 77 | apiKey: string; |
| 78 | apiOrigin: string; |
| 79 | }> { |
| 80 | const local = await readProjectConfig(); |
| 81 | const creds = await readCredentials(); |
| 82 | const projectId = |
| 83 | projectFlag ?? |
| 84 | local?.projectId ?? |
| 85 | creds.default ?? |
| 86 | Object.keys(creds.projects)[0]; |
| 87 | if (!projectId) { |
| 88 | throw new Error( |
| 89 | 'no project linked. run: briven connect p_… (or briven setup <name> for new, or briven projects use p_… --link)', |
| 90 | ); |
| 91 | } |
| 92 | const cred = creds.projects[projectId]; |
| 93 | if (!cred?.apiKey) { |
| 94 | throw new Error( |
| 95 | `no CLI key for ${projectId}. run: briven projects use ${projectId}`, |
| 96 | ); |
| 97 | } |
| 98 | return { |
| 99 | projectId, |
| 100 | apiKey: cred.apiKey, |
| 101 | apiOrigin: cred.apiOrigin || 'https://api.briven.tech', |
| 102 | }; |
| 103 | } |
| 104 | |
| 105 | export async function runStorage(argv: readonly string[]): Promise<number> { |
| 106 | const [sub, ...rest] = argv; |
| 107 | |
| 108 | if (!sub || sub === '--help' || sub === '-h' || sub === 'help') { |
| 109 | printUsage(); |
| 110 | return sub ? 0 : 1; |
| 111 | } |
| 112 | |
| 113 | if (sub === 'status') { |
| 114 | return storageStatus(rest); |
| 115 | } |
| 116 | if (sub === 'setup') { |
| 117 | return storageSetup(rest); |
| 118 | } |
| 119 | |
| 120 | printError(`unknown subcommand: ${sub}`); |
| 121 | step("run 'briven storage --help'"); |
| 122 | return 1; |
| 123 | } |
| 124 | |
| 125 | async function storageStatus(argv: readonly string[]): Promise<number> { |
| 126 | const projectFlag = flagValue(argv, '--project'); |
| 127 | try { |
| 128 | const { projectId, apiKey, apiOrigin } = await resolveProjectCred(projectFlag); |
| 129 | banner('storage status'); |
| 130 | step(`project ${projectId}`); |
| 131 | step(`api ${apiOrigin}`); |
| 132 | |
| 133 | const body = await apiCall<ListResponse>(`/v1/projects/${projectId}/storage-keys`, { |
| 134 | apiOrigin, |
| 135 | apiKey, |
| 136 | }); |
| 137 | blankLine(); |
| 138 | step(`endpoint ${body.endpoint || '(not set)'}`); |
| 139 | step(`bucket proj-${projectId.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 58)}`); |
| 140 | blankLine(); |
| 141 | if (body.keys.length === 0) { |
| 142 | step('no storage keys yet — run: briven storage setup'); |
| 143 | return 0; |
| 144 | } |
| 145 | for (const k of body.keys) { |
| 146 | const state = k.revokedAt ? 'revoked' : k.enabled ? 'active' : 'disabled'; |
| 147 | step(` ${k.name} ${k.accessKeyId.slice(0, 8)}… bucket=${k.bucket} ${state}`); |
| 148 | } |
| 149 | return 0; |
| 150 | } catch (err) { |
| 151 | if (err instanceof ApiCallError) { |
| 152 | printError(`${err.code}: ${err.message}`); |
| 153 | return 1; |
| 154 | } |
| 155 | printError(err instanceof Error ? err.message : String(err)); |
| 156 | return 1; |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | async function storageSetup(argv: readonly string[]): Promise<number> { |
| 161 | const projectFlag = flagValue(argv, '--project'); |
| 162 | let name = flagValue(argv, '--name') ?? 'default'; |
| 163 | const writeEnv = hasFlag(argv, '--write-env'); |
| 164 | const envFile = flagValue(argv, '--env-file') ?? '.env.local'; |
| 165 | |
| 166 | try { |
| 167 | const { projectId, apiKey, apiOrigin } = await resolveProjectCred(projectFlag); |
| 168 | banner('storage setup'); |
| 169 | step(`project ${projectId}`); |
| 170 | step(`api ${apiOrigin}`); |
| 171 | step('ensuring MinIO bucket + minting bucket-scoped S3 key…'); |
| 172 | |
| 173 | // Always mint a key so we have a full secret (required for .env.local). |
| 174 | // If the requested name is taken, pick a unique label. |
| 175 | const listed = await apiCall<ListResponse>(`/v1/projects/${projectId}/storage-keys`, { |
| 176 | apiOrigin, |
| 177 | apiKey, |
| 178 | }).catch(() => ({ keys: [] as ListResponse['keys'], endpoint: '' })); |
| 179 | const used = new Set( |
| 180 | listed.keys.filter((k) => k.enabled && !k.revokedAt).map((k) => k.name), |
| 181 | ); |
| 182 | if (used.has(name)) { |
| 183 | name = `${name}-${Date.now().toString(36).slice(-5)}`; |
| 184 | step(`key name already used — minting as "${name}"`); |
| 185 | } |
| 186 | |
| 187 | const created = await apiCall<CreatedStorageKey>( |
| 188 | `/v1/projects/${projectId}/storage-keys`, |
| 189 | { |
| 190 | apiOrigin, |
| 191 | apiKey, |
| 192 | method: 'POST', |
| 193 | body: { name }, |
| 194 | }, |
| 195 | ); |
| 196 | |
| 197 | blankLine(); |
| 198 | success('S3 bucket + key ready'); |
| 199 | step(`bucket ${created.bucket}`); |
| 200 | step(`endpoint ${created.endpoint}`); |
| 201 | step(`accessKey ${created.accessKey}`); |
| 202 | step(`secretKey ${created.secretKey}`); |
| 203 | step('(secret shown once — also written to env when --write-env)'); |
| 204 | blankLine(); |
| 205 | step('S3-compatible env (for apps / AWS SDK / rclone):'); |
| 206 | step(` AWS_ENDPOINT_URL=${created.endpoint}`); |
| 207 | step(` AWS_ACCESS_KEY_ID=${created.accessKey}`); |
| 208 | step(` AWS_SECRET_ACCESS_KEY=${created.secretKey}`); |
| 209 | step(` AWS_REGION=auto`); |
| 210 | step(` S3_BUCKET=${created.bucket}`); |
| 211 | |
| 212 | // Default: write env when called from setup, or when --write-env is set. |
| 213 | // `briven setup` always passes --write-env so the folder is fully wired. |
| 214 | if (writeEnv) { |
| 215 | const path = resolve(process.cwd(), envFile); |
| 216 | const block = [ |
| 217 | '', |
| 218 | '# Briven project storage (MinIO S3) — from briven storage setup', |
| 219 | `BRIVEN_STORAGE_ENDPOINT=${created.endpoint}`, |
| 220 | `BRIVEN_STORAGE_BUCKET=${created.bucket}`, |
| 221 | `BRIVEN_STORAGE_ACCESS_KEY=${created.accessKey}`, |
| 222 | `BRIVEN_STORAGE_SECRET_KEY=${created.secretKey}`, |
| 223 | `AWS_ENDPOINT_URL=${created.endpoint}`, |
| 224 | `AWS_ACCESS_KEY_ID=${created.accessKey}`, |
| 225 | `AWS_SECRET_ACCESS_KEY=${created.secretKey}`, |
| 226 | `S3_BUCKET=${created.bucket}`, |
| 227 | '', |
| 228 | ].join('\n'); |
| 229 | try { |
| 230 | const existing = await readFile(path, 'utf8').catch(() => ''); |
| 231 | if (existing.includes('BRIVEN_STORAGE_BUCKET=')) { |
| 232 | await appendFile( |
| 233 | path, |
| 234 | `\n# --- briven storage setup ${new Date().toISOString()} (appended; review duplicates) ---\n${block}`, |
| 235 | ); |
| 236 | } else { |
| 237 | await writeFile(path, existing + block, { mode: 0o600 }); |
| 238 | } |
| 239 | success(`wrote credentials to ${envFile}`); |
| 240 | } catch (e) { |
| 241 | printError(`could not write ${envFile}: ${e instanceof Error ? e.message : e}`); |
| 242 | return 1; |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | return 0; |
| 247 | } catch (err) { |
| 248 | if (err instanceof ApiCallError) { |
| 249 | printError(`${err.code}: ${err.message}`); |
| 250 | if (err.code === 'storage_not_configured' || err.status === 503) { |
| 251 | step('platform MinIO is not configured on the API (operator: BRIVEN_MINIO_*).'); |
| 252 | } |
| 253 | if (err.status === 401 || err.status === 403) { |
| 254 | step('re-link the project: briven projects use <p_…> --link'); |
| 255 | } |
| 256 | return 1; |
| 257 | } |
| 258 | printError(err instanceof Error ? err.message : String(err)); |
| 259 | return 1; |
| 260 | } |
| 261 | } |