@coffre/cli 0.1.13 → 0.1.15

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 CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  The `coffre` command: secrets in your shell, and a new deployment.
4
4
 
5
+ The published CLI runs on Node 20 or newer. Developing coffre from its
6
+ TypeScript sources and running a deployment require Node 24.
7
+
5
8
  ```sh
6
9
  npx @coffre/cli init --workers my-coffre # or --node
7
10
  npx @coffre/cli setup # its database, keys and, on Workers, Cloudflare
@@ -1,2 +1,2 @@
1
- import { n as openDatabase, r as postgresConnection, t as engineOfUrl } from "./connect-tekAM4hC.js";
1
+ import { n as openDatabase, r as postgresConnection, t as engineOfUrl } from "./connect-C4wevMJ5.js";
2
2
  export { engineOfUrl, openDatabase, postgresConnection };
@@ -0,0 +1,213 @@
1
+ import { r as hiddenLine } from "./tty-BDa14JX2.js";
2
+ import { r as postgresConnection, t as engineOfUrl } from "./connect-C4wevMJ5.js";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { createHash } from "node:crypto";
6
+ import { readFile } from "node:fs/promises";
7
+ //#region ../db/dist/migrate.js
8
+ /**
9
+ * Bring a database up to date with its engine's migration tree:
10
+ * migrations/postgres or migrations/sqlite. Both trees
11
+ * describe one schema; see schema-parity.test.ts.
12
+ *
13
+ * Applied history must be a prefix of the local journal, byte for byte,
14
+ * before and after. Only one migrator runs at a time: Postgres takes an
15
+ * advisory lock, and SQLite's own write lock covers the
16
+ * whole run. Restricted runtime roles are Postgres-only (baseline/postgres.sql).
17
+ */
18
+ const MIGRATION_LOCK_KEY = "7165058122361679213";
19
+ /** Reassert database privileges that pg_dump and CREATE DATABASE TEMPLATE do not carry. */
20
+ async function restrictDatabase(client) {
21
+ await client.query(`DO $$ BEGIN
22
+ EXECUTE format(
23
+ 'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime',
24
+ current_database()
25
+ );
26
+ END $$`);
27
+ }
28
+ /**
29
+ * Beside this module: src/migrations in the workspace, and dist/migrations
30
+ * in the built package, where the build copies them.
31
+ */
32
+ function migrationsFolder(engine) {
33
+ return fileURLToPath(new URL(`./migrations/${engine}`, import.meta.url));
34
+ }
35
+ async function expectedMigrations(engine) {
36
+ const folder = migrationsFolder(engine);
37
+ const journal = JSON.parse(await readFile(`${folder}/meta/_journal.json`, "utf8"));
38
+ if (!Array.isArray(journal.entries)) throw new Error("invalid Drizzle migration journal");
39
+ const expected = await Promise.all(journal.entries.map(async (entry, position) => {
40
+ 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}`);
41
+ const sqlText = await readFile(`${folder}/${entry.tag}.sql`, "utf8");
42
+ return {
43
+ createdAt: String(entry.when),
44
+ hash: createHash("sha256").update(sqlText).digest("hex"),
45
+ tag: entry.tag
46
+ };
47
+ }));
48
+ 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");
49
+ return expected;
50
+ }
51
+ function verifyHistory(expected, applied, requireComplete) {
52
+ const rows = applied ?? [];
53
+ if (rows.length > expected.length) throw new Error("database contains migrations that are absent from this image");
54
+ if (requireComplete && rows.length !== expected.length) throw new Error(`migration history is incomplete: expected ${expected.length}, found ${rows.length}`);
55
+ rows.forEach((row, index) => {
56
+ const local = expected[index];
57
+ 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`);
58
+ });
59
+ }
60
+ async function postgresMigrator(url) {
61
+ const [{ default: pg }, { drizzle }, { migrate }] = await Promise.all([
62
+ import("./esm-B69mMLcR.js"),
63
+ import("./node-postgres-BWEOOdc6.js"),
64
+ import("./migrator-W1a-ooWi.js")
65
+ ]);
66
+ const pool = new pg.Pool({
67
+ ...postgresConnection(url),
68
+ application_name: "coffre-migrations",
69
+ max: 1
70
+ });
71
+ const client = await pool.connect();
72
+ return {
73
+ async applied() {
74
+ if (!(await client.query("SELECT to_regclass('drizzle.__drizzle_migrations')::text AS relation")).rows[0]?.relation) return null;
75
+ return (await client.query("SELECT hash, created_at::text FROM drizzle.__drizzle_migrations ORDER BY created_at, id")).rows;
76
+ },
77
+ async lock() {
78
+ await client.query("SET lock_timeout = '5min'");
79
+ await client.query("SELECT pg_advisory_lock($1::bigint)", [MIGRATION_LOCK_KEY]);
80
+ },
81
+ async unlock() {
82
+ await client.query("SELECT pg_advisory_unlock($1::bigint)", [MIGRATION_LOCK_KEY]);
83
+ },
84
+ migrate: (migrationsFolder) => migrate(drizzle(client), { migrationsFolder }),
85
+ restrict: () => restrictDatabase(client),
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-k2qEwjqZ.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
+ * Which of this version's migrations a database has applied, and which it
110
+ * has not, without changing it or waiting on a migration under way. A
111
+ * history that is not a prefix of this version's is refused, as
112
+ * `migrateDatabase` refuses it.
113
+ */
114
+ async function migrationStatus(url) {
115
+ const engine = engineOfUrl(url);
116
+ const expected = await expectedMigrations(engine);
117
+ const migrator = await {
118
+ postgres: postgresMigrator,
119
+ sqlite: sqliteMigrator
120
+ }[engine](url);
121
+ try {
122
+ const history = await migrator.applied();
123
+ verifyHistory(expected, history, false);
124
+ const applied = history?.length ?? 0;
125
+ return {
126
+ applied: expected.slice(0, applied).map((entry) => entry.tag),
127
+ pending: expected.slice(applied).map((entry) => entry.tag)
128
+ };
129
+ } finally {
130
+ await migrator.close();
131
+ }
132
+ }
133
+ /**
134
+ * Apply missing migrations, check their history and reassert database
135
+ * privileges. `onPlan` hears what there is to apply before it is, under
136
+ * the migration lock, and may stop the run by throwing: all of it then goes
137
+ * in one call, with no word of each.
138
+ */
139
+ async function migrateDatabase(url, onPlan) {
140
+ const engine = engineOfUrl(url);
141
+ const expected = await expectedMigrations(engine);
142
+ const migrator = await {
143
+ postgres: postgresMigrator,
144
+ sqlite: sqliteMigrator
145
+ }[engine](url);
146
+ let locked = false;
147
+ try {
148
+ await migrator.lock();
149
+ locked = true;
150
+ const applied = await migrator.applied();
151
+ verifyHistory(expected, applied, false);
152
+ const done = applied?.length ?? 0;
153
+ await onPlan?.({
154
+ applied: done,
155
+ total: expected.length,
156
+ pending: expected.slice(done).map((entry) => entry.tag)
157
+ });
158
+ await migrator.migrate(migrationsFolder(engine));
159
+ verifyHistory(expected, await migrator.applied(), true);
160
+ await migrator.restrict();
161
+ } finally {
162
+ try {
163
+ if (locked) await migrator.unlock();
164
+ } finally {
165
+ await migrator.close();
166
+ }
167
+ }
168
+ }
169
+ //#endregion
170
+ //#region src/database-url.ts
171
+ /** Why there is no usable URL, said as it is. */
172
+ var DatabaseUrlError = class extends Error {};
173
+ /**
174
+ * The connection string: from `variable`, from stdin when it is piped, or
175
+ * typed at a hidden prompt. With it, every form of its secrets, as typed and
176
+ * decoded, for the caller to keep out of anything it shows.
177
+ */
178
+ async function readDatabaseUrl(out, s, asking) {
179
+ const given = process.env[asking.variable];
180
+ delete process.env[asking.variable];
181
+ let text;
182
+ if (given !== void 0 && given.trim() !== "") text = given.trim();
183
+ else if (!process.stdin.isTTY) {
184
+ const line = readFileSync(0, "utf8").split("\n").find((candidate) => candidate.trim() !== "");
185
+ if (line === void 0) throw new DatabaseUrlError(`no connection string: pipe it to stdin, or set ${asking.variable}`);
186
+ text = line.trim();
187
+ } else {
188
+ text = await hiddenLine(process.stdin, out, s, asking.question, asking.hint);
189
+ if (text === "") throw new DatabaseUrlError("no connection string given");
190
+ }
191
+ const url = databaseUrl(text, asking.command);
192
+ return {
193
+ url,
194
+ secrets: url.password === "" ? [text] : [
195
+ text,
196
+ url.password,
197
+ decodeURIComponent(url.password)
198
+ ]
199
+ };
200
+ }
201
+ function databaseUrl(text, command) {
202
+ let url;
203
+ try {
204
+ url = new URL(text);
205
+ } catch {
206
+ throw new DatabaseUrlError("that is not a connection string, such as postgresql://user:password@host:5432/database");
207
+ }
208
+ if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") throw new DatabaseUrlError(`${command} needs a Postgres connection string, postgresql://…`);
209
+ if (url.username === "" || url.hostname === "" || url.pathname.length <= 1) throw new DatabaseUrlError("the connection string must name a user, a host and a database");
210
+ return url;
211
+ }
212
+ //#endregion
213
+ export { migrateDatabase as n, migrationStatus as r, readDatabaseUrl as t };