@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 +65 -4
- package/package.json +1 -1
- package/plugin/skills/oalo/SKILL.md +23 -0
- package/src/commands/db.js +336 -4
- package/src/commands/migrate.js +321 -0
- package/src/help.js +44 -4
- package/src/index.js +4 -0
- package/src/migrate/plan.js +478 -0
- package/src/migrate/toml.js +242 -0
- package/src/migrate/yaml.js +179 -0
package/man/oalo.1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.TH OALO 1 "" "oalo 0.1.
|
|
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
|
-
|
|
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,
|
|
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
|
@@ -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
|
|
package/src/commands/db.js
CHANGED
|
@@ -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
|
-
|
|
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;
|