env.ts428 lines · main
1import { access, readFile, writeFile } from 'node:fs/promises';
2import { createInterface } from 'node:readline';
3import { dirname, resolve } from 'node:path';
4
5import { apiCall, ApiCallError } from '../api-client.js';
6import { readCredentials } from '../config.js';
7import { readProjectConfig } from '../project-config.js';
8import { banner, blankLine, error as printError, step, success } from '../output.js';
9
10interface MaskedEnvVar {
11 id: string;
12 key: string;
13 lastFour: string;
14 createdAt: string;
15 updatedAt: string;
16}
17
18interface AuthCtx {
19 projectId: string;
20 apiOrigin: string;
21 apiKey: string;
22}
23
24export async function runEnv(argv: readonly string[]): Promise<number> {
25 const [sub, ...rest] = argv;
26 if (!sub || sub === '--help' || sub === '-h') {
27 printHelp();
28 return sub ? 0 : 1;
29 }
30 switch (sub) {
31 case 'list':
32 return runList(rest);
33 case 'get':
34 return runGet(rest);
35 case 'set':
36 return runSet(rest);
37 case 'delete':
38 case 'rm':
39 return runDelete(rest);
40 case 'pull':
41 return runPull(rest);
42 default:
43 printError(`unknown env subcommand: ${sub}`);
44 printHelp();
45 return 1;
46 }
47}
48
49function printHelp(): void {
50 banner('env');
51 blankLine();
52 step('briven env list list keys (masked)');
53 step('briven env get KEY show masked value');
54 step('briven env set KEY interactive value prompt');
55 step('briven env set KEY=value inline value (logs to shell history)');
56 step('briven env delete KEY [--yes] delete by key');
57 step('briven env pull [--out .env.local] write plaintext to file');
58}
59
60async function auth(): Promise<AuthCtx | number> {
61 const local = await readProjectConfig();
62 if (!local) {
63 printError('no briven.json in this directory.');
64 step('run: briven init');
65 return 1;
66 }
67 if (!local.projectId) {
68 printError('briven.json has no projectId — link this directory first.');
69 step('run: briven link');
70 return 1;
71 }
72 const creds = await readCredentials();
73 const cred = creds.projects[local.projectId];
74 if (!cred) {
75 printError(`no stored credentials for ${local.projectId}.`);
76 step('run: briven login --project <id> --key <brk_...>');
77 return 1;
78 }
79 return { projectId: local.projectId, apiOrigin: cred.apiOrigin, apiKey: cred.apiKey };
80}
81
82async function runList(_argv: readonly string[]): Promise<number> {
83 const ctx = await auth();
84 if (typeof ctx === 'number') return ctx;
85
86 banner('env list');
87 try {
88 const res = await apiCall<{ env: MaskedEnvVar[] }>(`/v1/projects/${ctx.projectId}/env`, {
89 apiOrigin: ctx.apiOrigin,
90 apiKey: ctx.apiKey,
91 });
92 blankLine();
93 if (res.env.length === 0) {
94 step('no env vars set');
95 return 0;
96 }
97 const maxKeyLen = res.env.reduce((m, v) => Math.max(m, v.key.length), 0);
98 for (const v of res.env) {
99 step(`${v.key.padEnd(maxKeyLen)} ····${v.lastFour} ${relativeTime(v.updatedAt)}`);
100 }
101 return 0;
102 } catch (err) {
103 return apiFail(err, 'list failed');
104 }
105}
106
107async function runGet(argv: readonly string[]): Promise<number> {
108 const ctx = await auth();
109 if (typeof ctx === 'number') return ctx;
110 const key = argv[0];
111 if (!key) {
112 printError('missing key — usage: briven env get KEY');
113 return 1;
114 }
115
116 try {
117 const res = await apiCall<{ env: MaskedEnvVar[] }>(`/v1/projects/${ctx.projectId}/env`, {
118 apiOrigin: ctx.apiOrigin,
119 apiKey: ctx.apiKey,
120 });
121 const match = res.env.find((v) => v.key === key);
122 if (!match) {
123 printError(`no env var named '${key}'`);
124 return 1;
125 }
126 banner(`env get ${key}`);
127 blankLine();
128 step(`value: ····${match.lastFour}`);
129 step(`updated ${relativeTime(match.updatedAt)}`);
130 blankLine();
131 step(`use 'briven env pull' to fetch plaintext to .env.local`);
132 return 0;
133 } catch (err) {
134 return apiFail(err, 'get failed');
135 }
136}
137
138async function runSet(argv: readonly string[]): Promise<number> {
139 const ctx = await auth();
140 if (typeof ctx === 'number') return ctx;
141 if (argv.length === 0) {
142 printError('missing key — usage: briven env set KEY[=value]');
143 return 1;
144 }
145
146 const raw = argv[0]!;
147 const eqIdx = raw.indexOf('=');
148 let key: string;
149 let value: string | null;
150
151 if (eqIdx === -1) {
152 key = raw;
153 value = null;
154 } else {
155 key = raw.slice(0, eqIdx);
156 value = raw.slice(eqIdx + 1);
157 }
158
159 if (!/^[A-Z_][A-Z0-9_]{0,63}$/.test(key)) {
160 printError(
161 'key must be uppercase letters, digits, underscores; start with letter or underscore',
162 );
163 return 1;
164 }
165
166 banner(`env set ${key}`);
167
168 if (value === null) {
169 try {
170 value = await readSecret(' · value: ');
171 } catch {
172 blankLine();
173 printError('cancelled');
174 return 1;
175 }
176 if (!value) {
177 printError('empty value — refusing to set');
178 return 1;
179 }
180 } else {
181 step('value passed inline; prefer `briven env set KEY` to avoid shell history');
182 }
183
184 try {
185 await apiCall<{ key: string }>(`/v1/projects/${ctx.projectId}/env`, {
186 method: 'PUT',
187 apiOrigin: ctx.apiOrigin,
188 apiKey: ctx.apiKey,
189 body: { key, value },
190 });
191 blankLine();
192 success(`set ${key}`);
193 return 0;
194 } catch (err) {
195 return apiFail(err, 'set failed');
196 }
197}
198
199async function runDelete(argv: readonly string[]): Promise<number> {
200 const ctx = await auth();
201 if (typeof ctx === 'number') return ctx;
202 const args = parseFlags(argv);
203 const key = args._[0];
204 if (!key) {
205 printError('missing key — usage: briven env delete KEY [--yes]');
206 return 1;
207 }
208
209 if (!args.yes) {
210 const ok = await readYesNo(` · delete ${key}? [y/N] `);
211 if (!ok) {
212 step('aborted');
213 return 0;
214 }
215 }
216
217 try {
218 await apiCall<{ deleted: string; key: string }>(
219 `/v1/projects/${ctx.projectId}/env/by-key/${encodeURIComponent(key)}`,
220 { method: 'DELETE', apiOrigin: ctx.apiOrigin, apiKey: ctx.apiKey },
221 );
222 success(`deleted ${key}`);
223 return 0;
224 } catch (err) {
225 return apiFail(err, 'delete failed');
226 }
227}
228
229async function runPull(argv: readonly string[]): Promise<number> {
230 const ctx = await auth();
231 if (typeof ctx === 'number') return ctx;
232 const args = parseFlags(argv);
233 const out = resolve(process.cwd(), args.out ?? '.env.local');
234
235 banner('env pull');
236 step(`target: ${out}`);
237
238 if (!args.force) {
239 const existing = await readFile(out, 'utf8').catch((err) => {
240 if ((err as { code?: string }).code === 'ENOENT') return null;
241 throw err;
242 });
243 if (existing !== null) {
244 printError(`${out} exists — re-run with --force to overwrite`);
245 return 1;
246 }
247 }
248
249 const ignoreCheck = await checkGitignore(out);
250 if (ignoreCheck.insideGitRepo && !ignoreCheck.ignored && !args.force) {
251 printError(`${out} is inside a git repo but not matched by .gitignore`);
252 step(`add this line to .gitignore:`);
253 step(` ${ignoreCheck.suggestedLine}`);
254 step('or re-run with --force');
255 return 1;
256 }
257
258 try {
259 const res = await apiCall<{ env: Record<string, string> }>(
260 `/v1/projects/${ctx.projectId}/env/plaintext`,
261 { apiOrigin: ctx.apiOrigin, apiKey: ctx.apiKey },
262 );
263 const body = Object.entries(res.env)
264 .map(([k, v]) => `${k}=${serialiseValue(v)}`)
265 .join('\n');
266 await writeFile(out, body.length > 0 ? `${body}\n` : '', { mode: 0o600 });
267 blankLine();
268 success(`wrote ${Object.keys(res.env).length} vars`);
269 return 0;
270 } catch (err) {
271 return apiFail(err, 'pull failed');
272 }
273}
274
275function serialiseValue(value: string): string {
276 // Only quote when the value has whitespace, quotes, backslashes, or shell
277 // metachars that make the `.env.local` unambiguous for dotenv parsers.
278 if (/^[A-Za-z0-9_./:@-]*$/.test(value)) return value;
279 const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
280 return `"${escaped}"`;
281}
282
283async function checkGitignore(
284 target: string,
285): Promise<{ insideGitRepo: boolean; ignored: boolean; suggestedLine: string }> {
286 // Walk up from the target's directory looking for .git; quit at filesystem root.
287 let dir = dirname(target);
288 let gitRoot: string | null = null;
289 for (let i = 0; i < 40; i += 1) {
290 const maybe = resolve(dir, '.git');
291 const hit = await access(maybe)
292 .then(() => true)
293 .catch(() => false);
294 if (hit) {
295 gitRoot = dir;
296 break;
297 }
298 const parent = dirname(dir);
299 if (parent === dir) break;
300 dir = parent;
301 }
302 const filename = target.split('/').pop() ?? '.env.local';
303 const suggestedLine = filename;
304 if (!gitRoot) {
305 return { insideGitRepo: false, ignored: false, suggestedLine };
306 }
307 const gitignorePath = resolve(gitRoot, '.gitignore');
308 const contents = await readFile(gitignorePath, 'utf8').catch(() => '');
309 const lines = contents
310 .split(/\r?\n/)
311 .map((l) => l.trim())
312 .filter((l) => l && !l.startsWith('#'));
313 // why: we do a best-effort match on three common patterns — exact name,
314 // glob-prefix (`.env*`), and glob-suffix (`*.local`). A proper gitignore
315 // matcher would handle nesting, negation, and anchoring; that's overkill
316 // for this single-file check.
317 const ignored = lines.some(
318 (pattern) =>
319 pattern === filename ||
320 pattern === `/${filename}` ||
321 pattern === '.env*' ||
322 pattern === '*.local' ||
323 pattern === '.env.*',
324 );
325 return { insideGitRepo: true, ignored, suggestedLine };
326}
327
328function parseFlags(argv: readonly string[]): {
329 _: string[];
330 yes: boolean;
331 force: boolean;
332 out?: string;
333} {
334 const out: { _: string[]; yes: boolean; force: boolean; out?: string } = {
335 _: [],
336 yes: false,
337 force: false,
338 };
339 for (let i = 0; i < argv.length; i += 1) {
340 const a = argv[i]!;
341 if (a === '--yes' || a === '-y') out.yes = true;
342 else if (a === '--force') out.force = true;
343 else if (a === '--out') out.out = argv[++i];
344 else if (a.startsWith('--out=')) out.out = a.slice('--out='.length);
345 else out._.push(a);
346 }
347 return out;
348}
349
350function apiFail(err: unknown, headline: string): number {
351 if (err instanceof ApiCallError) {
352 printError(`${headline}: ${err.code} (${err.status})`);
353 } else {
354 printError(err instanceof Error ? err.message : 'unknown error');
355 }
356 return 1;
357}
358
359function relativeTime(iso: string): string {
360 const then = new Date(iso).getTime();
361 if (!Number.isFinite(then)) return '';
362 const deltaSec = Math.max(0, Math.round((Date.now() - then) / 1000));
363 if (deltaSec < 60) return 'just now';
364 if (deltaSec < 3600) return `${Math.round(deltaSec / 60)}m ago`;
365 if (deltaSec < 86_400) return `${Math.round(deltaSec / 3600)}h ago`;
366 return `${Math.round(deltaSec / 86_400)}d ago`;
367}
368
369async function readSecret(prompt: string): Promise<string> {
370 if (!process.stdin.isTTY) {
371 return new Promise((resolvePromise, rejectPromise) => {
372 const rl = createInterface({ input: process.stdin });
373 rl.once('line', (l) => {
374 rl.close();
375 resolvePromise(l);
376 });
377 rl.once('close', () => {
378 rejectPromise(new Error('cancelled'));
379 });
380 });
381 }
382 process.stdout.write(prompt);
383 process.stdin.setRawMode(true);
384 process.stdin.resume();
385 return new Promise((resolvePromise, rejectPromise) => {
386 let buf = '';
387 function onData(chunk: Buffer): void {
388 const s = chunk.toString('utf8');
389 for (const ch of s) {
390 if (ch === '\r' || ch === '\n') {
391 cleanup();
392 process.stdout.write('\n');
393 resolvePromise(buf);
394 return;
395 }
396 if (ch === '\u0003') {
397 cleanup();
398 process.stdout.write('\n');
399 rejectPromise(new Error('cancelled'));
400 return;
401 }
402 if (ch === '\u007f' || ch === '\b') {
403 if (buf.length > 0) buf = buf.slice(0, -1);
404 continue;
405 }
406 buf += ch;
407 }
408 }
409 function cleanup(): void {
410 process.stdin.setRawMode(false);
411 process.stdin.removeListener('data', onData);
412 process.stdin.pause();
413 }
414 process.stdin.on('data', onData);
415 });
416}
417
418async function readYesNo(prompt: string): Promise<boolean> {
419 process.stdout.write(prompt);
420 return new Promise((resolvePromise) => {
421 const rl = createInterface({ input: process.stdin, output: process.stdout });
422 rl.once('line', (line) => {
423 rl.close();
424 const answer = line.trim().toLowerCase();
425 resolvePromise(answer === 'y' || answer === 'yes');
426 });
427 });
428}