export.ts192 lines · main
1import { spawn } from 'node:child_process';
2import { writeFile } from 'node:fs/promises';
3import { 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 ExportPayload {
11 manifest: {
12 version: number;
13 sourceProjectId: string;
14 sourceProjectName: string;
15 sourceDeploymentId: string;
16 exportedAt: string;
17 };
18 schema: Record<string, unknown> | null;
19 functions: Record<string, string>;
20}
21
22interface ShellTokenResponse {
23 dsn: string;
24 role: string;
25 expiresAt: string;
26}
27
28type ExportTarget = 'briven' | 'convex' | 'supabase' | 'postgres-sql';
29
30interface Args {
31 out: string | null;
32 withData: boolean;
33 target: ExportTarget;
34}
35
36function parse(argv: readonly string[]): Args {
37 const out: Args = { out: null, withData: false, target: 'briven' };
38 for (let i = 0; i < argv.length; i++) {
39 if (argv[i] === '--out' && argv[i + 1]) {
40 out.out = argv[++i] ?? null;
41 } else if (argv[i] === '--with-data') {
42 out.withData = true;
43 } else if (argv[i] === '--target' && argv[i + 1]) {
44 out.target = argv[++i] as ExportTarget;
45 } else if (argv[i]?.startsWith('--target=')) {
46 out.target = argv[i]!.slice('--target='.length) as ExportTarget;
47 } else if (argv[i] === '--help' || argv[i] === '-h') {
48 out.out = '__HELP__';
49 }
50 }
51 return out;
52}
53
54function printUsage(): void {
55 banner('export');
56 blankLine();
57 step('usage: briven export [--out <path>] [--with-data] [--target <name>]');
58 step(' --out <path> destination file (default: <projectId>-<timestamp>.briven-export.json)');
59 step(' --with-data also stream pg_dump of the project database to <out>.data.dump');
60 step(' --target <name> output shape — briven (default) | convex | supabase | postgres-sql');
61 step('');
62 step('targets:');
63 step(' briven briven-native bundle (json). this is the format briven import reads.');
64 step(' convex emit convex/schema.ts + convex/<name>.ts files in a directory.');
65 step(' supabase emit supabase/migrations/<ts>_init.sql + supabase/functions/<name>/.');
66 step(' postgres-sql emit a single .sql file with CREATE TABLE statements only.');
67 step('');
68 step('non-briven targets are reverse-direction parity — you can leave briven any day.');
69 step('migrating TO briven? see https://briven.tech/migrate.');
70}
71
72export async function runExport(argv: readonly string[]): Promise<number> {
73 const args = parse(argv);
74 if (args.out === '__HELP__') {
75 printUsage();
76 return 0;
77 }
78
79 const local = await readProjectConfig();
80 const file = await readCredentials();
81 const targetId = local?.projectId ?? file.default;
82 if (!targetId) {
83 printError('no linked project found.');
84 step('run: briven login --project <p_...> --key <brk_...>');
85 return 1;
86 }
87 const cred = file.projects[targetId];
88 if (!cred) {
89 printError(`no credentials stored for ${targetId}`);
90 return 1;
91 }
92
93 banner('export');
94 step(`project ${targetId}`);
95 step(`origin ${cred.apiOrigin}`);
96
97 let payload: ExportPayload;
98 try {
99 payload = await apiCall<ExportPayload>(`/v1/projects/${targetId}/export`, {
100 apiOrigin: cred.apiOrigin,
101 apiKey: cred.apiKey,
102 });
103 } catch (err) {
104 blankLine();
105 if (err instanceof ApiCallError) {
106 printError(`server rejected: ${err.code} (${err.status})`);
107 } else {
108 printError(err instanceof Error ? err.message : 'unknown error');
109 }
110 return 1;
111 }
112
113 // Non-default targets are reverse-direction parity. We don't implement
114 // them fully today — the briven export bundle is the source of truth,
115 // and the target adapters land per-source as customers ask for them.
116 // The flag exists now so the surface is documented, the CLI knows
117 // about it, and the migration story includes "you can leave any day".
118 if (args.target !== 'briven') {
119 blankLine();
120 step(`target ${args.target}`);
121 step(`schema ${payload.schema ? 'present' : 'absent'}`);
122 step(`functions ${Object.keys(payload.functions).length}`);
123 blankLine();
124 printError(
125 `--target=${args.target} is on the roadmap but not yet implemented.`,
126 );
127 step('your data is safe — briven export with no --target writes the briven-native bundle.');
128 step('to leave briven today: run `briven export --with-data` and operate on the .json + .data.dump yourself.');
129 step('priority order for adapters is driven by demand — file an issue at code.konnos.org/flndrn/briven if you need one urgently.');
130 return 2;
131 }
132
133 const outPath = args.out
134 ? resolve(args.out)
135 : resolve(
136 `${targetId}-${payload.manifest.exportedAt.replace(/[:.]/g, '-')}.briven-export.json`,
137 );
138 await writeFile(outPath, JSON.stringify(payload, null, 2), { mode: 0o600 });
139
140 blankLine();
141 step(`target ${args.target}`);
142 step(`schema ${payload.schema ? 'present' : 'absent'}`);
143 step(`functions ${Object.keys(payload.functions).length}`);
144 step(`wrote ${outPath}`);
145
146 if (args.withData) {
147 const dumpPath = `${outPath.replace(/\.json$/, '')}.data.dump`;
148 blankLine();
149 step('requesting short-lived dsn for pg_dump');
150 let token: ShellTokenResponse;
151 try {
152 token = await apiCall<ShellTokenResponse>(
153 `/v1/projects/${targetId}/db/shell-token`,
154 { method: 'POST', apiOrigin: cred.apiOrigin, apiKey: cred.apiKey },
155 );
156 } catch (err) {
157 printError(
158 `couldn't issue dsn: ${err instanceof ApiCallError ? `${err.code} (${err.status})` : err instanceof Error ? err.message : 'unknown'}`,
159 );
160 return 1;
161 }
162 step(`dsn expires ${token.expiresAt}`);
163 step(`pg_dump → ${dumpPath}`);
164 const code = await runPgDump(token.dsn, dumpPath);
165 if (code !== 0) {
166 printError(`pg_dump exited ${code}`);
167 step('check that pg_dump is on PATH and the dsn is reachable from this host');
168 return 1;
169 }
170 step('done');
171 }
172
173 blankLine();
174 success(args.withData ? 'export + data dump complete' : `wrote ${outPath}`);
175 return 0;
176}
177
178/**
179 * Spawn pg_dump in custom format, write to disk. Inherits stderr so the
180 * operator sees pg_dump's own progress. Returns exit code.
181 */
182function runPgDump(dsn: string, outPath: string): Promise<number> {
183 return new Promise((resolveExit) => {
184 const child = spawn(
185 'pg_dump',
186 ['--format=custom', '--compress=6', `--file=${outPath}`, dsn],
187 { stdio: ['ignore', 'inherit', 'inherit'] },
188 );
189 child.on('exit', (code) => resolveExit(code ?? 1));
190 child.on('error', () => resolveExit(127));
191 });
192}