@coffre/cli 0.1.13 → 0.1.14
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/dist/{connect-BXDkiWOL.js → connect-k2qEwjqZ.js} +1 -1
- package/dist/database-url-CJ9yqoi_.js +213 -0
- package/dist/deployment-S8za5nUu.js +1369 -0
- package/dist/init-mO8V087p.js +71 -0
- package/dist/{keys-BCKUEb-Y.js → keys-DDdMwm0N.js} +2 -352
- package/dist/main.js +121 -2
- package/dist/migrate-ERNNgYO0.js +163 -0
- package/dist/{setup-BMmFAR3N.js → setup-qGoLPykX.js} +17 -1810
- package/dist/templates/node/README.md +4 -2
- package/dist/templates/node/package.json +3 -3
- package/dist/templates/workers/README.md +4 -2
- package/dist/templates/workers/package.json +4 -4
- package/dist/tty-BDa14JX2.js +283 -0
- package/dist/update-DwGbG-eM.js +263 -0
- package/dist/version-CPk6zTTK.js +317 -0
- package/package.json +4 -4
- package/dist/{connect-tekAM4hC.js → connect-C4wevMJ5.js} +34 -34
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { a as openTerminal, c as release, d as style, i as listed, t as Cancelled } from "./tty-BDa14JX2.js";
|
|
2
|
+
import { n as migrateDatabase, r as migrationStatus, t as readDatabaseUrl } from "./database-url-CJ9yqoi_.js";
|
|
3
|
+
import { n as StepFailed, r as Steps, t as cliVersion } from "./version-CPk6zTTK.js";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
//#region src/migrate.ts
|
|
6
|
+
/** Where the owner's connection string comes from, when not from a hidden prompt. */
|
|
7
|
+
const URL_VARIABLE = "COFFRE_MIGRATE_DATABASE_URL";
|
|
8
|
+
var MigrateError = class extends Error {};
|
|
9
|
+
/** The migrations a database that has applied `applied` of `known` still lacks. */
|
|
10
|
+
function pendingOf(state) {
|
|
11
|
+
return state.migrations.known.slice(state.migrations.applied);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Why this CLI may not migrate an instance that runs `deployed`: it would
|
|
15
|
+
* apply its own migrations, not the instance's. Null when they agree.
|
|
16
|
+
*/
|
|
17
|
+
function versionProblem(cli, deployed, origin) {
|
|
18
|
+
if (cli === deployed) return null;
|
|
19
|
+
return newer(deployed, cli) ? `${origin} runs coffre ${deployed}, and this CLI is ${cli}: run \`coffre update\` (to ${deployed}), then \`coffre migrate\` again` : `${origin} runs coffre ${deployed}, and this CLI is ${cli}, whose migrations it does not know yet: deploy ${cli} first, or migrate with the CLI it runs, \`npx @coffre/cli@${deployed} migrate\``;
|
|
20
|
+
}
|
|
21
|
+
/** Whether version `a` comes after `b`, by their numbers. */
|
|
22
|
+
function newer(a, b) {
|
|
23
|
+
const parts = (version) => version.split(/[.-]/).map((part) => Number.parseInt(part, 10) || 0);
|
|
24
|
+
const [x, y] = [parts(a), parts(b)];
|
|
25
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0);
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A migration's refusal, as Postgres raised it: the migration's own sentence,
|
|
30
|
+
* not the SQL around it. `0001_remove_syncs` refuses while syncs remain.
|
|
31
|
+
*/
|
|
32
|
+
function migrationFailure(error) {
|
|
33
|
+
for (let cause = error; cause instanceof Error; cause = cause.cause) {
|
|
34
|
+
const code = cause.code;
|
|
35
|
+
if (typeof code === "string" && /^[0-9A-Z]{5}$/.test(code)) return `${cause.message}. Nothing was changed: the migrations run in one transaction.`;
|
|
36
|
+
}
|
|
37
|
+
return error instanceof Error ? error.message : String(error);
|
|
38
|
+
}
|
|
39
|
+
/** `connect` reaches the instance: the current one, or the one `--url` names, with your session there. */
|
|
40
|
+
async function migrate(args, connect) {
|
|
41
|
+
const secrets = [];
|
|
42
|
+
const clean = (error) => redact(error instanceof Error ? error.message : String(error), secrets);
|
|
43
|
+
let options;
|
|
44
|
+
try {
|
|
45
|
+
options = parseOptions(args);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return fail(process.stderr, clean(error));
|
|
48
|
+
}
|
|
49
|
+
const { yes } = options;
|
|
50
|
+
const instance = connect(options.url);
|
|
51
|
+
const terminal = openTerminal();
|
|
52
|
+
const out = terminal?.out ?? process.stderr;
|
|
53
|
+
const s = style(out);
|
|
54
|
+
try {
|
|
55
|
+
const me = await instance.me();
|
|
56
|
+
if (me.instance === null) throw new MigrateError(`${instance.origin} tells only its owners and root admins its version, and you are signed in as ${me.principal.id}: ask one of them to run coffre migrate`);
|
|
57
|
+
const problem = versionProblem(cliVersion(), me.instance.version, instance.origin);
|
|
58
|
+
if (problem !== null) throw new MigrateError(problem);
|
|
59
|
+
const pending = pendingOf(me.instance);
|
|
60
|
+
if (pending.length === 0) {
|
|
61
|
+
out.write(`${s.green("✓")} ${instance.origin}'s database is up to date, at coffre ${me.instance.version}'s schema\n`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!yes && terminal === null) throw new MigrateError("nothing here to confirm on: run coffre migrate on a terminal, or pass --yes to apply without asking");
|
|
65
|
+
if (s.ansi) out.write(`\n ${s.bold("coffre migrate")} ${s.dim(`${instance.origin}, coffre ${me.instance.version}`)}\n\n`);
|
|
66
|
+
out.write(` ${count(pending.length, "migration")} to apply: ${listed(pending, "and")}\n\n`);
|
|
67
|
+
const { url, secrets: typed } = await readDatabaseUrl(out, s, {
|
|
68
|
+
variable: URL_VARIABLE,
|
|
69
|
+
question: "The database owner's connection string",
|
|
70
|
+
hint: "Hidden as you type. The login that owns coffre's tables, direct: not a runtime login, not Hyperdrive.",
|
|
71
|
+
command: "coffre migrate"
|
|
72
|
+
});
|
|
73
|
+
secrets.push(...typed);
|
|
74
|
+
await run(url, instance, pending, out, () => terminal?.keys ?? null, yes, clean);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof Cancelled) return fail(out, "cancelled; nothing was changed", 130);
|
|
77
|
+
if (!(error instanceof StepFailed)) return fail(out, clean(error));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
} finally {
|
|
80
|
+
if (terminal !== null) release(terminal.keys);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The steps: read the database's history and hold it to what the instance
|
|
85
|
+
* reported, so that a URL to another database stops here; show what will be
|
|
86
|
+
* applied and ask; apply it; then ask the instance what it now sees.
|
|
87
|
+
*/
|
|
88
|
+
async function run(url, instance, pending, out, keys, yes, clean) {
|
|
89
|
+
const where = `${url.hostname}${decodeURIComponent(url.pathname)}`;
|
|
90
|
+
const steps = new Steps(out, [
|
|
91
|
+
`Read ${where}'s migrations`,
|
|
92
|
+
`Apply ${listed(pending, "and")}`,
|
|
93
|
+
`Check ${instance.origin} sees them`
|
|
94
|
+
], keys, clean);
|
|
95
|
+
try {
|
|
96
|
+
await steps.run(0, async () => {
|
|
97
|
+
const status = await migrationStatus(url.href);
|
|
98
|
+
if (status.pending.join() !== pending.join()) throw new MigrateError(`${where} lacks ${status.pending.length === 0 ? "no migration" : listed(status.pending, "and")}, but ${instance.origin} reports ${listed(pending, "and")} pending: is this its database? Nothing was changed`);
|
|
99
|
+
return {
|
|
100
|
+
text: `${where} has ${count(status.applied.length, "migration")} applied, the last ${status.applied.at(-1) ?? "none"}`,
|
|
101
|
+
details: []
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
await steps.run(1, async (step) => {
|
|
105
|
+
if (!yes && !await step.ask(`Apply ${count(pending.length, "migration")} to ${where}?`)) throw new Cancelled();
|
|
106
|
+
step.note(`Applying ${listed(pending, "and")}`);
|
|
107
|
+
try {
|
|
108
|
+
await migrateDatabase(url.href, (plan) => {
|
|
109
|
+
if (plan.pending.join() !== pending.join()) throw new MigrateError(`another migration ran meanwhile: ${where} now lacks ${listed(plan.pending, "and") || "nothing"}`);
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
throw new MigrateError(migrationFailure(error));
|
|
113
|
+
}
|
|
114
|
+
return `Applied ${listed(pending, "and")}, and reasserted the database's privileges`;
|
|
115
|
+
});
|
|
116
|
+
await steps.run(2, async () => {
|
|
117
|
+
const after = (await instance.me()).instance;
|
|
118
|
+
const left = after === null ? pending : pendingOf(after);
|
|
119
|
+
if (left.length > 0) throw new MigrateError(`${instance.origin} still reports ${listed(left, "and")} pending: is the URL its database's?`);
|
|
120
|
+
const ready = await instance.ready();
|
|
121
|
+
if (!ready.ok) throw new MigrateError(`${instance.origin} sees the new schema, but is not ready: ` + (ready.heartbeatAgeSeconds === null ? "it has no audit heartbeat yet" : !ready.checkpointed ? "no checkpoint signed by the vault covers its last heartbeat" : `its last heartbeat is ${Math.round(ready.heartbeatAgeSeconds)}s old`) + ". The migration is applied; see /readyz");
|
|
122
|
+
return `${instance.origin} sees the new schema, and is ready`;
|
|
123
|
+
});
|
|
124
|
+
} finally {
|
|
125
|
+
steps.end();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function parseOptions(args) {
|
|
129
|
+
try {
|
|
130
|
+
const { values } = parseArgs({
|
|
131
|
+
args,
|
|
132
|
+
options: {
|
|
133
|
+
yes: {
|
|
134
|
+
type: "boolean",
|
|
135
|
+
default: false
|
|
136
|
+
},
|
|
137
|
+
url: { type: "string" }
|
|
138
|
+
},
|
|
139
|
+
strict: true
|
|
140
|
+
});
|
|
141
|
+
if (values.url !== void 0 && /^postgres(ql)?:/i.test(values.url)) throw new Error("a database URL");
|
|
142
|
+
return {
|
|
143
|
+
yes: values.yes,
|
|
144
|
+
url: values.url
|
|
145
|
+
};
|
|
146
|
+
} catch {
|
|
147
|
+
const leaked = args.some((arg) => /postgres(ql)?:|@/i.test(arg));
|
|
148
|
+
throw new MigrateError(`coffre migrate takes only --yes and --url. It reads the database owner'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." : ""));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function count(n, word) {
|
|
152
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
153
|
+
}
|
|
154
|
+
function redact(text, secrets) {
|
|
155
|
+
return secrets.filter((secret) => secret.length > 0).reduce((shown, secret) => shown.split(secret).join("…"), text);
|
|
156
|
+
}
|
|
157
|
+
function fail(out, message, code = 1) {
|
|
158
|
+
const s = style(out);
|
|
159
|
+
out.write(`${s.red("✗")} ${message}\n`);
|
|
160
|
+
process.exit(code);
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
export { migrate };
|