@oalo-ai/cli 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/man/oalo.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH OALO 1 "" "oalo 0.1.0" "OALO CLI"
1
+ .TH OALO 1 "" "oalo 0.1.1" "OALO CLI"
2
2
  .SH NAME
3
3
  oalo \- wire a local app into your Oalo workspace
4
4
  .SH SYNOPSIS
@@ -368,25 +368,51 @@ Each one says what was seen: which process, how many restarts, a memory kill wit
368
368
  .PP
369
369
  Exit codes: 0 no open incident; 1 an incident is open, or the request failed; 2 bad arguments or no CLI token.
370
370
  .SS db
371
- All of an env's data as plain SQL (every app's own tables and the Oalo models), to keep a copy or take it anywhere.
371
+ All of an env's data as plain SQL (every app's own tables and the Oalo models), to keep a copy or take it anywhere. Or load an existing Postgres database into an env.
372
372
  .PP
373
373
  \fBoalo db dump [\-\-env dev|prod] [\-\-out <file.sql>] [\-\-force]\fR
374
374
  .br
375
+ \fBoalo db restore <file> \-\-env dev|prod [\-\-schema <name>] [\-\-check] [\-\-yes]\fR
376
+ .br
377
+ \fBoalo db restore \-\-from\-env <VAR> \-\-env dev|prod [\-\-schema <name>] [\-\-check] [\-\-yes]\fR
378
+ .br
375
379
  .TP
376
380
  \fB\-\-env dev|prod\fR
377
- Which env's data. Default prod.
381
+ dump: which env's data. Default prod. restore: which env the data goes into, always on the command line.
378
382
  .TP
379
383
  \fB\-\-out <file.sql>\fR
380
384
  Where to write it. Default <workspace>\-<env>\-<date>.sql here.
381
385
  .TP
382
386
  \fB\-\-force\fR
383
387
  Replace the file if it exists.
388
+ .TP
389
+ \fB\-\-from\-env <VAR>\fR
390
+ restore: copy from a running database instead of a file. VAR is the environment variable that holds its postgres:// or postgresql:// URL. Only its host is ever shown.
391
+ .TP
392
+ \fB\-\-schema <name>\fR
393
+ restore: the source schema to load. Default public, or the only one when there's just one.
394
+ .TP
395
+ \fB\-\-check\fR
396
+ restore: do everything but the write, and report what would happen. Nothing is written, so it never asks.
397
+ .TP
398
+ \fB\-\-yes\fR
399
+ restore: go ahead with prod without asking. Needed for prod when there's no terminal to ask at.
384
400
  .IP \(bu 2
385
401
  Workspace admins only: it holds every member's data. Restore it into any Postgres with psql \-f.
386
402
  .IP \(bu 2
387
403
  Your code is in git (oalo deploy \-\-pull); this is the data. Together they are everything you need to run your apps elsewhere.
404
+ .IP \(bu 2
405
+ restore loads a dump file made by pg_dump (like Heroku's latest.dump), up to 10 GB, or copies from a database URL. Admins only, and one restore at a time per Base.
406
+ .IP \(bu 2
407
+ The tables land in the env's schema, owned by the app's database login, so its Procfile \fBrelease:\fR migrations can alter them. Your app reads them at DATABASE_URL.
408
+ .IP \(bu 2
409
+ It's refused if a table name already exists there, and the names are listed. A failed restore leaves the env as it was.
410
+ .IP \(bu 2
411
+ The URL is never a flag value, since that stays in your shell history. Put it in a variable and pass \-\-from\-env VAR.
412
+ .IP \(bu 2
413
+ Run it with \-\-check first. prod asks before it writes; without a terminal it needs \-\-yes. Stop writes to the old database before the copy: writes made after it starts are lost.
388
414
  .PP
389
- Exit codes: 0 a whole dump was written; 1 refused, unreachable, or cut off (the partial file is removed); 2 bad arguments, the file exists, or no CLI token.
415
+ Exit codes: 0 a whole dump was written, or the restore (or check) passed; 1 refused, unreachable, cut off (a partial dump is removed), or the restore failed or was declined; 2 bad arguments, the file exists or is missing, or no CLI token.
390
416
  .SS metrics
391
417
  A number from the workspace's data: count, sum or average over a model's rows, events or contacts, grouped or over time.
392
418
  .PP
@@ -911,6 +937,41 @@ How a server app starts: its Procfile (\fBweb:\fR, workers, \fBrelease:\fR), els
911
937
  Admins and members with Developer access only. Progress streams while it runs.
912
938
  .PP
913
939
  Exit codes: 0 deployed; 1 refused or failed; 2 bad arguments, no CLI token, or not in a git repo.
940
+ .SS migrate
941
+ Move an app from Heroku, Railway, Render, Fly.io or Primcloud: its Procfile, sizes, health path and variables, worked out from its files and that platform's CLI.
942
+ .PP
943
+ \fBoalo migrate <heroku|railway|render|fly|primcloud> [\-\-from\-app <name>] [\-\-env\-file <file>] [\-\-json]\fR
944
+ .br
945
+ \fBoalo migrate <platform> \-\-apply [\-\-app <slug>]\fR
946
+ .br
947
+ .TP
948
+ \fB\-\-from\-app <name>\fR
949
+ The app or service on the old platform (a Heroku or Fly app, a Railway or Render service). Default: what that platform's CLI is linked to here, or fly.toml's app.
950
+ .TP
951
+ \fB\-\-env\-file <file>\fR
952
+ More variables, from a .env file (NAME=value lines). They win over what the platform's CLI reported. For Primcloud, and for Fly secrets, which can't be read back.
953
+ .TP
954
+ \fB\-\-apply\fR
955
+ Write the Procfile and .oalo/app.json (never over an existing Procfile; app.json only gains keys it lacks), and set the variables on the app's prod env. Without it, nothing changes.
956
+ .TP
957
+ \fB\-\-no\-cli\fR
958
+ Don't run the old platform's CLI; read only the files here and \-\-env\-file.
959
+ .TP
960
+ \fB\-\-app <slug>\fR
961
+ The Oalo app the variables go to. Default: the app this folder is linked to (oalo deploy \-\-new links it).
962
+ .TP
963
+ \fB\-\-json\fR
964
+ The plan as JSON: names only, never a value.
965
+ .IP \(bu 2
966
+ Run it in the app's repo. It reads fly.toml, railway.json or railway.toml, render.yaml, app.json and the Procfile, and asks the platform's CLI for variables (heroku, railway), secret names (fly) and dyno sizes (heroku) when it's installed and signed in.
967
+ .IP \(bu 2
968
+ Variables: PORT and each platform's own (HEROKU_*, RAILWAY_*, RENDER_*, FLY_*) are left out, and DATABASE_URL becomes EXTERNAL_DATABASE_URL, since DATABASE_URL on Oalo is the Base's own Postgres. Values are never printed or written to a file; \-\-apply sends them to prod without restarting the app.
969
+ .IP \(bu 2
970
+ It says what doesn't carry over: cron jobs, volumes and disks, private addresses (*.railway.internal, *.flycast), extra replicas, build commands, an Aptfile, a VM bigger than large.
971
+ .IP \(bu 2
972
+ The order: oalo migrate <platform> (read the plan), \-\-apply, commit, oalo deploy \-\-new, oalo migrate <platform> \-\-apply again (now the app exists, it sets the variables), then the data (oalo db restore) and oalo deploy \-\-env prod. \fBoalo docs migrating\-from\-<platform>\fR walks through it.
973
+ .PP
974
+ Exit codes: 0 the plan was shown, or applied; 1 a config file couldn't be read, or the variables couldn't be set; 2 bad arguments, an unknown platform, or no CLI token.
914
975
  .SS env
915
976
  An app's environment variables on Oalo (API keys, settings), per env: list, set, unset, rotate\-token.
916
977
  .PP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oalo-ai/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Wire a local app into your OALO workspace: link a token, generate typed bindings, and serve the build surface over MCP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -263,6 +263,29 @@ oalo deploy --app shop --env prod # build and swap the live release
263
263
  6. **Rolling back** (`oalo deploy --rollback`, MCP: `oalo_releases`) changes what
264
264
  live visitors get: only when the person asks. `--history` lists the
265
265
  releases.
266
+ 7. **Bringing a database in** (from Heroku, Railway, Render, Fly.io or
267
+ Primcloud): `oalo db restore <dump file> --env prod --check`, then the same
268
+ without `--check` once the person says go. The dump comes from the old
269
+ platform (Heroku: `heroku pg:backups:capture`, then `heroku
270
+ pg:backups:download` gives `latest.dump`). Or the person puts the old
271
+ database's URL in an environment variable and you pass `--from-env VAR`.
272
+ Never write a database URL into a command or a flag. Always name `--env`.
273
+ Have the person stop writes to the old database first: anything written
274
+ after the copy starts is lost. `oalo docs migrating-from-heroku` (or
275
+ `-railway`, `-render`, `-fly`, `-primcloud`) has each platform's steps.
276
+ 8. **Moving an app from another platform** (Heroku, Railway, Render, Fly.io,
277
+ Primcloud): in the app's repo, run `oalo migrate <platform> --from-app
278
+ <name>` first (the app or service's name on the old platform, the Heroku
279
+ app say) and show the person the plan; it changes nothing. Only `--apply`
280
+ writes the Procfile and `.oalo/app.json` and sets the variables, and it
281
+ sets them only once the app exists, so the order is `--apply`, commit,
282
+ `oalo deploy --new`, then the same `oalo migrate ... --apply` again. The
283
+ variables go to the app's prod settings with nothing restarted or deployed,
284
+ so this doesn't put it live; the preview keeps its own (`--env dev`, test
285
+ keys). It never prints a value. Don't copy variables by hand with `oalo env
286
+ set NAME=value`: for values it can't read (Fly secrets, Primcloud) the
287
+ person puts them in a .env file and you pass `--env-file`. Then the data
288
+ (rule 7), and live only when the person says so.
266
289
 
267
290
  ## An app's environment variables
268
291
 
@@ -1,7 +1,24 @@
1
- import { createWriteStream, existsSync, rmSync, statSync } from "node:fs";
1
+ import { createReadStream, createWriteStream, existsSync, rmSync, statSync } from "node:fs";
2
+ import { Readable, Transform } from "node:stream";
3
+ import http from "node:http";
4
+ import https from "node:https";
5
+ import { createInterface } from "node:readline/promises";
2
6
  import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
3
7
  import { dim } from "../events.js";
4
8
 
9
+ const USAGE =
10
+ "usage: oalo db dump [--env dev|prod] [--out <file.sql>]\n" +
11
+ " oalo db restore <file> --env dev|prod [--schema <name>] [--check] [--yes]\n" +
12
+ " oalo db restore --from-env <VAR> --env dev|prod [--schema <name>] [--check] [--yes]";
13
+
14
+ /** `oalo db dump` and `oalo db restore`. */
15
+ export async function db(flags, rest, opts = {}) {
16
+ const sub = rest[1];
17
+ if (sub === "dump") return dump(flags, opts);
18
+ if (sub === "restore") return restore(flags, rest, opts);
19
+ return fail(USAGE, 2);
20
+ }
21
+
5
22
  /**
6
23
  * `oalo db dump [--env prod] [--out file.sql]`: all of an env's data as plain
7
24
  * SQL (every app's own tables and the Oalo models, with their rows), so it can
@@ -9,9 +26,7 @@ import { dim } from "../events.js";
9
26
  * with pg_dump's own "dump complete" line; a dump without it is cut off, and
10
27
  * the file is removed rather than left looking whole.
11
28
  */
12
- export async function db(flags, rest, opts = {}) {
13
- const sub = rest[1];
14
- if (sub !== "dump") return fail("usage: oalo db dump [--env dev|prod] [--out <file.sql>]", 2);
29
+ async function dump(flags, opts) {
15
30
  const env = flags.env === "dev" ? "dev" : "prod";
16
31
  const { url, token } = controlPlane(resolveConfig(flags));
17
32
  if (!url || !token) return fail(`oalo db dump needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
@@ -52,6 +67,323 @@ export async function db(flags, rest, opts = {}) {
52
67
  console.log(`${out} ${dim(`${mb} MB, the ${env} data as SQL. Restore it with: psql "$DATABASE_URL" -f ${out}`)}`);
53
68
  }
54
69
 
70
+ export const RESTORE_LIMIT = 10 * 1024 ** 3;
71
+
72
+ /**
73
+ * `oalo db restore <file> --env dev|prod` or `--from-env VAR`: load an
74
+ * existing Postgres database (a dump file, or a URL the Base copies from) into
75
+ * one env of this Base. The reverse of dump. The URL comes from an environment
76
+ * variable, never a flag (shell history), and is never printed: only its host
77
+ * is named. A live restore asks first at a terminal and needs --yes without
78
+ * one; --check writes nothing, so it never asks.
79
+ *
80
+ * opts (for tests): fetch, env (the environment variables), isTTY (stdin is a
81
+ * terminal), confirm(question) -> answer, progress (stderr is a terminal).
82
+ */
83
+ async function restore(flags, rest, opts) {
84
+ const vars = opts.env || process.env;
85
+ const env = flags.env;
86
+ if (env !== "dev" && env !== "prod") return fail("oalo db restore needs --env dev or --env prod: which env the data goes into.", 2);
87
+
88
+ const file = rest[2];
89
+ const fromEnv = flags["from-env"];
90
+ if (file && fromEnv !== undefined) return fail("pick one: a dump file, or --from-env VAR for a database URL.", 2);
91
+ if (!file && fromEnv === undefined) return fail(USAGE, 2);
92
+ if (flags.schema !== undefined && (typeof flags.schema !== "string" || !flags.schema)) return fail("--schema needs the source schema's name, like --schema public", 2);
93
+ const check = !!flags.check;
94
+
95
+ let source;
96
+ if (file) {
97
+ let st;
98
+ try {
99
+ st = statSync(file);
100
+ } catch {
101
+ return fail(`${file} doesn't exist.`, 2);
102
+ }
103
+ if (!st.isFile()) return fail(`${file} isn't a file. Give the dump file's path.`, 2);
104
+ if (st.size > RESTORE_LIMIT) return fail(`${file} is ${size(st.size)}, over the 10 GB a restore can take.`, 1);
105
+ source = { file, bytes: st.size };
106
+ } else {
107
+ const name = typeof fromEnv === "string" ? fromEnv : "";
108
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return fail("--from-env takes the name of the environment variable that holds the database URL, like --from-env SOURCE_DATABASE_URL", 2);
109
+ const value = (vars[name] || "").trim();
110
+ if (!value) return fail(`${name} is empty or not set. Put the database URL in it, then run this again.`, 2);
111
+ const host = pgHost(value);
112
+ if (!host) return fail(`${name} doesn't hold a database URL. It needs one that starts with postgres:// or postgresql:// and names a host.`, 2);
113
+ source = { url: value, host };
114
+ }
115
+
116
+ const { url, token } = controlPlane(resolveConfig(flags));
117
+ if (!url || !token) return fail(`oalo db restore needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
118
+
119
+ // A live restore asks first. --check writes nothing, so it never does.
120
+ if (env === "prod" && !check && !flags.yes) {
121
+ const tty = opts.isTTY ?? process.stdin.isTTY;
122
+ if (!tty) return fail("restoring into prod needs --yes when there's no terminal to ask at. Run it with --check first, then add --yes.", 2);
123
+ const question = source.file ? `Restore ${size(source.bytes)} into prod? [y/N] ` : `Restore the database at ${source.host} into prod? [y/N] `;
124
+ const answer = await (opts.confirm || ask)(question);
125
+ if (!/^(y|yes)$/i.test(String(answer || "").trim())) return fail("Nothing was restored.", 1);
126
+ }
127
+
128
+ const scrub = scrubber(source.url);
129
+ const q = new URLSearchParams({ env, ...(typeof flags.schema === "string" ? { schema: flags.schema } : {}), ...(check ? { check: "true" } : {}) });
130
+ const endpoint = `${url.replace(/\/$/, "")}/api/cli/v1/db/restore?${q}`;
131
+ console.error(dim(`${check ? "checking" : "restoring"} ${source.file ? `${source.file} (${size(source.bytes)})` : `the database at ${source.host}`} ${check ? "against" : "into"} ${env} on ${url}`));
132
+
133
+ let res;
134
+ let upload = null;
135
+ try {
136
+ if (source.file) {
137
+ upload = uploadBody(source, opts.progress ?? process.stderr.isTTY);
138
+ const headers = { authorization: `Bearer ${token}`, "content-type": "application/octet-stream", "content-length": String(source.bytes) };
139
+ res = opts.fetch
140
+ ? await opts.fetch(endpoint, { method: "POST", headers, body: upload.web(), duplex: "half" })
141
+ : await sendFile(endpoint, headers, upload.stream);
142
+ } else {
143
+ res = await (opts.fetch || fetch)(endpoint, {
144
+ method: "POST",
145
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
146
+ body: JSON.stringify({ secret_url: source.url }),
147
+ });
148
+ }
149
+ } catch (e) {
150
+ upload?.stop();
151
+ const why = scrub(e.cause?.message || e.message);
152
+ return fail(`${upload ? `the upload to ${url} didn't go through` : `could not reach ${url}`} (${why}). Nothing was restored.`, 1);
153
+ }
154
+
155
+ // A refusal can come before the upload is done: read it, then stop sending.
156
+ if (!res.ok) {
157
+ upload?.pause();
158
+ const body = await res.json().catch(() => ({}));
159
+ upload?.stop();
160
+ res.close?.();
161
+ return fail(scrub(body?.error?.message || `the restore was refused (HTTP ${res.status})`), 1);
162
+ }
163
+
164
+ let last = null;
165
+ try {
166
+ last = await follow(res, (m) => (upload ? upload.note(() => console.error(dim(` ${scrub(m)}`))) : console.error(dim(` ${scrub(m)}`))));
167
+ } catch {
168
+ last = null;
169
+ } finally {
170
+ upload?.stop();
171
+ res.close?.();
172
+ }
173
+
174
+ if (!last) {
175
+ return fail("the connection closed before the restore finished. A restore that fails leaves the env as it was; run it again with --check to see where it stands.", 1);
176
+ }
177
+ if (last.error) {
178
+ console.error(`restore failed: ${scrub(last.error.message || last.error.code || "unknown error")}`);
179
+ const names = last.error.conflicts || [];
180
+ if (names.length) console.error(dim(` already in ${env}: ${names.join(", ")}`));
181
+ process.exitCode = 1;
182
+ return;
183
+ }
184
+ summary(last, env, scrub);
185
+ }
186
+
187
+ /** The summary of a finished restore, or of a check. */
188
+ function summary(r, env, scrub) {
189
+ if (r.message) console.log(scrub(r.message));
190
+ const tables = r.tables || [];
191
+ const w = Math.max(0, ...tables.map((t) => String(t.name).length));
192
+ for (const t of tables) {
193
+ const rows = `${Number(t.rows || 0).toLocaleString("en-US")} ${t.rows === 1 ? "row" : "rows"}`;
194
+ console.log(` ${String(t.name).padEnd(w)} ${dim(`${rows}, ${size(t.bytes || 0)}`)}`);
195
+ }
196
+ if (r.extensions?.length) console.log(dim(`extensions: ${r.extensions.join(", ")}`));
197
+ if (r.skipped?.length) console.log(dim(`skipped: ${r.skipped.join(", ")}`));
198
+ for (const x of r.warnings || []) console.log(`warning: ${scrub(x)}`);
199
+ if (r.owner) console.log(dim(`owner: ${r.owner}, the app's database login, so its release: migrations can alter them`));
200
+ if (r.check) {
201
+ const names = r.conflicts || [];
202
+ if (names.length) {
203
+ console.log(`already in ${env}, so the restore would be refused: ${names.join(", ")}`);
204
+ process.exitCode = 1;
205
+ }
206
+ console.log(dim("Nothing was written."));
207
+ }
208
+ }
209
+
210
+ /** Print each progress message; return the final line (done or error), or null. */
211
+ async function follow(res, print) {
212
+ const decoder = new TextDecoder();
213
+ let buf = "";
214
+ let last = null;
215
+ const take = (raw) => {
216
+ raw = raw.trim();
217
+ if (!raw) return;
218
+ let msg;
219
+ try {
220
+ msg = JSON.parse(raw);
221
+ } catch {
222
+ return;
223
+ }
224
+ if (msg.done || msg.error) last = msg;
225
+ else if (msg.phase !== "waiting" && msg.message) print(msg.message);
226
+ };
227
+ for await (const part of res.body) {
228
+ buf += decoder.decode(part, { stream: true });
229
+ let i;
230
+ while ((i = buf.indexOf("\n")) >= 0) {
231
+ take(buf.slice(0, i));
232
+ buf = buf.slice(i + 1);
233
+ }
234
+ }
235
+ take(buf + decoder.decode());
236
+ return last;
237
+ }
238
+
239
+ /**
240
+ * The file as a request body, read as it's sent and counted. At a terminal one
241
+ * stderr line shows how far it got; progress messages print above it.
242
+ */
243
+ function uploadBody({ file, bytes }, tty) {
244
+ const total = size(bytes);
245
+ let sent = 0;
246
+ let shown = "";
247
+ let active = false;
248
+ let done = false;
249
+ const clear = () => active && !done && process.stderr.write("\r\x1b[K");
250
+ const draw = (force) => {
251
+ if (!tty || done) return;
252
+ const line = `uploading ${size(sent)} of ${total}`;
253
+ if (line === shown && !force) return;
254
+ shown = line;
255
+ active = true;
256
+ process.stderr.write(`\r\x1b[K${dim(line)}`);
257
+ };
258
+ const src = createReadStream(file);
259
+ const stream = new Transform({
260
+ transform(chunk, _enc, cb) {
261
+ sent += chunk.length;
262
+ draw();
263
+ cb(null, chunk);
264
+ },
265
+ flush(cb) {
266
+ if (active && !done) process.stderr.write(`\r\x1b[K${dim(`uploaded ${total}`)}\n`);
267
+ done = true;
268
+ cb();
269
+ },
270
+ });
271
+ src.on("error", (e) => stream.destroy(e));
272
+ src.pipe(stream);
273
+ if (!tty) console.error(dim(`uploading ${total}`));
274
+ return {
275
+ stream,
276
+ web: () => Readable.toWeb(stream),
277
+ /** Print a line without breaking the progress line. */
278
+ note(print) {
279
+ clear();
280
+ print();
281
+ if (active) draw(true);
282
+ },
283
+ pause: () => src.unpipe(stream),
284
+ /** Stop reading the file (a refusal, an error, or the end). */
285
+ stop() {
286
+ clear();
287
+ done = true;
288
+ src.destroy();
289
+ stream.destroy();
290
+ },
291
+ };
292
+ }
293
+
294
+ /**
295
+ * POST the file with node:http and `expect: 100-continue`: the body goes once
296
+ * the server says to (or after CONTINUE_WAIT_MS, for one that never does), so
297
+ * a refusal made from the headers (too big, not enough disk) arrives before
298
+ * any bytes are sent. fetch can't wait like this, and when a server refuses
299
+ * mid-upload and closes, it fails with EPIPE and the server's message is lost.
300
+ * The reply is returned as a web Response; `close()` ends the request.
301
+ */
302
+ export const CONTINUE_WAIT_MS = 3000;
303
+
304
+ function sendFile(endpoint, headers, body) {
305
+ return new Promise((resolve, reject) => {
306
+ const u = new URL(endpoint);
307
+ let replied = false;
308
+ let started = false;
309
+ const req = (u.protocol === "https:" ? https : http).request(u, { method: "POST", headers: { ...headers, expect: "100-continue" } });
310
+ const start = () => {
311
+ if (started || replied) return;
312
+ started = true;
313
+ body.pipe(req);
314
+ };
315
+ const timer = setTimeout(start, CONTINUE_WAIT_MS);
316
+ req.on("continue", () => (clearTimeout(timer), start()));
317
+ req.on("response", (incoming) => {
318
+ replied = true;
319
+ clearTimeout(timer);
320
+ const h = new Headers();
321
+ for (const [k, v] of Object.entries(incoming.headers)) h.set(k, Array.isArray(v) ? v.join(", ") : String(v));
322
+ const res = new Response(Readable.toWeb(incoming), { status: incoming.statusCode, headers: h });
323
+ res.close = () => req.destroy();
324
+ resolve(res);
325
+ });
326
+ // After the reply has come, a write error (the server stopped reading) is expected.
327
+ req.on("error", (e) => {
328
+ clearTimeout(timer);
329
+ if (!replied) reject(e);
330
+ });
331
+ body.on("error", (e) => req.destroy(e));
332
+ req.flushHeaders();
333
+ });
334
+ }
335
+
336
+ /** The host of a postgres:// or postgresql:// URL, or null when it isn't one. */
337
+ export function pgHost(value) {
338
+ if (!/^postgres(ql)?:\/\//i.test(value)) return null;
339
+ try {
340
+ return new URL(value).hostname || null;
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Removes a database URL's credentials from text the server or the network
348
+ * stack wrote, before it's printed: the URL itself, its password, and the
349
+ * user:password part of any postgres URL.
350
+ */
351
+ function scrubber(secret) {
352
+ let password = "";
353
+ let host = "";
354
+ if (secret) {
355
+ try {
356
+ const u = new URL(secret);
357
+ password = decodeURIComponent(u.password || "");
358
+ host = u.hostname;
359
+ } catch {}
360
+ }
361
+ return (text) => {
362
+ let s = String(text ?? "");
363
+ if (secret) s = s.split(secret).join(`the database at ${host}`);
364
+ s = s.replace(/(postgres(?:ql)?:\/\/)[^\s@/"']*@/gi, "$1***@");
365
+ if (password.length >= 4) s = s.split(password).join("***");
366
+ return s;
367
+ };
368
+ }
369
+
370
+ /** Bytes as people read them: 1.2 GB, 340 MB, 12 KB. */
371
+ export function size(n) {
372
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
373
+ if (n >= 1024 ** 2) return `${n >= 10 * 1024 ** 2 ? Math.round(n / 1024 ** 2) : (n / 1024 ** 2).toFixed(1)} MB`;
374
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`;
375
+ return `${n} B`;
376
+ }
377
+
378
+ async function ask(question) {
379
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
380
+ try {
381
+ return await rl.question(question);
382
+ } finally {
383
+ rl.close();
384
+ }
385
+ }
386
+
55
387
  function fail(message, code) {
56
388
  console.error(message);
57
389
  process.exitCode = code;