import.ts220 lines · main
| 1 | import { spawn } from 'node:child_process'; |
| 2 | import { readFile, stat } from 'node:fs/promises'; |
| 3 | import { resolve } from 'node:path'; |
| 4 | |
| 5 | import { apiCall, ApiCallError } from '../api-client.js'; |
| 6 | import { readCredentials } from '../config.js'; |
| 7 | import { readProjectConfig } from '../project-config.js'; |
| 8 | import { banner, blankLine, error as printError, step, success } from '../output.js'; |
| 9 | |
| 10 | interface 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 | |
| 22 | interface DeploymentResponse { |
| 23 | deployment: { |
| 24 | id: string; |
| 25 | status: string; |
| 26 | createdAt: string; |
| 27 | }; |
| 28 | } |
| 29 | |
| 30 | interface ShellTokenResponse { |
| 31 | dsn: string; |
| 32 | role: string; |
| 33 | expiresAt: string; |
| 34 | } |
| 35 | |
| 36 | interface Args { |
| 37 | path: string | null; |
| 38 | confirmDestructive: boolean; |
| 39 | restoreData: boolean; |
| 40 | } |
| 41 | |
| 42 | function parse(argv: readonly string[]): Args { |
| 43 | const out: Args = { path: null, confirmDestructive: false, restoreData: false }; |
| 44 | for (let i = 0; i < argv.length; i++) { |
| 45 | const a = argv[i]; |
| 46 | if (a === '--confirm-destructive') { |
| 47 | out.confirmDestructive = true; |
| 48 | } else if (a === '--restore-data') { |
| 49 | out.restoreData = true; |
| 50 | } else if (a === '--help' || a === '-h') { |
| 51 | out.path = '__HELP__'; |
| 52 | } else if (a && !a.startsWith('--') && !out.path) { |
| 53 | out.path = a; |
| 54 | } |
| 55 | } |
| 56 | return out; |
| 57 | } |
| 58 | |
| 59 | function printUsage(): void { |
| 60 | banner('import'); |
| 61 | blankLine(); |
| 62 | step('usage: briven import <path> [--confirm-destructive] [--restore-data]'); |
| 63 | step(''); |
| 64 | step('reads a briven-export.json and creates a deployment on the linked'); |
| 65 | step('target project. the linked project is the one in briven.json (or'); |
| 66 | step('the default credential). cross-project imports rewrite the schema,'); |
| 67 | step('which can drop tables — pass --confirm-destructive to allow.'); |
| 68 | step(''); |
| 69 | step('with --restore-data, also pg_restores the sibling <path>.data.dump'); |
| 70 | step('against the target project schema (requires pg_restore on PATH).'); |
| 71 | } |
| 72 | |
| 73 | export async function runImport(argv: readonly string[]): Promise<number> { |
| 74 | const args = parse(argv); |
| 75 | if (args.path === '__HELP__') { |
| 76 | printUsage(); |
| 77 | return 0; |
| 78 | } |
| 79 | if (!args.path) { |
| 80 | printUsage(); |
| 81 | return 1; |
| 82 | } |
| 83 | |
| 84 | const local = await readProjectConfig(); |
| 85 | const file = await readCredentials(); |
| 86 | const targetId = local?.projectId ?? file.default; |
| 87 | if (!targetId) { |
| 88 | printError('no linked project found — link a destination first.'); |
| 89 | step('run: briven login --project <p_...> --key <brk_...>'); |
| 90 | return 1; |
| 91 | } |
| 92 | const cred = file.projects[targetId]; |
| 93 | if (!cred) { |
| 94 | printError(`no credentials stored for ${targetId}`); |
| 95 | return 1; |
| 96 | } |
| 97 | |
| 98 | let payload: ExportPayload; |
| 99 | try { |
| 100 | const raw = await readFile(resolve(args.path), 'utf8'); |
| 101 | payload = JSON.parse(raw) as ExportPayload; |
| 102 | } catch (err) { |
| 103 | printError( |
| 104 | `could not read export: ${err instanceof Error ? err.message : 'unknown error'}`, |
| 105 | ); |
| 106 | return 1; |
| 107 | } |
| 108 | |
| 109 | if (payload.manifest?.version !== 1) { |
| 110 | printError(`unsupported export version: ${String(payload.manifest?.version)} (expected 1)`); |
| 111 | return 1; |
| 112 | } |
| 113 | |
| 114 | banner('import'); |
| 115 | step(`source ${payload.manifest.sourceProjectId} (${payload.manifest.sourceProjectName})`); |
| 116 | step(`exported ${payload.manifest.exportedAt}`); |
| 117 | step(`target ${targetId}`); |
| 118 | step(`origin ${cred.apiOrigin}`); |
| 119 | step(`schema ${payload.schema ? 'present' : 'absent'}`); |
| 120 | step(`functions ${Object.keys(payload.functions).length}`); |
| 121 | |
| 122 | const functionNames = Object.keys(payload.functions); |
| 123 | |
| 124 | // The deployments POST already enforces destructive guards on the |
| 125 | // server — we mirror it as a CLI flag for parity with `briven deploy`. |
| 126 | // Pass-through: when --confirm-destructive isn't set, the server |
| 127 | // refuses if the inbound schema would drop a table on the target. |
| 128 | let res: DeploymentResponse; |
| 129 | try { |
| 130 | res = await apiCall<DeploymentResponse>(`/v1/projects/${targetId}/deployments`, { |
| 131 | method: 'POST', |
| 132 | apiOrigin: cred.apiOrigin, |
| 133 | apiKey: cred.apiKey, |
| 134 | body: { |
| 135 | schemaSnapshot: payload.schema ?? undefined, |
| 136 | functionCount: functionNames.length, |
| 137 | functionNames, |
| 138 | bundle: payload.functions, |
| 139 | schemaDiffSummary: { import: 1, source: payload.manifest.sourceProjectId }, |
| 140 | }, |
| 141 | }); |
| 142 | } catch (err) { |
| 143 | blankLine(); |
| 144 | if (err instanceof ApiCallError) { |
| 145 | printError(`server rejected: ${err.code} (${err.status})`); |
| 146 | if (err.code === 'destructive_changes_refused' && !args.confirmDestructive) { |
| 147 | step('re-run with --confirm-destructive to allow drops on the target'); |
| 148 | } |
| 149 | } else { |
| 150 | printError(err instanceof Error ? err.message : 'unknown error'); |
| 151 | } |
| 152 | return 1; |
| 153 | } |
| 154 | blankLine(); |
| 155 | success(`deployment ${res.deployment.id} · ${res.deployment.status}`); |
| 156 | |
| 157 | if (!args.restoreData) return 0; |
| 158 | |
| 159 | // Locate the sibling .data.dump. The export command writes it next to |
| 160 | // the .json with a stripped extension: foo.briven-export.json → |
| 161 | // foo.briven-export.data.dump. |
| 162 | const dumpPath = resolve(args.path).replace(/\.json$/, '') + '.data.dump'; |
| 163 | try { |
| 164 | await stat(dumpPath); |
| 165 | } catch { |
| 166 | printError(`--restore-data set but no sibling dump found at ${dumpPath}`); |
| 167 | step('re-export with `briven export --with-data` to produce one'); |
| 168 | return 1; |
| 169 | } |
| 170 | blankLine(); |
| 171 | step('requesting short-lived dsn for pg_restore'); |
| 172 | let token: ShellTokenResponse; |
| 173 | try { |
| 174 | token = await apiCall<ShellTokenResponse>( |
| 175 | `/v1/projects/${targetId}/db/shell-token`, |
| 176 | { method: 'POST', apiOrigin: cred.apiOrigin, apiKey: cred.apiKey }, |
| 177 | ); |
| 178 | } catch (err) { |
| 179 | printError( |
| 180 | `couldn't issue dsn: ${err instanceof ApiCallError ? `${err.code} (${err.status})` : err instanceof Error ? err.message : 'unknown'}`, |
| 181 | ); |
| 182 | return 1; |
| 183 | } |
| 184 | step(`dsn expires ${token.expiresAt}`); |
| 185 | step(`pg_restore ← ${dumpPath}`); |
| 186 | const code = await runPgRestore(token.dsn, dumpPath); |
| 187 | if (code !== 0) { |
| 188 | printError(`pg_restore exited ${code}`); |
| 189 | step('check pg_restore is on PATH and inspect the output above for details'); |
| 190 | return 1; |
| 191 | } |
| 192 | blankLine(); |
| 193 | success('data restored'); |
| 194 | return 0; |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * pg_restore from a custom-format dump into a target dsn. --no-owner |
| 199 | * and --no-privileges so the restore doesn't try to recreate the source |
| 200 | * roles. --clean drops + recreates objects that exist; the deploy step |
| 201 | * above already created the schema, so this is largely additive. |
| 202 | */ |
| 203 | function runPgRestore(dsn: string, dumpPath: string): Promise<number> { |
| 204 | return new Promise((resolveExit) => { |
| 205 | const child = spawn( |
| 206 | 'pg_restore', |
| 207 | [ |
| 208 | '--no-owner', |
| 209 | '--no-privileges', |
| 210 | '--clean', |
| 211 | '--if-exists', |
| 212 | `--dbname=${dsn}`, |
| 213 | dumpPath, |
| 214 | ], |
| 215 | { stdio: ['ignore', 'inherit', 'inherit'] }, |
| 216 | ); |
| 217 | child.on('exit', (code) => resolveExit(code ?? 1)); |
| 218 | child.on('error', () => resolveExit(127)); |
| 219 | }); |
| 220 | } |