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
10import { appendFile, readFile, writeFile } from 'node:fs/promises';
11import { resolve } from 'node:path';
12
13import { ApiCallError, apiCall } from '../api-client.js';
14import { readCredentials } from '../config.js';
15import { readProjectConfig } from '../project-config.js';
16import { banner, blankLine, error as printError, step, success } from '../output.js';
17
18interface 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
32interface 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
45function 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
62function 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
71function hasFlag(argv: readonly string[], name: string): boolean {
72 return argv.includes(name);
73}
74
75async 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
105export 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
125async 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
160async 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}