@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 +3 -0
- package/dist/{connect-BXDkiWOL.js → connect-k2qEwjqZ.js} +1 -1
- package/dist/database-url-CJ9yqoi_.js +213 -0
- package/dist/deployment-Di8BG9qY.js +1456 -0
- package/dist/init-2xGelVG1.js +71 -0
- package/dist/{keys-BCKUEb-Y.js → keys-DDdMwm0N.js} +2 -352
- package/dist/main.js +361 -202
- package/dist/migrate-ERNNgYO0.js +163 -0
- package/dist/{setup-BMmFAR3N.js → setup-Bs8q8Ily.js} +17 -1810
- package/dist/templates/node/README.md +4 -2
- package/dist/templates/node/package.json +4 -3
- package/dist/templates/workers/README.md +4 -2
- package/dist/templates/workers/package.json +5 -4
- package/dist/tty-BDa14JX2.js +283 -0
- package/dist/update-eYzLTNoL.js +334 -0
- package/dist/version-CPk6zTTK.js +317 -0
- package/package.json +5 -5
- package/dist/{connect-tekAM4hC.js → connect-C4wevMJ5.js} +34 -34
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-
|
|
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 };
|