@coffre/cli 0.1.4 → 0.1.6
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/README.md +1 -1
- package/dist/{keys-RhB7ayOY.js → keys-BCKUEb-Y.js} +174 -19
- package/dist/main.js +6 -69
- package/dist/setup-D3SXt_P-.js +3341 -0
- package/dist/templates/node/package.json +3 -3
- package/dist/templates/workers/README.md +6 -0
- package/dist/templates/workers/package.json +4 -4
- package/package.json +5 -4
- package/dist/setup-3OJc_VCA.js +0 -756
package/dist/setup-3OJc_VCA.js
DELETED
|
@@ -1,756 +0,0 @@
|
|
|
1
|
-
import { d as esm_default } from "./esm-Bts-esAl.js";
|
|
2
|
-
import { _ as yes, c as Cancelled, d as paragraph, f as release, g as wrap, h as truncate, i as keyValues, l as hiddenLine, m as style, n as jsonWarning, o as needsTerminal, p as row, r as keyGuide, s as showSecrets, t as generateKeys, u as openTerminal } from "./keys-RhB7ayOY.js";
|
|
3
|
-
import { r as postgresConnection, t as engineOfUrl } from "./connect-BONR52al.js";
|
|
4
|
-
import { parseArgs } from "node:util";
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { createHash, createHmac, pbkdf2Sync, randomBytes } from "node:crypto";
|
|
8
|
-
import { readFile } from "node:fs/promises";
|
|
9
|
-
//#region ../db/dist/migrate.js
|
|
10
|
-
/**
|
|
11
|
-
* Bring a database up to date with its engine's migration tree:
|
|
12
|
-
* migrations/postgres or migrations/sqlite. Both trees
|
|
13
|
-
* describe one schema; see schema-parity.test.ts.
|
|
14
|
-
*
|
|
15
|
-
* Applied history must be a prefix of the local journal, byte for byte,
|
|
16
|
-
* before and after. Only one migrator runs at a time: Postgres takes an
|
|
17
|
-
* advisory lock, and SQLite's own write lock covers the
|
|
18
|
-
* whole run. Restricted runtime roles are Postgres-only (baseline/postgres.sql).
|
|
19
|
-
*/
|
|
20
|
-
const MIGRATION_LOCK_KEY = "7165058122361679213";
|
|
21
|
-
/**
|
|
22
|
-
* Beside this module: src/migrations in the workspace, and dist/migrations
|
|
23
|
-
* in the built package, where the build copies them.
|
|
24
|
-
*/
|
|
25
|
-
function migrationsFolder(engine) {
|
|
26
|
-
return fileURLToPath(new URL(`./migrations/${engine}`, import.meta.url));
|
|
27
|
-
}
|
|
28
|
-
async function expectedMigrations(engine) {
|
|
29
|
-
const folder = migrationsFolder(engine);
|
|
30
|
-
const journal = JSON.parse(await readFile(`${folder}/meta/_journal.json`, "utf8"));
|
|
31
|
-
if (!Array.isArray(journal.entries)) throw new Error("invalid Drizzle migration journal");
|
|
32
|
-
const expected = await Promise.all(journal.entries.map(async (entry, position) => {
|
|
33
|
-
if (entry.idx !== position || !Number.isSafeInteger(entry.when) || typeof entry.tag !== "string" || !/^[a-zA-Z0-9_-]+$/.test(entry.tag)) throw new Error(`invalid Drizzle migration journal entry at index ${position}`);
|
|
34
|
-
const sqlText = await readFile(`${folder}/${entry.tag}.sql`, "utf8");
|
|
35
|
-
return {
|
|
36
|
-
createdAt: String(entry.when),
|
|
37
|
-
hash: createHash("sha256").update(sqlText).digest("hex"),
|
|
38
|
-
tag: entry.tag
|
|
39
|
-
};
|
|
40
|
-
}));
|
|
41
|
-
for (let index = 1; index < expected.length; index += 1) if (BigInt(expected[index - 1].createdAt) >= BigInt(expected[index].createdAt)) throw new Error("Drizzle migration journal timestamps must be strictly increasing");
|
|
42
|
-
return expected;
|
|
43
|
-
}
|
|
44
|
-
function verifyHistory(expected, applied, requireComplete) {
|
|
45
|
-
const rows = applied ?? [];
|
|
46
|
-
if (rows.length > expected.length) throw new Error("database contains migrations that are absent from this image");
|
|
47
|
-
if (requireComplete && rows.length !== expected.length) throw new Error(`migration history is incomplete: expected ${expected.length}, found ${rows.length}`);
|
|
48
|
-
rows.forEach((row, index) => {
|
|
49
|
-
const local = expected[index];
|
|
50
|
-
if (row.created_at !== local.createdAt || row.hash !== local.hash) throw new Error(`migration history diverged at ${local.tag}; applied migrations must never be edited`);
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
async function postgresMigrator(url) {
|
|
54
|
-
const [{ default: pg }, { drizzle }, { migrate }] = await Promise.all([
|
|
55
|
-
import("./esm-B69mMLcR.js"),
|
|
56
|
-
import("./node-postgres-BlNvYfq_.js"),
|
|
57
|
-
import("./migrator-W1a-ooWi.js")
|
|
58
|
-
]);
|
|
59
|
-
const pool = new pg.Pool({
|
|
60
|
-
...postgresConnection(url),
|
|
61
|
-
application_name: "coffre-migrations",
|
|
62
|
-
max: 1
|
|
63
|
-
});
|
|
64
|
-
const client = await pool.connect();
|
|
65
|
-
return {
|
|
66
|
-
async applied() {
|
|
67
|
-
if (!(await client.query("SELECT to_regclass('drizzle.__drizzle_migrations')::text AS relation")).rows[0]?.relation) return null;
|
|
68
|
-
return (await client.query("SELECT hash, created_at::text FROM drizzle.__drizzle_migrations ORDER BY created_at, id")).rows;
|
|
69
|
-
},
|
|
70
|
-
async lock() {
|
|
71
|
-
await client.query("SET lock_timeout = '5min'");
|
|
72
|
-
await client.query("SELECT pg_advisory_lock($1::bigint)", [MIGRATION_LOCK_KEY]);
|
|
73
|
-
},
|
|
74
|
-
async unlock() {
|
|
75
|
-
await client.query("SELECT pg_advisory_unlock($1::bigint)", [MIGRATION_LOCK_KEY]);
|
|
76
|
-
},
|
|
77
|
-
migrate: (migrationsFolder) => migrate(drizzle(client), { migrationsFolder }),
|
|
78
|
-
async restrict() {
|
|
79
|
-
await client.query(`DO $$ BEGIN
|
|
80
|
-
EXECUTE format(
|
|
81
|
-
'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime',
|
|
82
|
-
current_database()
|
|
83
|
-
);
|
|
84
|
-
END $$`);
|
|
85
|
-
},
|
|
86
|
-
async close() {
|
|
87
|
-
client.release();
|
|
88
|
-
await pool.end();
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
async function sqliteMigrator(url) {
|
|
93
|
-
const [{ openDatabase }, { migrate }] = await Promise.all([import("./connect-BZdPYNbZ.js"), import("drizzle-orm/libsql/migrator")]);
|
|
94
|
-
const { db, close } = await openDatabase(url);
|
|
95
|
-
const sqlite = db;
|
|
96
|
-
return {
|
|
97
|
-
async applied() {
|
|
98
|
-
if ((await sqlite.all("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'")).length === 0) return null;
|
|
99
|
-
return sqlite.all("SELECT hash, CAST(created_at AS TEXT) AS created_at FROM __drizzle_migrations ORDER BY created_at, id");
|
|
100
|
-
},
|
|
101
|
-
async lock() {},
|
|
102
|
-
async unlock() {},
|
|
103
|
-
migrate: (migrationsFolder) => migrate(sqlite, { migrationsFolder }),
|
|
104
|
-
async restrict() {},
|
|
105
|
-
close
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Apply missing migrations, check their history and reassert database
|
|
110
|
-
* privileges. `onPlan` hears how many there are to apply before they are:
|
|
111
|
-
* all of them go in one call, with no word of each.
|
|
112
|
-
*/
|
|
113
|
-
async function migrateDatabase(url, onPlan) {
|
|
114
|
-
const engine = engineOfUrl(url);
|
|
115
|
-
const expected = await expectedMigrations(engine);
|
|
116
|
-
const migrator = await {
|
|
117
|
-
postgres: postgresMigrator,
|
|
118
|
-
sqlite: sqliteMigrator
|
|
119
|
-
}[engine](url);
|
|
120
|
-
let locked = false;
|
|
121
|
-
try {
|
|
122
|
-
await migrator.lock();
|
|
123
|
-
locked = true;
|
|
124
|
-
const applied = await migrator.applied();
|
|
125
|
-
verifyHistory(expected, applied, false);
|
|
126
|
-
onPlan?.({
|
|
127
|
-
applied: applied?.length ?? 0,
|
|
128
|
-
total: expected.length
|
|
129
|
-
});
|
|
130
|
-
await migrator.migrate(migrationsFolder(engine));
|
|
131
|
-
verifyHistory(expected, await migrator.applied(), true);
|
|
132
|
-
await migrator.restrict();
|
|
133
|
-
} finally {
|
|
134
|
-
try {
|
|
135
|
-
if (locked) await migrator.unlock();
|
|
136
|
-
} finally {
|
|
137
|
-
await migrator.close();
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
//#endregion
|
|
142
|
-
//#region src/steps.ts
|
|
143
|
-
const SPINNER = [
|
|
144
|
-
"⠋",
|
|
145
|
-
"⠙",
|
|
146
|
-
"⠹",
|
|
147
|
-
"⠸",
|
|
148
|
-
"⠼",
|
|
149
|
-
"⠴",
|
|
150
|
-
"⠦",
|
|
151
|
-
"⠧",
|
|
152
|
-
"⠇",
|
|
153
|
-
"⠏"
|
|
154
|
-
];
|
|
155
|
-
const FRAME_MS = 80;
|
|
156
|
-
var Steps = class {
|
|
157
|
-
#out;
|
|
158
|
-
#s;
|
|
159
|
-
#steps;
|
|
160
|
-
#keys;
|
|
161
|
-
#describe;
|
|
162
|
-
#frame = 0;
|
|
163
|
-
#drawn = 0;
|
|
164
|
-
#timer = null;
|
|
165
|
-
#restoreCursor = () => this.#out.write("\x1B[?25h");
|
|
166
|
-
/** Ctrl-C while a step runs, the terminal in its usual mode: the cursor back, then out, with nothing more to show. */
|
|
167
|
-
#interrupt = () => {
|
|
168
|
-
this.#stop();
|
|
169
|
-
this.end();
|
|
170
|
-
this.#out.write("\n");
|
|
171
|
-
process.exit(130);
|
|
172
|
-
};
|
|
173
|
-
/**
|
|
174
|
-
* `titles`, what each step does while it runs; `keys`, the keyboard for
|
|
175
|
-
* questions; `describe`, an error as it may be shown, a secret it quotes
|
|
176
|
-
* taken out.
|
|
177
|
-
*/
|
|
178
|
-
constructor(out, titles, keys, describe) {
|
|
179
|
-
this.#out = out;
|
|
180
|
-
this.#s = style(out);
|
|
181
|
-
this.#keys = keys;
|
|
182
|
-
this.#describe = describe;
|
|
183
|
-
this.#steps = titles.map((text) => ({
|
|
184
|
-
text,
|
|
185
|
-
status: "pending",
|
|
186
|
-
details: []
|
|
187
|
-
}));
|
|
188
|
-
if (this.#s.ansi) {
|
|
189
|
-
out.write("\x1B[?25l");
|
|
190
|
-
process.once("exit", this.#restoreCursor);
|
|
191
|
-
process.on("SIGINT", this.#interrupt);
|
|
192
|
-
this.#draw();
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
/** Run step `i`: its line is the outcome `work` returns, or the error it throws. */
|
|
196
|
-
async run(i, work) {
|
|
197
|
-
const state = this.#steps[i];
|
|
198
|
-
state.status = "running";
|
|
199
|
-
this.#tick();
|
|
200
|
-
const step = {
|
|
201
|
-
note: (text) => {
|
|
202
|
-
state.text = text;
|
|
203
|
-
this.#draw();
|
|
204
|
-
},
|
|
205
|
-
ask: (question) => this.#ask(state, question)
|
|
206
|
-
};
|
|
207
|
-
try {
|
|
208
|
-
const outcome = await work(step);
|
|
209
|
-
Object.assign(state, typeof outcome === "string" ? {
|
|
210
|
-
text: outcome,
|
|
211
|
-
details: []
|
|
212
|
-
} : outcome, { status: "done" });
|
|
213
|
-
} catch (error) {
|
|
214
|
-
state.status = "failed";
|
|
215
|
-
state.details = error instanceof Cancelled ? [] : [this.#describe(error)];
|
|
216
|
-
if (error instanceof Cancelled) state.text = `${state.text}: cancelled`;
|
|
217
|
-
this.#stop();
|
|
218
|
-
throw error;
|
|
219
|
-
}
|
|
220
|
-
this.#stop();
|
|
221
|
-
if (!this.#s.ansi) this.#out.write(`✓ ${state.text}\n${state.details.map((line) => ` ${line}\n`).join("")}`);
|
|
222
|
-
else if (this.#steps.every((each) => each.status === "done")) this.end();
|
|
223
|
-
}
|
|
224
|
-
/** Leave the list as it stands, the cursor below it. */
|
|
225
|
-
end() {
|
|
226
|
-
this.#stop();
|
|
227
|
-
if (this.#s.ansi) {
|
|
228
|
-
this.#restoreCursor();
|
|
229
|
-
process.off("exit", this.#restoreCursor);
|
|
230
|
-
process.off("SIGINT", this.#interrupt);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
async #ask(state, question) {
|
|
234
|
-
const keys = this.#keys();
|
|
235
|
-
if (keys === null) return false;
|
|
236
|
-
this.#stop();
|
|
237
|
-
const before = state.text;
|
|
238
|
-
state.text = `${question} ${this.#s.dim("y/N")}`;
|
|
239
|
-
state.status = "asking";
|
|
240
|
-
this.#draw();
|
|
241
|
-
try {
|
|
242
|
-
return await yes(keys);
|
|
243
|
-
} finally {
|
|
244
|
-
state.text = before;
|
|
245
|
-
state.status = "running";
|
|
246
|
-
this.#tick();
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
#tick() {
|
|
250
|
-
this.#draw();
|
|
251
|
-
if (this.#s.ansi && this.#timer === null) this.#timer = setInterval(() => {
|
|
252
|
-
this.#frame = (this.#frame + 1) % SPINNER.length;
|
|
253
|
-
this.#draw();
|
|
254
|
-
}, FRAME_MS);
|
|
255
|
-
}
|
|
256
|
-
#stop() {
|
|
257
|
-
if (this.#timer !== null) clearInterval(this.#timer);
|
|
258
|
-
this.#timer = null;
|
|
259
|
-
this.#draw();
|
|
260
|
-
if (!this.#s.ansi) {
|
|
261
|
-
const failed = this.#steps.find((each) => each.status === "failed");
|
|
262
|
-
if (failed !== void 0) this.#out.write(`✗ ${failed.text}\n${failed.details.map((line) => ` ${line}\n`).join("")}`);
|
|
263
|
-
} else if (this.#steps.some((each) => each.status === "failed")) this.end();
|
|
264
|
-
}
|
|
265
|
-
#draw() {
|
|
266
|
-
if (!this.#s.ansi) return;
|
|
267
|
-
const s = this.#s;
|
|
268
|
-
const columns = Math.max(20, (this.#out.columns ?? 80) - 1);
|
|
269
|
-
const lines = this.#steps.flatMap(({ text, status, details }) => {
|
|
270
|
-
const icon = {
|
|
271
|
-
pending: s.dim("·"),
|
|
272
|
-
running: s.accent(SPINNER[this.#frame]),
|
|
273
|
-
asking: s.accent("?"),
|
|
274
|
-
done: s.green("✓"),
|
|
275
|
-
failed: s.red("✗")
|
|
276
|
-
}[status];
|
|
277
|
-
const line = status === "pending" ? s.dim(text) : status === "failed" ? s.red(text) : text;
|
|
278
|
-
const under = status === "failed" ? details.flatMap((detail) => wrap(detail, columns - 4)) : details.map(s.dim);
|
|
279
|
-
return [` ${icon} ${line}`, ...under.map((detail) => ` ${detail}`)];
|
|
280
|
-
});
|
|
281
|
-
const up = this.#drawn > 0 ? `\x1b[${this.#drawn}F` : "";
|
|
282
|
-
this.#out.write(`${up}${lines.map((line) => `${truncate(line, columns)}\x1b[K\n`).join("")}\x1b[J`);
|
|
283
|
-
this.#drawn = lines.length;
|
|
284
|
-
}
|
|
285
|
-
};
|
|
286
|
-
//#endregion
|
|
287
|
-
//#region src/setup.ts
|
|
288
|
-
/** Where the administrator's connection string comes from, when not from a hidden prompt. */
|
|
289
|
-
const URL_VARIABLE = "COFFRE_SETUP_DATABASE_URL";
|
|
290
|
-
/** The two runtime roles, as the migration names them, and the Hyperdrive config each gets on Workers. */
|
|
291
|
-
const ROLES = {
|
|
292
|
-
app: "coffre_runtime",
|
|
293
|
-
vault: "coffre_vault_runtime"
|
|
294
|
-
};
|
|
295
|
-
const HYPERDRIVE = {
|
|
296
|
-
app: "coffre",
|
|
297
|
-
vault: "coffre-vault"
|
|
298
|
-
};
|
|
299
|
-
var SetupError = class extends Error {};
|
|
300
|
-
/**
|
|
301
|
-
* The name a role logs in as. On PlanetScale Postgres, a login names its
|
|
302
|
-
* branch: the administrator connects as `postgres.<branch id>`, and every
|
|
303
|
-
* role on that branch as `<role>.<branch id>`. Elsewhere, the role's name.
|
|
304
|
-
*/
|
|
305
|
-
function loginFor(role, administrator) {
|
|
306
|
-
const dot = administrator.indexOf(".");
|
|
307
|
-
return dot === -1 ? role : `${role}${administrator.slice(dot)}`;
|
|
308
|
-
}
|
|
309
|
-
/** The administrator's URL, as `login` with `password`: same host, database and TLS settings. */
|
|
310
|
-
function loginUrl(administrator, login, password) {
|
|
311
|
-
const url = new URL(administrator.href);
|
|
312
|
-
url.username = encodeURIComponent(login);
|
|
313
|
-
url.password = password;
|
|
314
|
-
return url.href;
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* A wrangler hyperdrive command that reads the URL at a silent prompt, so
|
|
318
|
-
* that no password reaches the shell's history, and drops its parameters:
|
|
319
|
-
* Hyperdrive connects over TLS itself, checking the certificate against
|
|
320
|
-
* public CAs, and takes no `sslrootcert`. `${v%%[?]*}` holds in bash and
|
|
321
|
-
* zsh alike.
|
|
322
|
-
*/
|
|
323
|
-
function hyperdriveCommand(target) {
|
|
324
|
-
return `read -rs COFFRE_DB_URL && pnpm exec wrangler hyperdrive ${target} --connection-string="\${COFFRE_DB_URL%%[?]*}"; unset COFFRE_DB_URL`;
|
|
325
|
-
}
|
|
326
|
-
/**
|
|
327
|
-
* What Postgres keeps for a password under SCRAM-SHA-256 (RFC 5802 and
|
|
328
|
-
* 7677), made here, so that the password itself never reaches the server,
|
|
329
|
-
* nor its logs.
|
|
330
|
-
*/
|
|
331
|
-
function scramVerifier(password, salt = randomBytes(16), iterations = 4096) {
|
|
332
|
-
const salted = pbkdf2Sync(password, salt, iterations, 32, "sha256");
|
|
333
|
-
const stored = createHash("sha256").update(createHmac("sha256", salted).update("Client Key").digest()).digest();
|
|
334
|
-
const server = createHmac("sha256", salted).update("Server Key").digest();
|
|
335
|
-
return `SCRAM-SHA-256$${iterations}:${salt.toString("base64")}$${stored.toString("base64")}:${server.toString("base64")}`;
|
|
336
|
-
}
|
|
337
|
-
/** 24 random bytes, URL-safe: nothing to encode in a connection string. */
|
|
338
|
-
function newPassword() {
|
|
339
|
-
return randomBytes(24).toString("base64url");
|
|
340
|
-
}
|
|
341
|
-
async function setup(args) {
|
|
342
|
-
const secrets = [];
|
|
343
|
-
const clean = (error) => redact(error instanceof Error ? error.message : String(error), secrets);
|
|
344
|
-
let options;
|
|
345
|
-
try {
|
|
346
|
-
options = parseOptions(args);
|
|
347
|
-
} catch (error) {
|
|
348
|
-
return fail(process.stderr, clean(error));
|
|
349
|
-
}
|
|
350
|
-
const terminal = options.json ? null : openTerminal();
|
|
351
|
-
if (!options.json && terminal === null) return fail(process.stderr, needsTerminal("coffre setup"));
|
|
352
|
-
const out = terminal?.out ?? process.stderr;
|
|
353
|
-
const s = style(out);
|
|
354
|
-
const questions = () => terminal?.keys ?? (process.stdin.isTTY ? process.stdin : null);
|
|
355
|
-
try {
|
|
356
|
-
if (s.ansi) out.write(`\n ${s.bold("coffre setup")} ${s.dim("a deployment's database logins, migrations and keys")}\n\n`);
|
|
357
|
-
const text = await readAdministrator(out, s);
|
|
358
|
-
secrets.push(text);
|
|
359
|
-
const administrator = administratorUrl(text);
|
|
360
|
-
if (administrator.password !== "") secrets.push(administrator.password, decodeURIComponent(administrator.password));
|
|
361
|
-
const result = await run(administrator, out, questions, { resetPasswords: options.resetPasswords }, secrets, clean);
|
|
362
|
-
if (options.json) {
|
|
363
|
-
jsonWarning(process.stderr, listed(shownNames(result), "and"));
|
|
364
|
-
process.stdout.write(`${JSON.stringify(asJson(result))}\n`);
|
|
365
|
-
} else if (result.keys === null && result.app.url === null && result.vault.url === null) out.write(nothingToSave(out));
|
|
366
|
-
else {
|
|
367
|
-
await showSecrets(terminal, setupScreen(result));
|
|
368
|
-
out.write(summary(result, out));
|
|
369
|
-
}
|
|
370
|
-
} catch (error) {
|
|
371
|
-
if (error instanceof Cancelled) return fail(out, "cancelled; nothing after the last step done was changed", 130);
|
|
372
|
-
if (!(error instanceof StepFailed)) return fail(out, clean(error));
|
|
373
|
-
process.exit(1);
|
|
374
|
-
} finally {
|
|
375
|
-
if (terminal !== null) release(terminal.keys);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
function fail(out, message, code = 1) {
|
|
379
|
-
const s = style(out);
|
|
380
|
-
out.write(`${s.red("✗")} ${message}\n`);
|
|
381
|
-
process.exit(code);
|
|
382
|
-
}
|
|
383
|
-
function parseOptions(args) {
|
|
384
|
-
try {
|
|
385
|
-
const { values } = parseArgs({
|
|
386
|
-
args,
|
|
387
|
-
options: {
|
|
388
|
-
"reset-passwords": {
|
|
389
|
-
type: "boolean",
|
|
390
|
-
default: false
|
|
391
|
-
},
|
|
392
|
-
json: {
|
|
393
|
-
type: "boolean",
|
|
394
|
-
default: false
|
|
395
|
-
}
|
|
396
|
-
},
|
|
397
|
-
allowPositionals: false,
|
|
398
|
-
strict: true
|
|
399
|
-
});
|
|
400
|
-
return {
|
|
401
|
-
resetPasswords: values["reset-passwords"],
|
|
402
|
-
json: values.json
|
|
403
|
-
};
|
|
404
|
-
} catch {
|
|
405
|
-
const leaked = args.some((arg) => /postgres(ql)?:|@/i.test(arg));
|
|
406
|
-
throw new SetupError(`coffre setup takes only --reset-passwords and --json. It reads the administrator's connection string from a hidden prompt, ${URL_VARIABLE} or stdin, never from the command line, where the shell's history and other users can read it.` + (leaked ? " One of the arguments looks like one: change that password, which is in your shell history now." : ""));
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
/** The connection string: from the environment, from stdin when it is piped, or typed at a hidden prompt. */
|
|
410
|
-
async function readAdministrator(out, s) {
|
|
411
|
-
const given = process.env[URL_VARIABLE];
|
|
412
|
-
delete process.env[URL_VARIABLE];
|
|
413
|
-
if (given !== void 0 && given.trim() !== "") return given.trim();
|
|
414
|
-
if (!process.stdin.isTTY) {
|
|
415
|
-
const line = readFileSync(0, "utf8").split("\n").find((candidate) => candidate.trim() !== "");
|
|
416
|
-
if (line === void 0) throw new SetupError(`no connection string: pipe it to stdin, or set ${URL_VARIABLE}`);
|
|
417
|
-
return line.trim();
|
|
418
|
-
}
|
|
419
|
-
const text = await hiddenLine(process.stdin, out, s, "The database administrator's connection string", "Hidden as you type. Your host's admin URL, such as PlanetScale's Connect page gives.");
|
|
420
|
-
if (text === "") throw new SetupError("no connection string given");
|
|
421
|
-
return text;
|
|
422
|
-
}
|
|
423
|
-
function administratorUrl(text) {
|
|
424
|
-
let url;
|
|
425
|
-
try {
|
|
426
|
-
url = new URL(text);
|
|
427
|
-
} catch {
|
|
428
|
-
throw new SetupError("that is not a connection string, such as postgresql://user:password@host:5432/database");
|
|
429
|
-
}
|
|
430
|
-
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") throw new SetupError("coffre setup needs a Postgres connection string, postgresql://…");
|
|
431
|
-
if (url.username === "" || url.hostname === "" || url.pathname.length <= 1) throw new SetupError("the connection string must name a user, a host and a database");
|
|
432
|
-
return url;
|
|
433
|
-
}
|
|
434
|
-
/** A step failed, and said so on its own line. */
|
|
435
|
-
var StepFailed = class extends Error {};
|
|
436
|
-
async function run(administrator, out, questions, options, secrets, clean) {
|
|
437
|
-
const user = decodeURIComponent(administrator.username);
|
|
438
|
-
const where = `${administrator.hostname}${decodeURIComponent(administrator.pathname)}`;
|
|
439
|
-
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
440
|
-
const steps = new Steps(out, [
|
|
441
|
-
`Connect to ${where}`,
|
|
442
|
-
`Check ${user} can create roles`,
|
|
443
|
-
"Make the two logins",
|
|
444
|
-
"Migrate the database",
|
|
445
|
-
"Check each login's rights"
|
|
446
|
-
], questions, clean);
|
|
447
|
-
const step = async (i, work) => {
|
|
448
|
-
try {
|
|
449
|
-
await steps.run(i, work);
|
|
450
|
-
} catch (error) {
|
|
451
|
-
if (error instanceof Cancelled) throw error;
|
|
452
|
-
throw new StepFailed(clean(error));
|
|
453
|
-
}
|
|
454
|
-
};
|
|
455
|
-
const client = new esm_default.Client({
|
|
456
|
-
...postgresConnection(administrator.href),
|
|
457
|
-
application_name: "coffre-setup"
|
|
458
|
-
});
|
|
459
|
-
try {
|
|
460
|
-
await step(0, async () => {
|
|
461
|
-
await client.connect();
|
|
462
|
-
return `Connected to ${where} as ${user}`;
|
|
463
|
-
});
|
|
464
|
-
await step(1, async () => {
|
|
465
|
-
const [self] = (await client.query("SELECT rolsuper, rolcreaterole FROM pg_roles WHERE rolname = current_user")).rows;
|
|
466
|
-
if (!self?.rolsuper && !self?.rolcreaterole) throw new SetupError(`${user} cannot create roles: connect as the database's administrator, which has CREATEROLE`);
|
|
467
|
-
return `${user} can create roles`;
|
|
468
|
-
});
|
|
469
|
-
let logins;
|
|
470
|
-
await step(2, async (progress) => {
|
|
471
|
-
logins = await provision(client, administrator, user, options.resetPasswords, progress.ask, secrets);
|
|
472
|
-
return described(logins);
|
|
473
|
-
});
|
|
474
|
-
await step(3, async (progress) => {
|
|
475
|
-
let plan = {
|
|
476
|
-
applied: 0,
|
|
477
|
-
total: 0
|
|
478
|
-
};
|
|
479
|
-
await migrateDatabase(administrator.href, (planned) => {
|
|
480
|
-
plan = planned;
|
|
481
|
-
const pending = planned.total - planned.applied;
|
|
482
|
-
progress.note(pending === 0 ? "Checking the database is up to date" : `Migrating the database: ${count(pending, "migration")} to apply`);
|
|
483
|
-
});
|
|
484
|
-
const pending = plan.total - plan.applied;
|
|
485
|
-
return pending === 0 ? `The database is up to date, at coffre ${version}'s schema` : `Migrated the database to coffre ${version}'s schema: ${count(pending, "migration")} applied`;
|
|
486
|
-
});
|
|
487
|
-
let used = false;
|
|
488
|
-
await step(4, async () => {
|
|
489
|
-
const details = [];
|
|
490
|
-
for (const component of ["app", "vault"]) {
|
|
491
|
-
const login = logins[component];
|
|
492
|
-
const allowed = login.url === null ? await granted(client, login.role) : await probed(login.url);
|
|
493
|
-
details.push(checked(component, login, allowed));
|
|
494
|
-
}
|
|
495
|
-
const kept = [logins.app, logins.vault].filter((login) => login.url === null);
|
|
496
|
-
if (kept.length > 0) details.push(`${listed(kept.map(({ role }) => role), "and")}: from the catalog, with no new password to log in with`);
|
|
497
|
-
used = (await client.query("SELECT EXISTS (SELECT 1 FROM audit_log) AS used")).rows[0].used;
|
|
498
|
-
return {
|
|
499
|
-
text: "Each login holds only its rights",
|
|
500
|
-
details
|
|
501
|
-
};
|
|
502
|
-
});
|
|
503
|
-
return {
|
|
504
|
-
keys: !used && (logins.app.url !== null || logins.vault.url !== null) ? generateKeys() : null,
|
|
505
|
-
...logins,
|
|
506
|
-
version
|
|
507
|
-
};
|
|
508
|
-
} finally {
|
|
509
|
-
steps.end();
|
|
510
|
-
await client.end().catch(() => {});
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
/**
|
|
514
|
-
* Each runtime login, created with a fresh password, or, if it exists, its
|
|
515
|
-
* password set again when asked to; otherwise left as it is.
|
|
516
|
-
*/
|
|
517
|
-
async function provision(client, administrator, user, resetPasswords, ask, secrets) {
|
|
518
|
-
const existing = new Set((await client.query("SELECT rolname FROM pg_roles WHERE rolname = ANY($1)", [Object.values(ROLES)])).rows.map((row) => row.rolname));
|
|
519
|
-
const reset = resetPasswords || existing.size > 0 && await ask(existing.size === 1 ? `${[...existing][0]} exists already. Set new passwords?` : "Both logins exist already. Set new passwords?");
|
|
520
|
-
const logins = {};
|
|
521
|
-
for (const component of ["app", "vault"]) {
|
|
522
|
-
const role = ROLES[component];
|
|
523
|
-
const login = loginFor(role, user);
|
|
524
|
-
const create = !existing.has(role);
|
|
525
|
-
if (!create && !reset) {
|
|
526
|
-
logins[component] = {
|
|
527
|
-
role,
|
|
528
|
-
login,
|
|
529
|
-
password: "kept",
|
|
530
|
-
url: null
|
|
531
|
-
};
|
|
532
|
-
continue;
|
|
533
|
-
}
|
|
534
|
-
const password = newPassword();
|
|
535
|
-
secrets.push(password);
|
|
536
|
-
const verifier = client.escapeLiteral(scramVerifier(password));
|
|
537
|
-
await client.query(create ? `CREATE ROLE ${client.escapeIdentifier(role)} LOGIN INHERIT NOCREATEDB NOCREATEROLE PASSWORD ${verifier}` : `ALTER ROLE ${client.escapeIdentifier(role)} LOGIN PASSWORD ${verifier}`);
|
|
538
|
-
logins[component] = {
|
|
539
|
-
role,
|
|
540
|
-
login,
|
|
541
|
-
password: create ? "created" : "reset",
|
|
542
|
-
url: loginUrl(administrator, login, password)
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
return logins;
|
|
546
|
-
}
|
|
547
|
-
/** The logins step's line: what happened to each, and what they log in as on PlanetScale. */
|
|
548
|
-
function described(logins) {
|
|
549
|
-
const verbs = {
|
|
550
|
-
created: "Created",
|
|
551
|
-
reset: "Set new passwords for",
|
|
552
|
-
kept: "Kept"
|
|
553
|
-
};
|
|
554
|
-
return {
|
|
555
|
-
text: logins.app.password === logins.vault.password ? `${verbs[logins.app.password]} ${logins.app.role} and ${logins.vault.role}${logins.app.password === "kept" ? ", with their passwords" : ""}` : `${verbs[logins.app.password]} ${logins.app.role}; ${verbs[logins.vault.password].toLowerCase()} ${logins.vault.role}`,
|
|
556
|
-
details: logins.app.login !== logins.app.role ? [`They log in as ${logins.app.login} and ${logins.vault.login}`] : []
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
/** What each login may do that matters, and what it must answer. */
|
|
560
|
-
const BOUNDARY = [
|
|
561
|
-
{
|
|
562
|
-
what: "write members",
|
|
563
|
-
expected: {
|
|
564
|
-
app: false,
|
|
565
|
-
vault: true
|
|
566
|
-
},
|
|
567
|
-
sql: `INSERT INTO vault_members (principal, status, owner, generation, created_at, created_by, status_changed_at, status_changed_by, access_seq, mac)
|
|
568
|
-
VALUES ('user:coffre-setup@probe.invalid', 'active', false, 0, 0, 'system:coffre-setup', 0, 'system:coffre-setup', 0, decode(repeat('00', 32), 'hex'))`,
|
|
569
|
-
privilege: "has_table_privilege($1, 'public.vault_members', 'INSERT')"
|
|
570
|
-
},
|
|
571
|
-
{
|
|
572
|
-
what: "delete log entries",
|
|
573
|
-
expected: {
|
|
574
|
-
app: false,
|
|
575
|
-
vault: false
|
|
576
|
-
},
|
|
577
|
-
sql: "DELETE FROM audit_log",
|
|
578
|
-
privilege: "has_table_privilege($1, 'public.audit_log', 'DELETE')"
|
|
579
|
-
},
|
|
580
|
-
{
|
|
581
|
-
what: "create tables",
|
|
582
|
-
expected: {
|
|
583
|
-
app: false,
|
|
584
|
-
vault: false
|
|
585
|
-
},
|
|
586
|
-
sql: "CREATE TABLE coffre_setup_probe (probe integer)",
|
|
587
|
-
privilege: "has_schema_privilege($1, 'public', 'CREATE') OR has_database_privilege($1, current_database(), 'CREATE')"
|
|
588
|
-
}
|
|
589
|
-
];
|
|
590
|
-
/** Each probe, tried as the login in a transaction rolled back: allowed, or refused for want of a privilege. */
|
|
591
|
-
async function probed(url) {
|
|
592
|
-
const client = new esm_default.Client({
|
|
593
|
-
...postgresConnection(url),
|
|
594
|
-
application_name: "coffre-setup"
|
|
595
|
-
});
|
|
596
|
-
await client.connect();
|
|
597
|
-
try {
|
|
598
|
-
const allowed = [];
|
|
599
|
-
for (const { sql } of BOUNDARY) {
|
|
600
|
-
await client.query("BEGIN");
|
|
601
|
-
try {
|
|
602
|
-
await client.query(sql);
|
|
603
|
-
allowed.push(true);
|
|
604
|
-
} catch (error) {
|
|
605
|
-
if (error.code !== "42501") throw error;
|
|
606
|
-
allowed.push(false);
|
|
607
|
-
} finally {
|
|
608
|
-
await client.query("ROLLBACK");
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
return allowed;
|
|
612
|
-
} finally {
|
|
613
|
-
await client.end();
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
/** The same, from the catalog, for a login whose password this run did not set: the administrator asks for it. */
|
|
617
|
-
async function granted(client, role) {
|
|
618
|
-
const allowed = [];
|
|
619
|
-
for (const { privilege } of BOUNDARY) allowed.push((await client.query(`SELECT ${privilege} AS allowed`, [role])).rows[0].allowed);
|
|
620
|
-
return allowed;
|
|
621
|
-
}
|
|
622
|
-
/** A login's line under the check, or the error that stops setup when its rights are wrong. */
|
|
623
|
-
function checked(component, login, allowed) {
|
|
624
|
-
const wrong = BOUNDARY.flatMap(({ what, expected }, i) => allowed[i] === expected[component] ? [] : [`${allowed[i] ? "can" : "cannot"} ${what}`]);
|
|
625
|
-
if (wrong.length > 0) throw new SetupError(`${login.role} ${wrong.join(", and ")}: the database's privileges are not what coffre needs. Nothing is shown: fix them, then run coffre setup again with --reset-passwords.`);
|
|
626
|
-
const can = BOUNDARY.filter(({ expected }) => expected[component]).map(({ what }) => what);
|
|
627
|
-
const cannot = BOUNDARY.filter(({ expected }) => !expected[component]).map(({ what }) => what);
|
|
628
|
-
return `${login.role.padEnd(20)} ${can.length > 0 ? `can ${listed(can, "and")}; ` : ""}cannot ${listed(cannot, "or")}`;
|
|
629
|
-
}
|
|
630
|
-
/** The values to save, by component: the app's key and URL, the vault's ID, key and URL, as this run made them. */
|
|
631
|
-
function setupValues({ keys, app, vault }) {
|
|
632
|
-
const made = keys === null ? {
|
|
633
|
-
app: [],
|
|
634
|
-
vault: []
|
|
635
|
-
} : keyValues(keys);
|
|
636
|
-
const database = (login, component) => login.url === null ? [] : [{
|
|
637
|
-
label: `${component === "app" ? "App" : "Vault"} database URL`,
|
|
638
|
-
value: login.url,
|
|
639
|
-
mask: "password",
|
|
640
|
-
about: `The ${component}'s own database login. Goes in the ${component}: its Hyperdrive config, or DATABASE_URL.`
|
|
641
|
-
}];
|
|
642
|
-
return {
|
|
643
|
-
app: [...made.app, ...database(app, "app")],
|
|
644
|
-
vault: [...made.vault, ...database(vault, "vault")]
|
|
645
|
-
};
|
|
646
|
-
}
|
|
647
|
-
/** The screen: the values, and where each goes, with the Hyperdrive commands to copy. */
|
|
648
|
-
function setupScreen(result) {
|
|
649
|
-
const { app, vault } = setupValues(result);
|
|
650
|
-
const set = ["app", "vault"].filter((component) => result[component].url !== null);
|
|
651
|
-
const created = set.some((component) => result[component].password === "created");
|
|
652
|
-
const keys = keyGuide();
|
|
653
|
-
const hyperdrive = set.map((component) => {
|
|
654
|
-
return { command: hyperdriveCommand(result[component].password === "created" ? `create ${HYPERDRIVE[component]} --caching-disabled` : `update <the ${component}'s config id>`) };
|
|
655
|
-
});
|
|
656
|
-
const workers = [...set.length === 0 ? [] : [
|
|
657
|
-
`${created ? "A Hyperdrive config for each database URL, with caching off" : "Each Hyperdrive config, pointed at its new database URL"}. Run each command, then paste its URL at the silent prompt: it stays out of your shell's history, and the command drops the URL's parameters, since Hyperdrive connects over TLS itself.`,
|
|
658
|
-
...hyperdrive.flatMap((command, i) => [`The ${set[i]} database URL:`, command]),
|
|
659
|
-
"Or make them in the Cloudflare dashboard, under Hyperdrive.",
|
|
660
|
-
...created ? ["Their ids go under hyperdrive, in app/wrangler.jsonc and vault/wrangler.jsonc."] : []
|
|
661
|
-
], ...result.keys === null ? [] : keys.workers];
|
|
662
|
-
const node = [
|
|
663
|
-
...result.keys === null ? [] : keys.node,
|
|
664
|
-
...set.map((component) => `${component === "app" ? "server.env" : "vault.env"} takes the ${component} database URL, as DATABASE_URL.`),
|
|
665
|
-
"Keep each file readable only by its process (chmod 600)."
|
|
666
|
-
];
|
|
667
|
-
return {
|
|
668
|
-
title: "coffre setup",
|
|
669
|
-
sections: [...app.length > 0 ? [{
|
|
670
|
-
title: "App",
|
|
671
|
-
values: app
|
|
672
|
-
}] : [], ...vault.length > 0 ? [{
|
|
673
|
-
title: "Vault",
|
|
674
|
-
values: vault
|
|
675
|
-
}] : []],
|
|
676
|
-
guide: [{
|
|
677
|
-
title: "On Cloudflare Workers",
|
|
678
|
-
lines: workers
|
|
679
|
-
}, {
|
|
680
|
-
title: "On Node",
|
|
681
|
-
lines: node
|
|
682
|
-
}]
|
|
683
|
-
};
|
|
684
|
-
}
|
|
685
|
-
/** What the values were, by name, for the --json warning. */
|
|
686
|
-
function shownNames(result) {
|
|
687
|
-
const { app, vault } = setupValues(result);
|
|
688
|
-
return [...app, ...vault].filter(({ mask }) => mask !== "none").map(({ label }) => `the ${label.replace(/^[A-Z]/, (c) => c.toLowerCase())}`);
|
|
689
|
-
}
|
|
690
|
-
/** The same values, for a script: each component's, under the examples' names, and what happened to each login. */
|
|
691
|
-
function asJson({ keys, app, vault }) {
|
|
692
|
-
const database = (login) => login.url === null ? {} : { DATABASE_URL: login.url };
|
|
693
|
-
return {
|
|
694
|
-
app: {
|
|
695
|
-
...keys === null ? {} : { APP_KEY: keys.APP_KEY },
|
|
696
|
-
...database(app)
|
|
697
|
-
},
|
|
698
|
-
vault: {
|
|
699
|
-
...keys === null ? {} : {
|
|
700
|
-
VAULT_KEY_ID: keys.VAULT_KEY_ID,
|
|
701
|
-
VAULT_KEY: keys.VAULT_KEY
|
|
702
|
-
},
|
|
703
|
-
...database(vault)
|
|
704
|
-
},
|
|
705
|
-
logins: Object.fromEntries([app, vault].map(({ role, login, password }) => [role, {
|
|
706
|
-
login,
|
|
707
|
-
password
|
|
708
|
-
}]))
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
/** After the screen: what happened, and what comes next, with no secret in it. */
|
|
712
|
-
function summary(result, out) {
|
|
713
|
-
const s = style(out);
|
|
714
|
-
const { app, vault } = setupValues(result);
|
|
715
|
-
const names = [...app, ...vault].filter(({ mask }) => mask !== "none").map(({ label }) => label.replace(/^[A-Z]/, (c) => c.toLowerCase()));
|
|
716
|
-
const created = result.app.password === "created" || result.vault.password === "created";
|
|
717
|
-
const reset = result.app.password === "reset" || result.vault.password === "reset";
|
|
718
|
-
const workers = [...created ? ["a Hyperdrive config for each database URL, with caching off"] : reset ? ["each Hyperdrive config updated with its new database URL"] : [], ...result.keys === null ? [] : ["VAULT_KEY_ID under vars", "APP_KEY and VAULT_KEY as secrets"]];
|
|
719
|
-
const fresh = created || result.keys !== null;
|
|
720
|
-
return [
|
|
721
|
-
"",
|
|
722
|
-
` ${s.green("✓")} ${s.bold(`The ${listed(names, "and")} were shown once.`)}`,
|
|
723
|
-
s.dim(paragraph(out, "They aren't stored anywhere. A lost database URL comes back with coffre setup --reset-passwords; a lost key can't be recovered.", 4)),
|
|
724
|
-
"",
|
|
725
|
-
...result.keys === null ? [] : [row(out, s, "Vault ID", `${result.keys.VAULT_KEY_ID}, not a secret`), ""],
|
|
726
|
-
` ${s.bold("Next")}`,
|
|
727
|
-
row(out, s, "Workers", `${workers.join("; ")}${fresh ? "; then pnpm run deploy" : ", which needs no redeploy"}.`),
|
|
728
|
-
row(out, s, "Node", fresh ? "server.env and vault.env, then pnpm vault and pnpm start." : "each DATABASE_URL updated, then both processes restarted."),
|
|
729
|
-
s.dim(paragraph(out, "After every upgrade of coffre, pnpm migrate. docs/deploy.md has each step.", 4)),
|
|
730
|
-
"",
|
|
731
|
-
""
|
|
732
|
-
].join("\n");
|
|
733
|
-
}
|
|
734
|
-
function nothingToSave(out) {
|
|
735
|
-
const s = style(out);
|
|
736
|
-
return [
|
|
737
|
-
"",
|
|
738
|
-
` ${s.green("✓")} ${s.bold("Nothing new to save.")}`,
|
|
739
|
-
s.dim(paragraph(out, "The logins kept their passwords, and setup makes keys only with new ones, for a database that holds no data yet. coffre setup --reset-passwords sets new passwords.", 4)),
|
|
740
|
-
"",
|
|
741
|
-
""
|
|
742
|
-
].join("\n");
|
|
743
|
-
}
|
|
744
|
-
/** An error's text without any secret in it: a driver may quote what it was given. */
|
|
745
|
-
function redact(text, secrets) {
|
|
746
|
-
return secrets.filter((secret) => secret.length >= 4).reduce((out, secret) => out.split(secret).join("…"), text);
|
|
747
|
-
}
|
|
748
|
-
function count(n, noun) {
|
|
749
|
-
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
750
|
-
}
|
|
751
|
-
/** "a, b or c". */
|
|
752
|
-
function listed(items, last) {
|
|
753
|
-
return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} ${last} ${items.at(-1)}`;
|
|
754
|
-
}
|
|
755
|
-
//#endregion
|
|
756
|
-
export { setup };
|