@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
|
@@ -0,0 +1,3341 @@
|
|
|
1
|
+
import { d as esm_default } from "./esm-Bts-esAl.js";
|
|
2
|
+
import { C as yes, E as isEmpty, S as wrap, T as init, _ as select, b as truncate, c as Cancelled, d as listed, f as openTerminal, g as row, h as release, i as keyValues, l as edit, m as readKeys, n as jsonWarning, o as needsTerminal, p as paragraph, r as keyGuide, s as showSecrets, t as generateKeys, u as hiddenLine, v as style, w as KINDS, x as width, y as textLine } from "./keys-BCKUEb-Y.js";
|
|
3
|
+
import { r as postgresConnection, t as engineOfUrl } from "./connect-BONR52al.js";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { createHash, createHmac, pbkdf2Sync, randomBytes } from "node:crypto";
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import { createServer, get } from "node:http";
|
|
12
|
+
//#region ../db/dist/migrate.js
|
|
13
|
+
/**
|
|
14
|
+
* Bring a database up to date with its engine's migration tree:
|
|
15
|
+
* migrations/postgres or migrations/sqlite. Both trees
|
|
16
|
+
* describe one schema; see schema-parity.test.ts.
|
|
17
|
+
*
|
|
18
|
+
* Applied history must be a prefix of the local journal, byte for byte,
|
|
19
|
+
* before and after. Only one migrator runs at a time: Postgres takes an
|
|
20
|
+
* advisory lock, and SQLite's own write lock covers the
|
|
21
|
+
* whole run. Restricted runtime roles are Postgres-only (baseline/postgres.sql).
|
|
22
|
+
*/
|
|
23
|
+
const MIGRATION_LOCK_KEY = "7165058122361679213";
|
|
24
|
+
/** Reassert database privileges that pg_dump and CREATE DATABASE TEMPLATE do not carry. */
|
|
25
|
+
async function restrictDatabase(client) {
|
|
26
|
+
await client.query(`DO $$ BEGIN
|
|
27
|
+
EXECUTE format(
|
|
28
|
+
'REVOKE CREATE, TEMPORARY ON DATABASE %I FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime',
|
|
29
|
+
current_database()
|
|
30
|
+
);
|
|
31
|
+
END $$`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Beside this module: src/migrations in the workspace, and dist/migrations
|
|
35
|
+
* in the built package, where the build copies them.
|
|
36
|
+
*/
|
|
37
|
+
function migrationsFolder(engine) {
|
|
38
|
+
return fileURLToPath(new URL(`./migrations/${engine}`, import.meta.url));
|
|
39
|
+
}
|
|
40
|
+
async function expectedMigrations(engine) {
|
|
41
|
+
const folder = migrationsFolder(engine);
|
|
42
|
+
const journal = JSON.parse(await readFile(`${folder}/meta/_journal.json`, "utf8"));
|
|
43
|
+
if (!Array.isArray(journal.entries)) throw new Error("invalid Drizzle migration journal");
|
|
44
|
+
const expected = await Promise.all(journal.entries.map(async (entry, position) => {
|
|
45
|
+
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}`);
|
|
46
|
+
const sqlText = await readFile(`${folder}/${entry.tag}.sql`, "utf8");
|
|
47
|
+
return {
|
|
48
|
+
createdAt: String(entry.when),
|
|
49
|
+
hash: createHash("sha256").update(sqlText).digest("hex"),
|
|
50
|
+
tag: entry.tag
|
|
51
|
+
};
|
|
52
|
+
}));
|
|
53
|
+
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");
|
|
54
|
+
return expected;
|
|
55
|
+
}
|
|
56
|
+
function verifyHistory(expected, applied, requireComplete) {
|
|
57
|
+
const rows = applied ?? [];
|
|
58
|
+
if (rows.length > expected.length) throw new Error("database contains migrations that are absent from this image");
|
|
59
|
+
if (requireComplete && rows.length !== expected.length) throw new Error(`migration history is incomplete: expected ${expected.length}, found ${rows.length}`);
|
|
60
|
+
rows.forEach((row, index) => {
|
|
61
|
+
const local = expected[index];
|
|
62
|
+
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`);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async function postgresMigrator(url) {
|
|
66
|
+
const [{ default: pg }, { drizzle }, { migrate }] = await Promise.all([
|
|
67
|
+
import("./esm-B69mMLcR.js"),
|
|
68
|
+
import("./node-postgres-BlNvYfq_.js"),
|
|
69
|
+
import("./migrator-W1a-ooWi.js")
|
|
70
|
+
]);
|
|
71
|
+
const pool = new pg.Pool({
|
|
72
|
+
...postgresConnection(url),
|
|
73
|
+
application_name: "coffre-migrations",
|
|
74
|
+
max: 1
|
|
75
|
+
});
|
|
76
|
+
const client = await pool.connect();
|
|
77
|
+
return {
|
|
78
|
+
async applied() {
|
|
79
|
+
if (!(await client.query("SELECT to_regclass('drizzle.__drizzle_migrations')::text AS relation")).rows[0]?.relation) return null;
|
|
80
|
+
return (await client.query("SELECT hash, created_at::text FROM drizzle.__drizzle_migrations ORDER BY created_at, id")).rows;
|
|
81
|
+
},
|
|
82
|
+
async lock() {
|
|
83
|
+
await client.query("SET lock_timeout = '5min'");
|
|
84
|
+
await client.query("SELECT pg_advisory_lock($1::bigint)", [MIGRATION_LOCK_KEY]);
|
|
85
|
+
},
|
|
86
|
+
async unlock() {
|
|
87
|
+
await client.query("SELECT pg_advisory_unlock($1::bigint)", [MIGRATION_LOCK_KEY]);
|
|
88
|
+
},
|
|
89
|
+
migrate: (migrationsFolder) => migrate(drizzle(client), { migrationsFolder }),
|
|
90
|
+
restrict: () => restrictDatabase(client),
|
|
91
|
+
async close() {
|
|
92
|
+
client.release();
|
|
93
|
+
await pool.end();
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function sqliteMigrator(url) {
|
|
98
|
+
const [{ openDatabase }, { migrate }] = await Promise.all([import("./connect-BZdPYNbZ.js"), import("drizzle-orm/libsql/migrator")]);
|
|
99
|
+
const { db, close } = await openDatabase(url);
|
|
100
|
+
const sqlite = db;
|
|
101
|
+
return {
|
|
102
|
+
async applied() {
|
|
103
|
+
if ((await sqlite.all("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'")).length === 0) return null;
|
|
104
|
+
return sqlite.all("SELECT hash, CAST(created_at AS TEXT) AS created_at FROM __drizzle_migrations ORDER BY created_at, id");
|
|
105
|
+
},
|
|
106
|
+
async lock() {},
|
|
107
|
+
async unlock() {},
|
|
108
|
+
migrate: (migrationsFolder) => migrate(sqlite, { migrationsFolder }),
|
|
109
|
+
async restrict() {},
|
|
110
|
+
close
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Apply missing migrations, check their history and reassert database
|
|
115
|
+
* privileges. `onPlan` hears how many there are to apply before they are:
|
|
116
|
+
* all of them go in one call, with no word of each.
|
|
117
|
+
*/
|
|
118
|
+
async function migrateDatabase(url, onPlan) {
|
|
119
|
+
const engine = engineOfUrl(url);
|
|
120
|
+
const expected = await expectedMigrations(engine);
|
|
121
|
+
const migrator = await {
|
|
122
|
+
postgres: postgresMigrator,
|
|
123
|
+
sqlite: sqliteMigrator
|
|
124
|
+
}[engine](url);
|
|
125
|
+
let locked = false;
|
|
126
|
+
try {
|
|
127
|
+
await migrator.lock();
|
|
128
|
+
locked = true;
|
|
129
|
+
const applied = await migrator.applied();
|
|
130
|
+
verifyHistory(expected, applied, false);
|
|
131
|
+
onPlan?.({
|
|
132
|
+
applied: applied?.length ?? 0,
|
|
133
|
+
total: expected.length
|
|
134
|
+
});
|
|
135
|
+
await migrator.migrate(migrationsFolder(engine));
|
|
136
|
+
verifyHistory(expected, await migrator.applied(), true);
|
|
137
|
+
await migrator.restrict();
|
|
138
|
+
} finally {
|
|
139
|
+
try {
|
|
140
|
+
if (locked) await migrator.unlock();
|
|
141
|
+
} finally {
|
|
142
|
+
await migrator.close();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region ../../node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/scanner.js
|
|
148
|
+
/**
|
|
149
|
+
* Creates a JSON scanner on the given text.
|
|
150
|
+
* If ignoreTrivia is set, whitespaces or comments are ignored.
|
|
151
|
+
*/
|
|
152
|
+
function createScanner(text, ignoreTrivia = false) {
|
|
153
|
+
const len = text.length;
|
|
154
|
+
let pos = 0, value = "", tokenOffset = 0, token = 16, lineNumber = 0, lineStartOffset = 0, tokenLineStartOffset = 0, prevTokenLineStartOffset = 0, scanError = 0;
|
|
155
|
+
function scanHexDigits(count, exact) {
|
|
156
|
+
let digits = 0;
|
|
157
|
+
let value = 0;
|
|
158
|
+
while (digits < count || !exact) {
|
|
159
|
+
let ch = text.charCodeAt(pos);
|
|
160
|
+
if (ch >= 48 && ch <= 57) value = value * 16 + ch - 48;
|
|
161
|
+
else if (ch >= 65 && ch <= 70) value = value * 16 + ch - 65 + 10;
|
|
162
|
+
else if (ch >= 97 && ch <= 102) value = value * 16 + ch - 97 + 10;
|
|
163
|
+
else break;
|
|
164
|
+
pos++;
|
|
165
|
+
digits++;
|
|
166
|
+
}
|
|
167
|
+
if (digits < count) value = -1;
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
function setPosition(newPosition) {
|
|
171
|
+
pos = newPosition;
|
|
172
|
+
value = "";
|
|
173
|
+
tokenOffset = 0;
|
|
174
|
+
token = 16;
|
|
175
|
+
scanError = 0;
|
|
176
|
+
}
|
|
177
|
+
function scanNumber() {
|
|
178
|
+
let start = pos;
|
|
179
|
+
if (text.charCodeAt(pos) === 48) pos++;
|
|
180
|
+
else {
|
|
181
|
+
pos++;
|
|
182
|
+
while (pos < text.length && isDigit(text.charCodeAt(pos))) pos++;
|
|
183
|
+
}
|
|
184
|
+
if (pos < text.length && text.charCodeAt(pos) === 46) {
|
|
185
|
+
pos++;
|
|
186
|
+
if (pos < text.length && isDigit(text.charCodeAt(pos))) {
|
|
187
|
+
pos++;
|
|
188
|
+
while (pos < text.length && isDigit(text.charCodeAt(pos))) pos++;
|
|
189
|
+
} else {
|
|
190
|
+
scanError = 3;
|
|
191
|
+
return text.substring(start, pos);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
let end = pos;
|
|
195
|
+
if (pos < text.length && (text.charCodeAt(pos) === 69 || text.charCodeAt(pos) === 101)) {
|
|
196
|
+
pos++;
|
|
197
|
+
if (pos < text.length && text.charCodeAt(pos) === 43 || text.charCodeAt(pos) === 45) pos++;
|
|
198
|
+
if (pos < text.length && isDigit(text.charCodeAt(pos))) {
|
|
199
|
+
pos++;
|
|
200
|
+
while (pos < text.length && isDigit(text.charCodeAt(pos))) pos++;
|
|
201
|
+
end = pos;
|
|
202
|
+
} else scanError = 3;
|
|
203
|
+
}
|
|
204
|
+
return text.substring(start, end);
|
|
205
|
+
}
|
|
206
|
+
function scanString() {
|
|
207
|
+
let result = "", start = pos;
|
|
208
|
+
while (true) {
|
|
209
|
+
if (pos >= len) {
|
|
210
|
+
result += text.substring(start, pos);
|
|
211
|
+
scanError = 2;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
const ch = text.charCodeAt(pos);
|
|
215
|
+
if (ch === 34) {
|
|
216
|
+
result += text.substring(start, pos);
|
|
217
|
+
pos++;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
if (ch === 92) {
|
|
221
|
+
result += text.substring(start, pos);
|
|
222
|
+
pos++;
|
|
223
|
+
if (pos >= len) {
|
|
224
|
+
scanError = 2;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
switch (text.charCodeAt(pos++)) {
|
|
228
|
+
case 34:
|
|
229
|
+
result += "\"";
|
|
230
|
+
break;
|
|
231
|
+
case 92:
|
|
232
|
+
result += "\\";
|
|
233
|
+
break;
|
|
234
|
+
case 47:
|
|
235
|
+
result += "/";
|
|
236
|
+
break;
|
|
237
|
+
case 98:
|
|
238
|
+
result += "\b";
|
|
239
|
+
break;
|
|
240
|
+
case 102:
|
|
241
|
+
result += "\f";
|
|
242
|
+
break;
|
|
243
|
+
case 110:
|
|
244
|
+
result += "\n";
|
|
245
|
+
break;
|
|
246
|
+
case 114:
|
|
247
|
+
result += "\r";
|
|
248
|
+
break;
|
|
249
|
+
case 116:
|
|
250
|
+
result += " ";
|
|
251
|
+
break;
|
|
252
|
+
case 117:
|
|
253
|
+
const ch3 = scanHexDigits(4, true);
|
|
254
|
+
if (ch3 >= 0) result += String.fromCharCode(ch3);
|
|
255
|
+
else scanError = 4;
|
|
256
|
+
break;
|
|
257
|
+
default: scanError = 5;
|
|
258
|
+
}
|
|
259
|
+
start = pos;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (ch >= 0 && ch <= 31) {
|
|
263
|
+
if (isLineBreak(ch)) {
|
|
264
|
+
result += text.substring(start, pos);
|
|
265
|
+
scanError = 2;
|
|
266
|
+
break;
|
|
267
|
+
} else scanError = 6;
|
|
268
|
+
}
|
|
269
|
+
pos++;
|
|
270
|
+
}
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
function scanNext() {
|
|
274
|
+
value = "";
|
|
275
|
+
scanError = 0;
|
|
276
|
+
tokenOffset = pos;
|
|
277
|
+
lineStartOffset = lineNumber;
|
|
278
|
+
prevTokenLineStartOffset = tokenLineStartOffset;
|
|
279
|
+
if (pos >= len) {
|
|
280
|
+
tokenOffset = len;
|
|
281
|
+
return token = 17;
|
|
282
|
+
}
|
|
283
|
+
let code = text.charCodeAt(pos);
|
|
284
|
+
if (isWhiteSpace(code)) {
|
|
285
|
+
do {
|
|
286
|
+
pos++;
|
|
287
|
+
value += String.fromCharCode(code);
|
|
288
|
+
code = text.charCodeAt(pos);
|
|
289
|
+
} while (isWhiteSpace(code));
|
|
290
|
+
return token = 15;
|
|
291
|
+
}
|
|
292
|
+
if (isLineBreak(code)) {
|
|
293
|
+
pos++;
|
|
294
|
+
value += String.fromCharCode(code);
|
|
295
|
+
if (code === 13 && text.charCodeAt(pos) === 10) {
|
|
296
|
+
pos++;
|
|
297
|
+
value += "\n";
|
|
298
|
+
}
|
|
299
|
+
lineNumber++;
|
|
300
|
+
tokenLineStartOffset = pos;
|
|
301
|
+
return token = 14;
|
|
302
|
+
}
|
|
303
|
+
switch (code) {
|
|
304
|
+
case 123:
|
|
305
|
+
pos++;
|
|
306
|
+
return token = 1;
|
|
307
|
+
case 125:
|
|
308
|
+
pos++;
|
|
309
|
+
return token = 2;
|
|
310
|
+
case 91:
|
|
311
|
+
pos++;
|
|
312
|
+
return token = 3;
|
|
313
|
+
case 93:
|
|
314
|
+
pos++;
|
|
315
|
+
return token = 4;
|
|
316
|
+
case 58:
|
|
317
|
+
pos++;
|
|
318
|
+
return token = 6;
|
|
319
|
+
case 44:
|
|
320
|
+
pos++;
|
|
321
|
+
return token = 5;
|
|
322
|
+
case 34:
|
|
323
|
+
pos++;
|
|
324
|
+
value = scanString();
|
|
325
|
+
return token = 10;
|
|
326
|
+
case 47:
|
|
327
|
+
const start = pos - 1;
|
|
328
|
+
if (text.charCodeAt(pos + 1) === 47) {
|
|
329
|
+
pos += 2;
|
|
330
|
+
while (pos < len) {
|
|
331
|
+
if (isLineBreak(text.charCodeAt(pos))) break;
|
|
332
|
+
pos++;
|
|
333
|
+
}
|
|
334
|
+
value = text.substring(start, pos);
|
|
335
|
+
return token = 12;
|
|
336
|
+
}
|
|
337
|
+
if (text.charCodeAt(pos + 1) === 42) {
|
|
338
|
+
pos += 2;
|
|
339
|
+
const safeLength = len - 1;
|
|
340
|
+
let commentClosed = false;
|
|
341
|
+
while (pos < safeLength) {
|
|
342
|
+
const ch = text.charCodeAt(pos);
|
|
343
|
+
if (ch === 42 && text.charCodeAt(pos + 1) === 47) {
|
|
344
|
+
pos += 2;
|
|
345
|
+
commentClosed = true;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
pos++;
|
|
349
|
+
if (isLineBreak(ch)) {
|
|
350
|
+
if (ch === 13 && text.charCodeAt(pos) === 10) pos++;
|
|
351
|
+
lineNumber++;
|
|
352
|
+
tokenLineStartOffset = pos;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!commentClosed) {
|
|
356
|
+
pos++;
|
|
357
|
+
scanError = 1;
|
|
358
|
+
}
|
|
359
|
+
value = text.substring(start, pos);
|
|
360
|
+
return token = 13;
|
|
361
|
+
}
|
|
362
|
+
value += String.fromCharCode(code);
|
|
363
|
+
pos++;
|
|
364
|
+
return token = 16;
|
|
365
|
+
case 45:
|
|
366
|
+
value += String.fromCharCode(code);
|
|
367
|
+
pos++;
|
|
368
|
+
if (pos === len || !isDigit(text.charCodeAt(pos))) return token = 16;
|
|
369
|
+
case 48:
|
|
370
|
+
case 49:
|
|
371
|
+
case 50:
|
|
372
|
+
case 51:
|
|
373
|
+
case 52:
|
|
374
|
+
case 53:
|
|
375
|
+
case 54:
|
|
376
|
+
case 55:
|
|
377
|
+
case 56:
|
|
378
|
+
case 57:
|
|
379
|
+
value += scanNumber();
|
|
380
|
+
return token = 11;
|
|
381
|
+
default:
|
|
382
|
+
while (pos < len && isUnknownContentCharacter(code)) {
|
|
383
|
+
pos++;
|
|
384
|
+
code = text.charCodeAt(pos);
|
|
385
|
+
}
|
|
386
|
+
if (tokenOffset !== pos) {
|
|
387
|
+
value = text.substring(tokenOffset, pos);
|
|
388
|
+
switch (value) {
|
|
389
|
+
case "true": return token = 8;
|
|
390
|
+
case "false": return token = 9;
|
|
391
|
+
case "null": return token = 7;
|
|
392
|
+
}
|
|
393
|
+
return token = 16;
|
|
394
|
+
}
|
|
395
|
+
value += String.fromCharCode(code);
|
|
396
|
+
pos++;
|
|
397
|
+
return token = 16;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function isUnknownContentCharacter(code) {
|
|
401
|
+
if (isWhiteSpace(code) || isLineBreak(code)) return false;
|
|
402
|
+
switch (code) {
|
|
403
|
+
case 125:
|
|
404
|
+
case 93:
|
|
405
|
+
case 123:
|
|
406
|
+
case 91:
|
|
407
|
+
case 34:
|
|
408
|
+
case 58:
|
|
409
|
+
case 44:
|
|
410
|
+
case 47: return false;
|
|
411
|
+
}
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
function scanNextNonTrivia() {
|
|
415
|
+
let result;
|
|
416
|
+
do
|
|
417
|
+
result = scanNext();
|
|
418
|
+
while (result >= 12 && result <= 15);
|
|
419
|
+
return result;
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
setPosition,
|
|
423
|
+
getPosition: () => pos,
|
|
424
|
+
scan: ignoreTrivia ? scanNextNonTrivia : scanNext,
|
|
425
|
+
getToken: () => token,
|
|
426
|
+
getTokenValue: () => value,
|
|
427
|
+
getTokenOffset: () => tokenOffset,
|
|
428
|
+
getTokenLength: () => pos - tokenOffset,
|
|
429
|
+
getTokenStartLine: () => lineStartOffset,
|
|
430
|
+
getTokenStartCharacter: () => tokenOffset - prevTokenLineStartOffset,
|
|
431
|
+
getTokenError: () => scanError
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function isWhiteSpace(ch) {
|
|
435
|
+
return ch === 32 || ch === 9;
|
|
436
|
+
}
|
|
437
|
+
function isLineBreak(ch) {
|
|
438
|
+
return ch === 10 || ch === 13;
|
|
439
|
+
}
|
|
440
|
+
function isDigit(ch) {
|
|
441
|
+
return ch >= 48 && ch <= 57;
|
|
442
|
+
}
|
|
443
|
+
var CharacterCodes;
|
|
444
|
+
(function(CharacterCodes) {
|
|
445
|
+
CharacterCodes[CharacterCodes["lineFeed"] = 10] = "lineFeed";
|
|
446
|
+
CharacterCodes[CharacterCodes["carriageReturn"] = 13] = "carriageReturn";
|
|
447
|
+
CharacterCodes[CharacterCodes["space"] = 32] = "space";
|
|
448
|
+
CharacterCodes[CharacterCodes["_0"] = 48] = "_0";
|
|
449
|
+
CharacterCodes[CharacterCodes["_1"] = 49] = "_1";
|
|
450
|
+
CharacterCodes[CharacterCodes["_2"] = 50] = "_2";
|
|
451
|
+
CharacterCodes[CharacterCodes["_3"] = 51] = "_3";
|
|
452
|
+
CharacterCodes[CharacterCodes["_4"] = 52] = "_4";
|
|
453
|
+
CharacterCodes[CharacterCodes["_5"] = 53] = "_5";
|
|
454
|
+
CharacterCodes[CharacterCodes["_6"] = 54] = "_6";
|
|
455
|
+
CharacterCodes[CharacterCodes["_7"] = 55] = "_7";
|
|
456
|
+
CharacterCodes[CharacterCodes["_8"] = 56] = "_8";
|
|
457
|
+
CharacterCodes[CharacterCodes["_9"] = 57] = "_9";
|
|
458
|
+
CharacterCodes[CharacterCodes["a"] = 97] = "a";
|
|
459
|
+
CharacterCodes[CharacterCodes["b"] = 98] = "b";
|
|
460
|
+
CharacterCodes[CharacterCodes["c"] = 99] = "c";
|
|
461
|
+
CharacterCodes[CharacterCodes["d"] = 100] = "d";
|
|
462
|
+
CharacterCodes[CharacterCodes["e"] = 101] = "e";
|
|
463
|
+
CharacterCodes[CharacterCodes["f"] = 102] = "f";
|
|
464
|
+
CharacterCodes[CharacterCodes["g"] = 103] = "g";
|
|
465
|
+
CharacterCodes[CharacterCodes["h"] = 104] = "h";
|
|
466
|
+
CharacterCodes[CharacterCodes["i"] = 105] = "i";
|
|
467
|
+
CharacterCodes[CharacterCodes["j"] = 106] = "j";
|
|
468
|
+
CharacterCodes[CharacterCodes["k"] = 107] = "k";
|
|
469
|
+
CharacterCodes[CharacterCodes["l"] = 108] = "l";
|
|
470
|
+
CharacterCodes[CharacterCodes["m"] = 109] = "m";
|
|
471
|
+
CharacterCodes[CharacterCodes["n"] = 110] = "n";
|
|
472
|
+
CharacterCodes[CharacterCodes["o"] = 111] = "o";
|
|
473
|
+
CharacterCodes[CharacterCodes["p"] = 112] = "p";
|
|
474
|
+
CharacterCodes[CharacterCodes["q"] = 113] = "q";
|
|
475
|
+
CharacterCodes[CharacterCodes["r"] = 114] = "r";
|
|
476
|
+
CharacterCodes[CharacterCodes["s"] = 115] = "s";
|
|
477
|
+
CharacterCodes[CharacterCodes["t"] = 116] = "t";
|
|
478
|
+
CharacterCodes[CharacterCodes["u"] = 117] = "u";
|
|
479
|
+
CharacterCodes[CharacterCodes["v"] = 118] = "v";
|
|
480
|
+
CharacterCodes[CharacterCodes["w"] = 119] = "w";
|
|
481
|
+
CharacterCodes[CharacterCodes["x"] = 120] = "x";
|
|
482
|
+
CharacterCodes[CharacterCodes["y"] = 121] = "y";
|
|
483
|
+
CharacterCodes[CharacterCodes["z"] = 122] = "z";
|
|
484
|
+
CharacterCodes[CharacterCodes["A"] = 65] = "A";
|
|
485
|
+
CharacterCodes[CharacterCodes["B"] = 66] = "B";
|
|
486
|
+
CharacterCodes[CharacterCodes["C"] = 67] = "C";
|
|
487
|
+
CharacterCodes[CharacterCodes["D"] = 68] = "D";
|
|
488
|
+
CharacterCodes[CharacterCodes["E"] = 69] = "E";
|
|
489
|
+
CharacterCodes[CharacterCodes["F"] = 70] = "F";
|
|
490
|
+
CharacterCodes[CharacterCodes["G"] = 71] = "G";
|
|
491
|
+
CharacterCodes[CharacterCodes["H"] = 72] = "H";
|
|
492
|
+
CharacterCodes[CharacterCodes["I"] = 73] = "I";
|
|
493
|
+
CharacterCodes[CharacterCodes["J"] = 74] = "J";
|
|
494
|
+
CharacterCodes[CharacterCodes["K"] = 75] = "K";
|
|
495
|
+
CharacterCodes[CharacterCodes["L"] = 76] = "L";
|
|
496
|
+
CharacterCodes[CharacterCodes["M"] = 77] = "M";
|
|
497
|
+
CharacterCodes[CharacterCodes["N"] = 78] = "N";
|
|
498
|
+
CharacterCodes[CharacterCodes["O"] = 79] = "O";
|
|
499
|
+
CharacterCodes[CharacterCodes["P"] = 80] = "P";
|
|
500
|
+
CharacterCodes[CharacterCodes["Q"] = 81] = "Q";
|
|
501
|
+
CharacterCodes[CharacterCodes["R"] = 82] = "R";
|
|
502
|
+
CharacterCodes[CharacterCodes["S"] = 83] = "S";
|
|
503
|
+
CharacterCodes[CharacterCodes["T"] = 84] = "T";
|
|
504
|
+
CharacterCodes[CharacterCodes["U"] = 85] = "U";
|
|
505
|
+
CharacterCodes[CharacterCodes["V"] = 86] = "V";
|
|
506
|
+
CharacterCodes[CharacterCodes["W"] = 87] = "W";
|
|
507
|
+
CharacterCodes[CharacterCodes["X"] = 88] = "X";
|
|
508
|
+
CharacterCodes[CharacterCodes["Y"] = 89] = "Y";
|
|
509
|
+
CharacterCodes[CharacterCodes["Z"] = 90] = "Z";
|
|
510
|
+
CharacterCodes[CharacterCodes["asterisk"] = 42] = "asterisk";
|
|
511
|
+
CharacterCodes[CharacterCodes["backslash"] = 92] = "backslash";
|
|
512
|
+
CharacterCodes[CharacterCodes["closeBrace"] = 125] = "closeBrace";
|
|
513
|
+
CharacterCodes[CharacterCodes["closeBracket"] = 93] = "closeBracket";
|
|
514
|
+
CharacterCodes[CharacterCodes["colon"] = 58] = "colon";
|
|
515
|
+
CharacterCodes[CharacterCodes["comma"] = 44] = "comma";
|
|
516
|
+
CharacterCodes[CharacterCodes["dot"] = 46] = "dot";
|
|
517
|
+
CharacterCodes[CharacterCodes["doubleQuote"] = 34] = "doubleQuote";
|
|
518
|
+
CharacterCodes[CharacterCodes["minus"] = 45] = "minus";
|
|
519
|
+
CharacterCodes[CharacterCodes["openBrace"] = 123] = "openBrace";
|
|
520
|
+
CharacterCodes[CharacterCodes["openBracket"] = 91] = "openBracket";
|
|
521
|
+
CharacterCodes[CharacterCodes["plus"] = 43] = "plus";
|
|
522
|
+
CharacterCodes[CharacterCodes["slash"] = 47] = "slash";
|
|
523
|
+
CharacterCodes[CharacterCodes["formFeed"] = 12] = "formFeed";
|
|
524
|
+
CharacterCodes[CharacterCodes["tab"] = 9] = "tab";
|
|
525
|
+
})(CharacterCodes || (CharacterCodes = {}));
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region ../../node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/string-intern.js
|
|
528
|
+
const cachedSpaces = new Array(20).fill(0).map((_, index) => {
|
|
529
|
+
return " ".repeat(index);
|
|
530
|
+
});
|
|
531
|
+
const maxCachedValues = 200;
|
|
532
|
+
const cachedBreakLinesWithSpaces = {
|
|
533
|
+
" ": {
|
|
534
|
+
"\n": new Array(maxCachedValues).fill(0).map((_, index) => {
|
|
535
|
+
return "\n" + " ".repeat(index);
|
|
536
|
+
}),
|
|
537
|
+
"\r": new Array(maxCachedValues).fill(0).map((_, index) => {
|
|
538
|
+
return "\r" + " ".repeat(index);
|
|
539
|
+
}),
|
|
540
|
+
"\r\n": new Array(maxCachedValues).fill(0).map((_, index) => {
|
|
541
|
+
return "\r\n" + " ".repeat(index);
|
|
542
|
+
})
|
|
543
|
+
},
|
|
544
|
+
" ": {
|
|
545
|
+
"\n": new Array(maxCachedValues).fill(0).map((_, index) => {
|
|
546
|
+
return "\n" + " ".repeat(index);
|
|
547
|
+
}),
|
|
548
|
+
"\r": new Array(maxCachedValues).fill(0).map((_, index) => {
|
|
549
|
+
return "\r" + " ".repeat(index);
|
|
550
|
+
}),
|
|
551
|
+
"\r\n": new Array(maxCachedValues).fill(0).map((_, index) => {
|
|
552
|
+
return "\r\n" + " ".repeat(index);
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
const supportedEols = [
|
|
557
|
+
"\n",
|
|
558
|
+
"\r",
|
|
559
|
+
"\r\n"
|
|
560
|
+
];
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region ../../node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/format.js
|
|
563
|
+
function format(documentText, range, options) {
|
|
564
|
+
let initialIndentLevel;
|
|
565
|
+
let formatText;
|
|
566
|
+
let formatTextStart;
|
|
567
|
+
let rangeStart;
|
|
568
|
+
let rangeEnd;
|
|
569
|
+
if (range) {
|
|
570
|
+
rangeStart = range.offset;
|
|
571
|
+
rangeEnd = rangeStart + range.length;
|
|
572
|
+
formatTextStart = rangeStart;
|
|
573
|
+
while (formatTextStart > 0 && !isEOL(documentText, formatTextStart - 1)) formatTextStart--;
|
|
574
|
+
let endOffset = rangeEnd;
|
|
575
|
+
while (endOffset < documentText.length && !isEOL(documentText, endOffset)) endOffset++;
|
|
576
|
+
formatText = documentText.substring(formatTextStart, endOffset);
|
|
577
|
+
initialIndentLevel = computeIndentLevel(formatText, options);
|
|
578
|
+
} else {
|
|
579
|
+
formatText = documentText;
|
|
580
|
+
initialIndentLevel = 0;
|
|
581
|
+
formatTextStart = 0;
|
|
582
|
+
rangeStart = 0;
|
|
583
|
+
rangeEnd = documentText.length;
|
|
584
|
+
}
|
|
585
|
+
const eol = getEOL(options, documentText);
|
|
586
|
+
const eolFastPathSupported = supportedEols.includes(eol);
|
|
587
|
+
let numberLineBreaks = 0;
|
|
588
|
+
let indentLevel = 0;
|
|
589
|
+
let indentValue;
|
|
590
|
+
if (options.insertSpaces) indentValue = cachedSpaces[options.tabSize || 4] ?? repeat(cachedSpaces[1], options.tabSize || 4);
|
|
591
|
+
else indentValue = " ";
|
|
592
|
+
const indentType = indentValue === " " ? " " : " ";
|
|
593
|
+
let scanner = createScanner(formatText, false);
|
|
594
|
+
let hasError = false;
|
|
595
|
+
function newLinesAndIndent() {
|
|
596
|
+
if (numberLineBreaks > 1) return repeat(eol, numberLineBreaks) + repeat(indentValue, initialIndentLevel + indentLevel);
|
|
597
|
+
const amountOfSpaces = indentValue.length * (initialIndentLevel + indentLevel);
|
|
598
|
+
if (!eolFastPathSupported || amountOfSpaces > cachedBreakLinesWithSpaces[indentType][eol].length) return eol + repeat(indentValue, initialIndentLevel + indentLevel);
|
|
599
|
+
if (amountOfSpaces <= 0) return eol;
|
|
600
|
+
return cachedBreakLinesWithSpaces[indentType][eol][amountOfSpaces];
|
|
601
|
+
}
|
|
602
|
+
function scanNext() {
|
|
603
|
+
let token = scanner.scan();
|
|
604
|
+
numberLineBreaks = 0;
|
|
605
|
+
while (token === 15 || token === 14) {
|
|
606
|
+
if (token === 14 && options.keepLines) numberLineBreaks += 1;
|
|
607
|
+
else if (token === 14) numberLineBreaks = 1;
|
|
608
|
+
token = scanner.scan();
|
|
609
|
+
}
|
|
610
|
+
hasError = token === 16 || scanner.getTokenError() !== 0;
|
|
611
|
+
return token;
|
|
612
|
+
}
|
|
613
|
+
const editOperations = [];
|
|
614
|
+
function addEdit(text, startOffset, endOffset) {
|
|
615
|
+
if (!hasError && (!range || startOffset < rangeEnd && endOffset > rangeStart) && documentText.substring(startOffset, endOffset) !== text) editOperations.push({
|
|
616
|
+
offset: startOffset,
|
|
617
|
+
length: endOffset - startOffset,
|
|
618
|
+
content: text
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
let firstToken = scanNext();
|
|
622
|
+
if (options.keepLines && numberLineBreaks > 0) addEdit(repeat(eol, numberLineBreaks), 0, 0);
|
|
623
|
+
if (firstToken !== 17) {
|
|
624
|
+
let firstTokenStart = scanner.getTokenOffset() + formatTextStart;
|
|
625
|
+
addEdit(indentValue.length * initialIndentLevel < 20 && options.insertSpaces ? cachedSpaces[indentValue.length * initialIndentLevel] : repeat(indentValue, initialIndentLevel), formatTextStart, firstTokenStart);
|
|
626
|
+
}
|
|
627
|
+
while (firstToken !== 17) {
|
|
628
|
+
let firstTokenEnd = scanner.getTokenOffset() + scanner.getTokenLength() + formatTextStart;
|
|
629
|
+
let secondToken = scanNext();
|
|
630
|
+
let replaceContent = "";
|
|
631
|
+
let needsLineBreak = false;
|
|
632
|
+
while (numberLineBreaks === 0 && (secondToken === 12 || secondToken === 13)) {
|
|
633
|
+
let commentTokenStart = scanner.getTokenOffset() + formatTextStart;
|
|
634
|
+
addEdit(cachedSpaces[1], firstTokenEnd, commentTokenStart);
|
|
635
|
+
firstTokenEnd = scanner.getTokenOffset() + scanner.getTokenLength() + formatTextStart;
|
|
636
|
+
needsLineBreak = secondToken === 12;
|
|
637
|
+
replaceContent = needsLineBreak ? newLinesAndIndent() : "";
|
|
638
|
+
secondToken = scanNext();
|
|
639
|
+
}
|
|
640
|
+
if (secondToken === 2) {
|
|
641
|
+
if (firstToken !== 1) indentLevel--;
|
|
642
|
+
if (options.keepLines && numberLineBreaks > 0 || !options.keepLines && firstToken !== 1) replaceContent = newLinesAndIndent();
|
|
643
|
+
else if (options.keepLines) replaceContent = cachedSpaces[1];
|
|
644
|
+
} else if (secondToken === 4) {
|
|
645
|
+
if (firstToken !== 3) indentLevel--;
|
|
646
|
+
if (options.keepLines && numberLineBreaks > 0 || !options.keepLines && firstToken !== 3) replaceContent = newLinesAndIndent();
|
|
647
|
+
else if (options.keepLines) replaceContent = cachedSpaces[1];
|
|
648
|
+
} else {
|
|
649
|
+
switch (firstToken) {
|
|
650
|
+
case 3:
|
|
651
|
+
case 1:
|
|
652
|
+
indentLevel++;
|
|
653
|
+
if (options.keepLines && numberLineBreaks > 0 || !options.keepLines) replaceContent = newLinesAndIndent();
|
|
654
|
+
else replaceContent = cachedSpaces[1];
|
|
655
|
+
break;
|
|
656
|
+
case 5:
|
|
657
|
+
if (options.keepLines && numberLineBreaks > 0 || !options.keepLines) replaceContent = newLinesAndIndent();
|
|
658
|
+
else replaceContent = cachedSpaces[1];
|
|
659
|
+
break;
|
|
660
|
+
case 12:
|
|
661
|
+
replaceContent = newLinesAndIndent();
|
|
662
|
+
break;
|
|
663
|
+
case 13:
|
|
664
|
+
if (numberLineBreaks > 0) replaceContent = newLinesAndIndent();
|
|
665
|
+
else if (!needsLineBreak) replaceContent = cachedSpaces[1];
|
|
666
|
+
break;
|
|
667
|
+
case 6:
|
|
668
|
+
if (options.keepLines && numberLineBreaks > 0) replaceContent = newLinesAndIndent();
|
|
669
|
+
else if (!needsLineBreak) replaceContent = cachedSpaces[1];
|
|
670
|
+
break;
|
|
671
|
+
case 10:
|
|
672
|
+
if (options.keepLines && numberLineBreaks > 0) replaceContent = newLinesAndIndent();
|
|
673
|
+
else if (secondToken === 6 && !needsLineBreak) replaceContent = "";
|
|
674
|
+
break;
|
|
675
|
+
case 7:
|
|
676
|
+
case 8:
|
|
677
|
+
case 9:
|
|
678
|
+
case 11:
|
|
679
|
+
case 2:
|
|
680
|
+
case 4:
|
|
681
|
+
if (options.keepLines && numberLineBreaks > 0) replaceContent = newLinesAndIndent();
|
|
682
|
+
else if ((secondToken === 12 || secondToken === 13) && !needsLineBreak) replaceContent = cachedSpaces[1];
|
|
683
|
+
else if (secondToken !== 5 && secondToken !== 17) hasError = true;
|
|
684
|
+
break;
|
|
685
|
+
case 16: hasError = true;
|
|
686
|
+
}
|
|
687
|
+
if (numberLineBreaks > 0 && (secondToken === 12 || secondToken === 13)) replaceContent = newLinesAndIndent();
|
|
688
|
+
}
|
|
689
|
+
if (secondToken === 17) {
|
|
690
|
+
if (options.keepLines && numberLineBreaks > 0) replaceContent = newLinesAndIndent();
|
|
691
|
+
else replaceContent = options.insertFinalNewline ? eol : "";
|
|
692
|
+
}
|
|
693
|
+
const secondTokenStart = scanner.getTokenOffset() + formatTextStart;
|
|
694
|
+
addEdit(replaceContent, firstTokenEnd, secondTokenStart);
|
|
695
|
+
firstToken = secondToken;
|
|
696
|
+
}
|
|
697
|
+
return editOperations;
|
|
698
|
+
}
|
|
699
|
+
function repeat(s, count) {
|
|
700
|
+
let result = "";
|
|
701
|
+
for (let i = 0; i < count; i++) result += s;
|
|
702
|
+
return result;
|
|
703
|
+
}
|
|
704
|
+
function computeIndentLevel(content, options) {
|
|
705
|
+
let i = 0;
|
|
706
|
+
let nChars = 0;
|
|
707
|
+
const tabSize = options.tabSize || 4;
|
|
708
|
+
while (i < content.length) {
|
|
709
|
+
let ch = content.charAt(i);
|
|
710
|
+
if (ch === cachedSpaces[1]) nChars++;
|
|
711
|
+
else if (ch === " ") nChars += tabSize;
|
|
712
|
+
else break;
|
|
713
|
+
i++;
|
|
714
|
+
}
|
|
715
|
+
return Math.floor(nChars / tabSize);
|
|
716
|
+
}
|
|
717
|
+
function getEOL(options, text) {
|
|
718
|
+
for (let i = 0; i < text.length; i++) {
|
|
719
|
+
const ch = text.charAt(i);
|
|
720
|
+
if (ch === "\r") {
|
|
721
|
+
if (i + 1 < text.length && text.charAt(i + 1) === "\n") return "\r\n";
|
|
722
|
+
return "\r";
|
|
723
|
+
} else if (ch === "\n") return "\n";
|
|
724
|
+
}
|
|
725
|
+
return options && options.eol || "\n";
|
|
726
|
+
}
|
|
727
|
+
function isEOL(text, offset) {
|
|
728
|
+
return "\r\n".indexOf(text.charAt(offset)) !== -1;
|
|
729
|
+
}
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region ../../node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/parser.js
|
|
732
|
+
var ParseOptions;
|
|
733
|
+
(function(ParseOptions) {
|
|
734
|
+
ParseOptions.DEFAULT = { allowTrailingComma: false };
|
|
735
|
+
})(ParseOptions || (ParseOptions = {}));
|
|
736
|
+
/**
|
|
737
|
+
* Parses the given text and returns the object the JSON content represents. On invalid input, the parser tries to be as fault tolerant as possible, but still return a result.
|
|
738
|
+
* Therefore always check the errors list to find out if the input was valid.
|
|
739
|
+
*/
|
|
740
|
+
function parse$1(text, errors = [], options = ParseOptions.DEFAULT) {
|
|
741
|
+
let currentProperty = null;
|
|
742
|
+
let currentParent = [];
|
|
743
|
+
const previousParents = [];
|
|
744
|
+
function onValue(value) {
|
|
745
|
+
if (Array.isArray(currentParent)) currentParent.push(value);
|
|
746
|
+
else if (currentProperty !== null) currentParent[currentProperty] = value;
|
|
747
|
+
}
|
|
748
|
+
visit(text, {
|
|
749
|
+
onObjectBegin: () => {
|
|
750
|
+
const object = {};
|
|
751
|
+
onValue(object);
|
|
752
|
+
previousParents.push(currentParent);
|
|
753
|
+
currentParent = object;
|
|
754
|
+
currentProperty = null;
|
|
755
|
+
},
|
|
756
|
+
onObjectProperty: (name) => {
|
|
757
|
+
currentProperty = name;
|
|
758
|
+
},
|
|
759
|
+
onObjectEnd: () => {
|
|
760
|
+
currentParent = previousParents.pop();
|
|
761
|
+
},
|
|
762
|
+
onArrayBegin: () => {
|
|
763
|
+
const array = [];
|
|
764
|
+
onValue(array);
|
|
765
|
+
previousParents.push(currentParent);
|
|
766
|
+
currentParent = array;
|
|
767
|
+
currentProperty = null;
|
|
768
|
+
},
|
|
769
|
+
onArrayEnd: () => {
|
|
770
|
+
currentParent = previousParents.pop();
|
|
771
|
+
},
|
|
772
|
+
onLiteralValue: onValue,
|
|
773
|
+
onError: (error, offset, length) => {
|
|
774
|
+
errors.push({
|
|
775
|
+
error,
|
|
776
|
+
offset,
|
|
777
|
+
length
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}, options);
|
|
781
|
+
return currentParent[0];
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Parses the given text and returns a tree representation the JSON content. On invalid input, the parser tries to be as fault tolerant as possible, but still return a result.
|
|
785
|
+
*/
|
|
786
|
+
function parseTree(text, errors = [], options = ParseOptions.DEFAULT) {
|
|
787
|
+
let currentParent = {
|
|
788
|
+
type: "array",
|
|
789
|
+
offset: -1,
|
|
790
|
+
length: -1,
|
|
791
|
+
children: [],
|
|
792
|
+
parent: void 0
|
|
793
|
+
};
|
|
794
|
+
function ensurePropertyComplete(endOffset) {
|
|
795
|
+
if (currentParent.type === "property") {
|
|
796
|
+
currentParent.length = endOffset - currentParent.offset;
|
|
797
|
+
currentParent = currentParent.parent;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
function onValue(valueNode) {
|
|
801
|
+
currentParent.children.push(valueNode);
|
|
802
|
+
return valueNode;
|
|
803
|
+
}
|
|
804
|
+
visit(text, {
|
|
805
|
+
onObjectBegin: (offset) => {
|
|
806
|
+
currentParent = onValue({
|
|
807
|
+
type: "object",
|
|
808
|
+
offset,
|
|
809
|
+
length: -1,
|
|
810
|
+
parent: currentParent,
|
|
811
|
+
children: []
|
|
812
|
+
});
|
|
813
|
+
},
|
|
814
|
+
onObjectProperty: (name, offset, length) => {
|
|
815
|
+
currentParent = onValue({
|
|
816
|
+
type: "property",
|
|
817
|
+
offset,
|
|
818
|
+
length: -1,
|
|
819
|
+
parent: currentParent,
|
|
820
|
+
children: []
|
|
821
|
+
});
|
|
822
|
+
currentParent.children.push({
|
|
823
|
+
type: "string",
|
|
824
|
+
value: name,
|
|
825
|
+
offset,
|
|
826
|
+
length,
|
|
827
|
+
parent: currentParent
|
|
828
|
+
});
|
|
829
|
+
},
|
|
830
|
+
onObjectEnd: (offset, length) => {
|
|
831
|
+
ensurePropertyComplete(offset + length);
|
|
832
|
+
currentParent.length = offset + length - currentParent.offset;
|
|
833
|
+
currentParent = currentParent.parent;
|
|
834
|
+
ensurePropertyComplete(offset + length);
|
|
835
|
+
},
|
|
836
|
+
onArrayBegin: (offset, length) => {
|
|
837
|
+
currentParent = onValue({
|
|
838
|
+
type: "array",
|
|
839
|
+
offset,
|
|
840
|
+
length: -1,
|
|
841
|
+
parent: currentParent,
|
|
842
|
+
children: []
|
|
843
|
+
});
|
|
844
|
+
},
|
|
845
|
+
onArrayEnd: (offset, length) => {
|
|
846
|
+
currentParent.length = offset + length - currentParent.offset;
|
|
847
|
+
currentParent = currentParent.parent;
|
|
848
|
+
ensurePropertyComplete(offset + length);
|
|
849
|
+
},
|
|
850
|
+
onLiteralValue: (value, offset, length) => {
|
|
851
|
+
onValue({
|
|
852
|
+
type: getNodeType(value),
|
|
853
|
+
offset,
|
|
854
|
+
length,
|
|
855
|
+
parent: currentParent,
|
|
856
|
+
value
|
|
857
|
+
});
|
|
858
|
+
ensurePropertyComplete(offset + length);
|
|
859
|
+
},
|
|
860
|
+
onSeparator: (sep, offset, length) => {
|
|
861
|
+
if (currentParent.type === "property") {
|
|
862
|
+
if (sep === ":") currentParent.colonOffset = offset;
|
|
863
|
+
else if (sep === ",") ensurePropertyComplete(offset);
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
onError: (error, offset, length) => {
|
|
867
|
+
errors.push({
|
|
868
|
+
error,
|
|
869
|
+
offset,
|
|
870
|
+
length
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
}, options);
|
|
874
|
+
const result = currentParent.children[0];
|
|
875
|
+
if (result) delete result.parent;
|
|
876
|
+
return result;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Finds the node at the given path in a JSON DOM.
|
|
880
|
+
*/
|
|
881
|
+
function findNodeAtLocation(root, path) {
|
|
882
|
+
if (!root) return;
|
|
883
|
+
let node = root;
|
|
884
|
+
for (let segment of path) if (typeof segment === "string") {
|
|
885
|
+
if (node.type !== "object" || !Array.isArray(node.children)) return;
|
|
886
|
+
let found = false;
|
|
887
|
+
for (const propertyNode of node.children) if (Array.isArray(propertyNode.children) && propertyNode.children[0].value === segment && propertyNode.children.length === 2) {
|
|
888
|
+
node = propertyNode.children[1];
|
|
889
|
+
found = true;
|
|
890
|
+
break;
|
|
891
|
+
}
|
|
892
|
+
if (!found) return;
|
|
893
|
+
} else {
|
|
894
|
+
const index = segment;
|
|
895
|
+
if (node.type !== "array" || index < 0 || !Array.isArray(node.children) || index >= node.children.length) return;
|
|
896
|
+
node = node.children[index];
|
|
897
|
+
}
|
|
898
|
+
return node;
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Parses the given text and invokes the visitor functions for each object, array and literal reached.
|
|
902
|
+
*/
|
|
903
|
+
function visit(text, visitor, options = ParseOptions.DEFAULT) {
|
|
904
|
+
const _scanner = createScanner(text, false);
|
|
905
|
+
const _jsonPath = [];
|
|
906
|
+
let suppressedCallbacks = 0;
|
|
907
|
+
function toNoArgVisit(visitFunction) {
|
|
908
|
+
return visitFunction ? () => suppressedCallbacks === 0 && visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter()) : () => true;
|
|
909
|
+
}
|
|
910
|
+
function toOneArgVisit(visitFunction) {
|
|
911
|
+
return visitFunction ? (arg) => suppressedCallbacks === 0 && visitFunction(arg, _scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter()) : () => true;
|
|
912
|
+
}
|
|
913
|
+
function toOneArgVisitWithPath(visitFunction) {
|
|
914
|
+
return visitFunction ? (arg) => suppressedCallbacks === 0 && visitFunction(arg, _scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter(), () => _jsonPath.slice()) : () => true;
|
|
915
|
+
}
|
|
916
|
+
function toBeginVisit(visitFunction) {
|
|
917
|
+
return visitFunction ? () => {
|
|
918
|
+
if (suppressedCallbacks > 0) suppressedCallbacks++;
|
|
919
|
+
else if (visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter(), () => _jsonPath.slice()) === false) suppressedCallbacks = 1;
|
|
920
|
+
} : () => true;
|
|
921
|
+
}
|
|
922
|
+
function toEndVisit(visitFunction) {
|
|
923
|
+
return visitFunction ? () => {
|
|
924
|
+
if (suppressedCallbacks > 0) suppressedCallbacks--;
|
|
925
|
+
if (suppressedCallbacks === 0) visitFunction(_scanner.getTokenOffset(), _scanner.getTokenLength(), _scanner.getTokenStartLine(), _scanner.getTokenStartCharacter());
|
|
926
|
+
} : () => true;
|
|
927
|
+
}
|
|
928
|
+
const onObjectBegin = toBeginVisit(visitor.onObjectBegin), onObjectProperty = toOneArgVisitWithPath(visitor.onObjectProperty), onObjectEnd = toEndVisit(visitor.onObjectEnd), onArrayBegin = toBeginVisit(visitor.onArrayBegin), onArrayEnd = toEndVisit(visitor.onArrayEnd), onLiteralValue = toOneArgVisitWithPath(visitor.onLiteralValue), onSeparator = toOneArgVisit(visitor.onSeparator), onComment = toNoArgVisit(visitor.onComment), onError = toOneArgVisit(visitor.onError);
|
|
929
|
+
const disallowComments = options && options.disallowComments;
|
|
930
|
+
const allowTrailingComma = options && options.allowTrailingComma;
|
|
931
|
+
function scanNext() {
|
|
932
|
+
while (true) {
|
|
933
|
+
const token = _scanner.scan();
|
|
934
|
+
switch (_scanner.getTokenError()) {
|
|
935
|
+
case 4:
|
|
936
|
+
handleError(14);
|
|
937
|
+
break;
|
|
938
|
+
case 5:
|
|
939
|
+
handleError(15);
|
|
940
|
+
break;
|
|
941
|
+
case 3:
|
|
942
|
+
handleError(13);
|
|
943
|
+
break;
|
|
944
|
+
case 1:
|
|
945
|
+
if (!disallowComments) handleError(11);
|
|
946
|
+
break;
|
|
947
|
+
case 2:
|
|
948
|
+
handleError(12);
|
|
949
|
+
break;
|
|
950
|
+
case 6: handleError(16);
|
|
951
|
+
}
|
|
952
|
+
switch (token) {
|
|
953
|
+
case 12:
|
|
954
|
+
case 13:
|
|
955
|
+
if (disallowComments) handleError(10);
|
|
956
|
+
else onComment();
|
|
957
|
+
break;
|
|
958
|
+
case 16:
|
|
959
|
+
handleError(1);
|
|
960
|
+
break;
|
|
961
|
+
case 15:
|
|
962
|
+
case 14: break;
|
|
963
|
+
default: return token;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function handleError(error, skipUntilAfter = [], skipUntil = []) {
|
|
968
|
+
onError(error);
|
|
969
|
+
if (skipUntilAfter.length + skipUntil.length > 0) {
|
|
970
|
+
let token = _scanner.getToken();
|
|
971
|
+
while (token !== 17) {
|
|
972
|
+
if (skipUntilAfter.indexOf(token) !== -1) {
|
|
973
|
+
scanNext();
|
|
974
|
+
break;
|
|
975
|
+
} else if (skipUntil.indexOf(token) !== -1) break;
|
|
976
|
+
token = scanNext();
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function parseString(isValue) {
|
|
981
|
+
const value = _scanner.getTokenValue();
|
|
982
|
+
if (isValue) onLiteralValue(value);
|
|
983
|
+
else {
|
|
984
|
+
onObjectProperty(value);
|
|
985
|
+
_jsonPath.push(value);
|
|
986
|
+
}
|
|
987
|
+
scanNext();
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
function parseLiteral() {
|
|
991
|
+
switch (_scanner.getToken()) {
|
|
992
|
+
case 11:
|
|
993
|
+
const tokenValue = _scanner.getTokenValue();
|
|
994
|
+
let value = Number(tokenValue);
|
|
995
|
+
if (isNaN(value)) {
|
|
996
|
+
handleError(2);
|
|
997
|
+
value = 0;
|
|
998
|
+
}
|
|
999
|
+
onLiteralValue(value);
|
|
1000
|
+
break;
|
|
1001
|
+
case 7:
|
|
1002
|
+
onLiteralValue(null);
|
|
1003
|
+
break;
|
|
1004
|
+
case 8:
|
|
1005
|
+
onLiteralValue(true);
|
|
1006
|
+
break;
|
|
1007
|
+
case 9:
|
|
1008
|
+
onLiteralValue(false);
|
|
1009
|
+
break;
|
|
1010
|
+
default: return false;
|
|
1011
|
+
}
|
|
1012
|
+
scanNext();
|
|
1013
|
+
return true;
|
|
1014
|
+
}
|
|
1015
|
+
function parseProperty() {
|
|
1016
|
+
if (_scanner.getToken() !== 10) {
|
|
1017
|
+
handleError(3, [], [2, 5]);
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
parseString(false);
|
|
1021
|
+
if (_scanner.getToken() === 6) {
|
|
1022
|
+
onSeparator(":");
|
|
1023
|
+
scanNext();
|
|
1024
|
+
if (!parseValue()) handleError(4, [], [2, 5]);
|
|
1025
|
+
} else handleError(5, [], [2, 5]);
|
|
1026
|
+
_jsonPath.pop();
|
|
1027
|
+
return true;
|
|
1028
|
+
}
|
|
1029
|
+
function parseObject() {
|
|
1030
|
+
onObjectBegin();
|
|
1031
|
+
scanNext();
|
|
1032
|
+
let needsComma = false;
|
|
1033
|
+
while (_scanner.getToken() !== 2 && _scanner.getToken() !== 17) {
|
|
1034
|
+
if (_scanner.getToken() === 5) {
|
|
1035
|
+
if (!needsComma) handleError(4, [], []);
|
|
1036
|
+
onSeparator(",");
|
|
1037
|
+
scanNext();
|
|
1038
|
+
if (_scanner.getToken() === 2 && allowTrailingComma) break;
|
|
1039
|
+
} else if (needsComma) handleError(6, [], []);
|
|
1040
|
+
if (!parseProperty()) handleError(4, [], [2, 5]);
|
|
1041
|
+
needsComma = true;
|
|
1042
|
+
}
|
|
1043
|
+
onObjectEnd();
|
|
1044
|
+
if (_scanner.getToken() !== 2) handleError(7, [2], []);
|
|
1045
|
+
else scanNext();
|
|
1046
|
+
return true;
|
|
1047
|
+
}
|
|
1048
|
+
function parseArray() {
|
|
1049
|
+
onArrayBegin();
|
|
1050
|
+
scanNext();
|
|
1051
|
+
let isFirstElement = true;
|
|
1052
|
+
let needsComma = false;
|
|
1053
|
+
while (_scanner.getToken() !== 4 && _scanner.getToken() !== 17) {
|
|
1054
|
+
if (_scanner.getToken() === 5) {
|
|
1055
|
+
if (!needsComma) handleError(4, [], []);
|
|
1056
|
+
onSeparator(",");
|
|
1057
|
+
scanNext();
|
|
1058
|
+
if (_scanner.getToken() === 4 && allowTrailingComma) break;
|
|
1059
|
+
} else if (needsComma) handleError(6, [], []);
|
|
1060
|
+
if (isFirstElement) {
|
|
1061
|
+
_jsonPath.push(0);
|
|
1062
|
+
isFirstElement = false;
|
|
1063
|
+
} else _jsonPath[_jsonPath.length - 1]++;
|
|
1064
|
+
if (!parseValue()) handleError(4, [], [4, 5]);
|
|
1065
|
+
needsComma = true;
|
|
1066
|
+
}
|
|
1067
|
+
onArrayEnd();
|
|
1068
|
+
if (!isFirstElement) _jsonPath.pop();
|
|
1069
|
+
if (_scanner.getToken() !== 4) handleError(8, [4], []);
|
|
1070
|
+
else scanNext();
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
function parseValue() {
|
|
1074
|
+
switch (_scanner.getToken()) {
|
|
1075
|
+
case 3: return parseArray();
|
|
1076
|
+
case 1: return parseObject();
|
|
1077
|
+
case 10: return parseString(true);
|
|
1078
|
+
default: return parseLiteral();
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
scanNext();
|
|
1082
|
+
if (_scanner.getToken() === 17) {
|
|
1083
|
+
if (options.allowEmptyContent) return true;
|
|
1084
|
+
handleError(4, [], []);
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
if (!parseValue()) {
|
|
1088
|
+
handleError(4, [], []);
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
if (_scanner.getToken() !== 17) handleError(9, [], []);
|
|
1092
|
+
return true;
|
|
1093
|
+
}
|
|
1094
|
+
function getNodeType(value) {
|
|
1095
|
+
switch (typeof value) {
|
|
1096
|
+
case "boolean": return "boolean";
|
|
1097
|
+
case "number": return "number";
|
|
1098
|
+
case "string": return "string";
|
|
1099
|
+
case "object":
|
|
1100
|
+
if (!value) return "null";
|
|
1101
|
+
else if (Array.isArray(value)) return "array";
|
|
1102
|
+
return "object";
|
|
1103
|
+
default: return "null";
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region ../../node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/impl/edit.js
|
|
1108
|
+
function setProperty(text, originalPath, value, options) {
|
|
1109
|
+
const path = originalPath.slice();
|
|
1110
|
+
const root = parseTree(text, []);
|
|
1111
|
+
let parent = void 0;
|
|
1112
|
+
let lastSegment = void 0;
|
|
1113
|
+
while (path.length > 0) {
|
|
1114
|
+
lastSegment = path.pop();
|
|
1115
|
+
parent = findNodeAtLocation(root, path);
|
|
1116
|
+
if (parent === void 0 && value !== void 0) {
|
|
1117
|
+
if (typeof lastSegment === "string") value = { [lastSegment]: value };
|
|
1118
|
+
else value = [value];
|
|
1119
|
+
} else break;
|
|
1120
|
+
}
|
|
1121
|
+
if (!parent) {
|
|
1122
|
+
if (value === void 0) throw new Error("Can not delete in empty document");
|
|
1123
|
+
return withFormatting(text, {
|
|
1124
|
+
offset: root ? root.offset : 0,
|
|
1125
|
+
length: root ? root.length : 0,
|
|
1126
|
+
content: JSON.stringify(value)
|
|
1127
|
+
}, options);
|
|
1128
|
+
} else if (parent.type === "object" && typeof lastSegment === "string" && Array.isArray(parent.children)) {
|
|
1129
|
+
const existing = findNodeAtLocation(parent, [lastSegment]);
|
|
1130
|
+
if (existing !== void 0) {
|
|
1131
|
+
if (value === void 0) {
|
|
1132
|
+
if (!existing.parent) throw new Error("Malformed AST");
|
|
1133
|
+
const propertyIndex = parent.children.indexOf(existing.parent);
|
|
1134
|
+
let removeBegin;
|
|
1135
|
+
let removeEnd = existing.parent.offset + existing.parent.length;
|
|
1136
|
+
if (propertyIndex > 0) {
|
|
1137
|
+
let previous = parent.children[propertyIndex - 1];
|
|
1138
|
+
removeBegin = previous.offset + previous.length;
|
|
1139
|
+
} else {
|
|
1140
|
+
removeBegin = parent.offset + 1;
|
|
1141
|
+
if (parent.children.length > 1) removeEnd = parent.children[1].offset;
|
|
1142
|
+
}
|
|
1143
|
+
return withFormatting(text, {
|
|
1144
|
+
offset: removeBegin,
|
|
1145
|
+
length: removeEnd - removeBegin,
|
|
1146
|
+
content: ""
|
|
1147
|
+
}, options);
|
|
1148
|
+
} else return withFormatting(text, {
|
|
1149
|
+
offset: existing.offset,
|
|
1150
|
+
length: existing.length,
|
|
1151
|
+
content: JSON.stringify(value)
|
|
1152
|
+
}, options);
|
|
1153
|
+
} else {
|
|
1154
|
+
if (value === void 0) return [];
|
|
1155
|
+
const newProperty = `${JSON.stringify(lastSegment)}: ${JSON.stringify(value)}`;
|
|
1156
|
+
const index = options.getInsertionIndex ? options.getInsertionIndex(parent.children.map((p) => p.children[0].value)) : parent.children.length;
|
|
1157
|
+
let edit;
|
|
1158
|
+
if (index > 0) {
|
|
1159
|
+
let previous = parent.children[index - 1];
|
|
1160
|
+
edit = {
|
|
1161
|
+
offset: previous.offset + previous.length,
|
|
1162
|
+
length: 0,
|
|
1163
|
+
content: "," + newProperty
|
|
1164
|
+
};
|
|
1165
|
+
} else if (parent.children.length === 0) edit = {
|
|
1166
|
+
offset: parent.offset + 1,
|
|
1167
|
+
length: 0,
|
|
1168
|
+
content: newProperty
|
|
1169
|
+
};
|
|
1170
|
+
else edit = {
|
|
1171
|
+
offset: parent.offset + 1,
|
|
1172
|
+
length: 0,
|
|
1173
|
+
content: newProperty + ","
|
|
1174
|
+
};
|
|
1175
|
+
return withFormatting(text, edit, options);
|
|
1176
|
+
}
|
|
1177
|
+
} else if (parent.type === "array" && typeof lastSegment === "number" && Array.isArray(parent.children)) {
|
|
1178
|
+
const insertIndex = lastSegment;
|
|
1179
|
+
if (insertIndex === -1) {
|
|
1180
|
+
const newProperty = `${JSON.stringify(value)}`;
|
|
1181
|
+
let edit;
|
|
1182
|
+
if (parent.children.length === 0) edit = {
|
|
1183
|
+
offset: parent.offset + 1,
|
|
1184
|
+
length: 0,
|
|
1185
|
+
content: newProperty
|
|
1186
|
+
};
|
|
1187
|
+
else {
|
|
1188
|
+
const previous = parent.children[parent.children.length - 1];
|
|
1189
|
+
edit = {
|
|
1190
|
+
offset: previous.offset + previous.length,
|
|
1191
|
+
length: 0,
|
|
1192
|
+
content: "," + newProperty
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
return withFormatting(text, edit, options);
|
|
1196
|
+
} else if (value === void 0 && parent.children.length >= 0) {
|
|
1197
|
+
const removalIndex = lastSegment;
|
|
1198
|
+
const toRemove = parent.children[removalIndex];
|
|
1199
|
+
let edit;
|
|
1200
|
+
if (parent.children.length === 1) edit = {
|
|
1201
|
+
offset: parent.offset + 1,
|
|
1202
|
+
length: parent.length - 2,
|
|
1203
|
+
content: ""
|
|
1204
|
+
};
|
|
1205
|
+
else if (parent.children.length - 1 === removalIndex) {
|
|
1206
|
+
let previous = parent.children[removalIndex - 1];
|
|
1207
|
+
let offset = previous.offset + previous.length;
|
|
1208
|
+
edit = {
|
|
1209
|
+
offset,
|
|
1210
|
+
length: parent.offset + parent.length - 2 - offset,
|
|
1211
|
+
content: ""
|
|
1212
|
+
};
|
|
1213
|
+
} else edit = {
|
|
1214
|
+
offset: toRemove.offset,
|
|
1215
|
+
length: parent.children[removalIndex + 1].offset - toRemove.offset,
|
|
1216
|
+
content: ""
|
|
1217
|
+
};
|
|
1218
|
+
return withFormatting(text, edit, options);
|
|
1219
|
+
} else if (value !== void 0) {
|
|
1220
|
+
let edit;
|
|
1221
|
+
const newProperty = `${JSON.stringify(value)}`;
|
|
1222
|
+
if (!options.isArrayInsertion && parent.children.length > lastSegment) {
|
|
1223
|
+
const toModify = parent.children[lastSegment];
|
|
1224
|
+
edit = {
|
|
1225
|
+
offset: toModify.offset,
|
|
1226
|
+
length: toModify.length,
|
|
1227
|
+
content: newProperty
|
|
1228
|
+
};
|
|
1229
|
+
} else if (parent.children.length === 0 || lastSegment === 0) edit = {
|
|
1230
|
+
offset: parent.offset + 1,
|
|
1231
|
+
length: 0,
|
|
1232
|
+
content: parent.children.length === 0 ? newProperty : newProperty + ","
|
|
1233
|
+
};
|
|
1234
|
+
else {
|
|
1235
|
+
const index = lastSegment > parent.children.length ? parent.children.length : lastSegment;
|
|
1236
|
+
const previous = parent.children[index - 1];
|
|
1237
|
+
edit = {
|
|
1238
|
+
offset: previous.offset + previous.length,
|
|
1239
|
+
length: 0,
|
|
1240
|
+
content: "," + newProperty
|
|
1241
|
+
};
|
|
1242
|
+
}
|
|
1243
|
+
return withFormatting(text, edit, options);
|
|
1244
|
+
} else throw new Error(`Can not ${value === void 0 ? "remove" : options.isArrayInsertion ? "insert" : "modify"} Array index ${insertIndex} as length is not sufficient`);
|
|
1245
|
+
} else throw new Error(`Can not add ${typeof lastSegment !== "number" ? "index" : "property"} to parent of type ${parent.type}`);
|
|
1246
|
+
}
|
|
1247
|
+
function withFormatting(text, edit, options) {
|
|
1248
|
+
if (!options.formattingOptions) return [edit];
|
|
1249
|
+
let newText = applyEdit(text, edit);
|
|
1250
|
+
let begin = edit.offset;
|
|
1251
|
+
let end = edit.offset + edit.content.length;
|
|
1252
|
+
if (edit.length === 0 || edit.content.length === 0) {
|
|
1253
|
+
while (begin > 0 && !isEOL(newText, begin - 1)) begin--;
|
|
1254
|
+
while (end < newText.length && !isEOL(newText, end)) end++;
|
|
1255
|
+
}
|
|
1256
|
+
const edits = format(newText, {
|
|
1257
|
+
offset: begin,
|
|
1258
|
+
length: end - begin
|
|
1259
|
+
}, {
|
|
1260
|
+
...options.formattingOptions,
|
|
1261
|
+
keepLines: false
|
|
1262
|
+
});
|
|
1263
|
+
for (let i = edits.length - 1; i >= 0; i--) {
|
|
1264
|
+
const edit = edits[i];
|
|
1265
|
+
newText = applyEdit(newText, edit);
|
|
1266
|
+
begin = Math.min(begin, edit.offset);
|
|
1267
|
+
end = Math.max(end, edit.offset + edit.length);
|
|
1268
|
+
end += edit.content.length - edit.length;
|
|
1269
|
+
}
|
|
1270
|
+
const editLength = text.length - (newText.length - end) - begin;
|
|
1271
|
+
return [{
|
|
1272
|
+
offset: begin,
|
|
1273
|
+
length: editLength,
|
|
1274
|
+
content: newText.substring(begin, end)
|
|
1275
|
+
}];
|
|
1276
|
+
}
|
|
1277
|
+
function applyEdit(text, edit) {
|
|
1278
|
+
return text.substring(0, edit.offset) + edit.content + text.substring(edit.offset + edit.length);
|
|
1279
|
+
}
|
|
1280
|
+
//#endregion
|
|
1281
|
+
//#region ../../node_modules/.pnpm/jsonc-parser@3.3.1/node_modules/jsonc-parser/lib/esm/main.js
|
|
1282
|
+
var ScanError;
|
|
1283
|
+
(function(ScanError) {
|
|
1284
|
+
ScanError[ScanError["None"] = 0] = "None";
|
|
1285
|
+
ScanError[ScanError["UnexpectedEndOfComment"] = 1] = "UnexpectedEndOfComment";
|
|
1286
|
+
ScanError[ScanError["UnexpectedEndOfString"] = 2] = "UnexpectedEndOfString";
|
|
1287
|
+
ScanError[ScanError["UnexpectedEndOfNumber"] = 3] = "UnexpectedEndOfNumber";
|
|
1288
|
+
ScanError[ScanError["InvalidUnicode"] = 4] = "InvalidUnicode";
|
|
1289
|
+
ScanError[ScanError["InvalidEscapeCharacter"] = 5] = "InvalidEscapeCharacter";
|
|
1290
|
+
ScanError[ScanError["InvalidCharacter"] = 6] = "InvalidCharacter";
|
|
1291
|
+
})(ScanError || (ScanError = {}));
|
|
1292
|
+
var SyntaxKind;
|
|
1293
|
+
(function(SyntaxKind) {
|
|
1294
|
+
SyntaxKind[SyntaxKind["OpenBraceToken"] = 1] = "OpenBraceToken";
|
|
1295
|
+
SyntaxKind[SyntaxKind["CloseBraceToken"] = 2] = "CloseBraceToken";
|
|
1296
|
+
SyntaxKind[SyntaxKind["OpenBracketToken"] = 3] = "OpenBracketToken";
|
|
1297
|
+
SyntaxKind[SyntaxKind["CloseBracketToken"] = 4] = "CloseBracketToken";
|
|
1298
|
+
SyntaxKind[SyntaxKind["CommaToken"] = 5] = "CommaToken";
|
|
1299
|
+
SyntaxKind[SyntaxKind["ColonToken"] = 6] = "ColonToken";
|
|
1300
|
+
SyntaxKind[SyntaxKind["NullKeyword"] = 7] = "NullKeyword";
|
|
1301
|
+
SyntaxKind[SyntaxKind["TrueKeyword"] = 8] = "TrueKeyword";
|
|
1302
|
+
SyntaxKind[SyntaxKind["FalseKeyword"] = 9] = "FalseKeyword";
|
|
1303
|
+
SyntaxKind[SyntaxKind["StringLiteral"] = 10] = "StringLiteral";
|
|
1304
|
+
SyntaxKind[SyntaxKind["NumericLiteral"] = 11] = "NumericLiteral";
|
|
1305
|
+
SyntaxKind[SyntaxKind["LineCommentTrivia"] = 12] = "LineCommentTrivia";
|
|
1306
|
+
SyntaxKind[SyntaxKind["BlockCommentTrivia"] = 13] = "BlockCommentTrivia";
|
|
1307
|
+
SyntaxKind[SyntaxKind["LineBreakTrivia"] = 14] = "LineBreakTrivia";
|
|
1308
|
+
SyntaxKind[SyntaxKind["Trivia"] = 15] = "Trivia";
|
|
1309
|
+
SyntaxKind[SyntaxKind["Unknown"] = 16] = "Unknown";
|
|
1310
|
+
SyntaxKind[SyntaxKind["EOF"] = 17] = "EOF";
|
|
1311
|
+
})(SyntaxKind || (SyntaxKind = {}));
|
|
1312
|
+
/**
|
|
1313
|
+
* Parses the given text and returns the object the JSON content represents. On invalid input, the parser tries to be as fault tolerant as possible, but still return a result.
|
|
1314
|
+
* Therefore, always check the errors list to find out if the input was valid.
|
|
1315
|
+
*/
|
|
1316
|
+
const parse = parse$1;
|
|
1317
|
+
var ParseErrorCode;
|
|
1318
|
+
(function(ParseErrorCode) {
|
|
1319
|
+
ParseErrorCode[ParseErrorCode["InvalidSymbol"] = 1] = "InvalidSymbol";
|
|
1320
|
+
ParseErrorCode[ParseErrorCode["InvalidNumberFormat"] = 2] = "InvalidNumberFormat";
|
|
1321
|
+
ParseErrorCode[ParseErrorCode["PropertyNameExpected"] = 3] = "PropertyNameExpected";
|
|
1322
|
+
ParseErrorCode[ParseErrorCode["ValueExpected"] = 4] = "ValueExpected";
|
|
1323
|
+
ParseErrorCode[ParseErrorCode["ColonExpected"] = 5] = "ColonExpected";
|
|
1324
|
+
ParseErrorCode[ParseErrorCode["CommaExpected"] = 6] = "CommaExpected";
|
|
1325
|
+
ParseErrorCode[ParseErrorCode["CloseBraceExpected"] = 7] = "CloseBraceExpected";
|
|
1326
|
+
ParseErrorCode[ParseErrorCode["CloseBracketExpected"] = 8] = "CloseBracketExpected";
|
|
1327
|
+
ParseErrorCode[ParseErrorCode["EndOfFileExpected"] = 9] = "EndOfFileExpected";
|
|
1328
|
+
ParseErrorCode[ParseErrorCode["InvalidCommentToken"] = 10] = "InvalidCommentToken";
|
|
1329
|
+
ParseErrorCode[ParseErrorCode["UnexpectedEndOfComment"] = 11] = "UnexpectedEndOfComment";
|
|
1330
|
+
ParseErrorCode[ParseErrorCode["UnexpectedEndOfString"] = 12] = "UnexpectedEndOfString";
|
|
1331
|
+
ParseErrorCode[ParseErrorCode["UnexpectedEndOfNumber"] = 13] = "UnexpectedEndOfNumber";
|
|
1332
|
+
ParseErrorCode[ParseErrorCode["InvalidUnicode"] = 14] = "InvalidUnicode";
|
|
1333
|
+
ParseErrorCode[ParseErrorCode["InvalidEscapeCharacter"] = 15] = "InvalidEscapeCharacter";
|
|
1334
|
+
ParseErrorCode[ParseErrorCode["InvalidCharacter"] = 16] = "InvalidCharacter";
|
|
1335
|
+
})(ParseErrorCode || (ParseErrorCode = {}));
|
|
1336
|
+
function printParseErrorCode(code) {
|
|
1337
|
+
switch (code) {
|
|
1338
|
+
case 1: return "InvalidSymbol";
|
|
1339
|
+
case 2: return "InvalidNumberFormat";
|
|
1340
|
+
case 3: return "PropertyNameExpected";
|
|
1341
|
+
case 4: return "ValueExpected";
|
|
1342
|
+
case 5: return "ColonExpected";
|
|
1343
|
+
case 6: return "CommaExpected";
|
|
1344
|
+
case 7: return "CloseBraceExpected";
|
|
1345
|
+
case 8: return "CloseBracketExpected";
|
|
1346
|
+
case 9: return "EndOfFileExpected";
|
|
1347
|
+
case 10: return "InvalidCommentToken";
|
|
1348
|
+
case 11: return "UnexpectedEndOfComment";
|
|
1349
|
+
case 12: return "UnexpectedEndOfString";
|
|
1350
|
+
case 13: return "UnexpectedEndOfNumber";
|
|
1351
|
+
case 14: return "InvalidUnicode";
|
|
1352
|
+
case 15: return "InvalidEscapeCharacter";
|
|
1353
|
+
case 16: return "InvalidCharacter";
|
|
1354
|
+
}
|
|
1355
|
+
return "<unknown ParseErrorCode>";
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Computes the edit operations needed to modify a value in the JSON document.
|
|
1359
|
+
*
|
|
1360
|
+
* @param documentText The input text
|
|
1361
|
+
* @param path The path of the value to change. The path represents either to the document root, a property or an array item.
|
|
1362
|
+
* If the path points to an non-existing property or item, it will be created.
|
|
1363
|
+
* @param value The new value for the specified property or item. If the value is undefined,
|
|
1364
|
+
* the property or item will be removed.
|
|
1365
|
+
* @param options Options
|
|
1366
|
+
* @returns The edit operations describing the changes to the original document, following the format described in {@linkcode EditResult}.
|
|
1367
|
+
* To apply the edit operations to the input, use {@linkcode applyEdits}.
|
|
1368
|
+
*/
|
|
1369
|
+
function modify(text, path, value, options) {
|
|
1370
|
+
return setProperty(text, path, value, options);
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Applies edits to an input string.
|
|
1374
|
+
* @param text The input text
|
|
1375
|
+
* @param edits Edit operations following the format described in {@linkcode EditResult}.
|
|
1376
|
+
* @returns The text with the applied edits.
|
|
1377
|
+
* @throws An error if the edit operations are not well-formed as described in {@linkcode EditResult}.
|
|
1378
|
+
*/
|
|
1379
|
+
function applyEdits(text, edits) {
|
|
1380
|
+
let sortedEdits = edits.slice(0).sort((a, b) => {
|
|
1381
|
+
const diff = a.offset - b.offset;
|
|
1382
|
+
if (diff === 0) return a.length - b.length;
|
|
1383
|
+
return diff;
|
|
1384
|
+
});
|
|
1385
|
+
let lastModifiedOffset = text.length;
|
|
1386
|
+
for (let i = sortedEdits.length - 1; i >= 0; i--) {
|
|
1387
|
+
let e = sortedEdits[i];
|
|
1388
|
+
if (e.offset + e.length <= lastModifiedOffset) text = applyEdit(text, e);
|
|
1389
|
+
else throw new Error("Overlapping edit");
|
|
1390
|
+
lastModifiedOffset = e.offset;
|
|
1391
|
+
}
|
|
1392
|
+
return text;
|
|
1393
|
+
}
|
|
1394
|
+
//#endregion
|
|
1395
|
+
//#region src/deployment.ts
|
|
1396
|
+
/** What a directory holds: a deployment of either kind, nothing yet (`isEmpty`), or something else. */
|
|
1397
|
+
function deploymentKind(dir) {
|
|
1398
|
+
if (isEmpty(dir)) return "empty";
|
|
1399
|
+
if (existsSync(join(dir, "app", "wrangler.jsonc")) && existsSync(join(dir, "vault", "wrangler.jsonc"))) return "workers";
|
|
1400
|
+
if (existsSync(join(dir, "src", "vault.ts")) && existsSync(join(dir, "vault.env.example"))) return "node";
|
|
1401
|
+
return "other";
|
|
1402
|
+
}
|
|
1403
|
+
/** Install a new deployment's packages, as its README says: pnpm, through corepack when pnpm itself is missing. */
|
|
1404
|
+
function install(dir) {
|
|
1405
|
+
const attempt = (command, args) => new Promise((resolve) => {
|
|
1406
|
+
const child = spawn(command, args, {
|
|
1407
|
+
cwd: dir,
|
|
1408
|
+
stdio: [
|
|
1409
|
+
"ignore",
|
|
1410
|
+
"pipe",
|
|
1411
|
+
"pipe"
|
|
1412
|
+
],
|
|
1413
|
+
env: {
|
|
1414
|
+
...process.env,
|
|
1415
|
+
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
let output = "";
|
|
1419
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => output += chunk);
|
|
1420
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => output += chunk);
|
|
1421
|
+
child.on("error", () => resolve(null));
|
|
1422
|
+
child.on("close", (code) => resolve({
|
|
1423
|
+
code,
|
|
1424
|
+
output
|
|
1425
|
+
}));
|
|
1426
|
+
});
|
|
1427
|
+
return (async () => {
|
|
1428
|
+
const ran = await attempt("pnpm", ["install"]) ?? await attempt("corepack", ["pnpm", "install"]);
|
|
1429
|
+
if (ran === null) throw new Error("pnpm is not installed: corepack enable, or npm install -g pnpm, then run setup again");
|
|
1430
|
+
if (ran.code !== 0) {
|
|
1431
|
+
const tail = ran.output.trim().split("\n").slice(-3).join(" ");
|
|
1432
|
+
throw new Error(`pnpm install failed: ${tail}`);
|
|
1433
|
+
}
|
|
1434
|
+
})();
|
|
1435
|
+
}
|
|
1436
|
+
/** A value the examples ship, standing for the operator's own. */
|
|
1437
|
+
function placeholder(value) {
|
|
1438
|
+
return value === void 0 || value === null || value === "" || /^replace-with-|example\.com\b/.test(value);
|
|
1439
|
+
}
|
|
1440
|
+
function readWorker(dir, path) {
|
|
1441
|
+
const errors = [];
|
|
1442
|
+
const config = parse(readFileSync(join(dir, path), "utf8"), errors, { allowTrailingComma: true });
|
|
1443
|
+
if (errors.length > 0) throw new Error(`${path} is not valid: ${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`);
|
|
1444
|
+
if (typeof config.name !== "string") throw new Error(`${path} names no Worker`);
|
|
1445
|
+
const route = config.routes?.[0];
|
|
1446
|
+
return {
|
|
1447
|
+
path,
|
|
1448
|
+
name: config.name,
|
|
1449
|
+
accountId: config.account_id ?? null,
|
|
1450
|
+
vars: Object.fromEntries(Object.entries(config.vars ?? {}).filter((entry) => typeof entry[1] === "string")),
|
|
1451
|
+
hyperdrive: placeholder(config.hyperdrive?.[0]?.id) ? null : config.hyperdrive[0].id,
|
|
1452
|
+
route: typeof route === "string" ? route : route?.pattern ?? null
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
/** A value as the examples write one on a single line: `[{ "pattern": "…", "custom_domain": true }]`. */
|
|
1456
|
+
function inline(value) {
|
|
1457
|
+
return JSON.stringify(value, null, 1).replace(/\n\s*/g, " ").replace(/\[ /g, "[").replace(/ \]/g, "]");
|
|
1458
|
+
}
|
|
1459
|
+
/** Make `changes` to the file at `path`, leaving the rest as it is, comments and all. Says which changed anything. */
|
|
1460
|
+
function editWorker(dir, path, changes) {
|
|
1461
|
+
const MARK = "\0coffre\0";
|
|
1462
|
+
let text = readFileSync(join(dir, path), "utf8");
|
|
1463
|
+
const changed = changes.map((change) => {
|
|
1464
|
+
const edits = modify(text, change.path, MARK, {
|
|
1465
|
+
formattingOptions: {
|
|
1466
|
+
insertSpaces: true,
|
|
1467
|
+
tabSize: 2,
|
|
1468
|
+
eol: "\n"
|
|
1469
|
+
},
|
|
1470
|
+
getInsertionIndex: change.after === void 0 ? void 0 : (properties) => properties.indexOf(change.after) + 1
|
|
1471
|
+
});
|
|
1472
|
+
const before = text;
|
|
1473
|
+
text = applyEdits(text, edits.map((edit) => ({
|
|
1474
|
+
...edit,
|
|
1475
|
+
content: edit.content.replace(JSON.stringify(MARK), inline(change.value))
|
|
1476
|
+
})));
|
|
1477
|
+
return text !== before;
|
|
1478
|
+
});
|
|
1479
|
+
writeFileSync(join(dir, path), text);
|
|
1480
|
+
return changed;
|
|
1481
|
+
}
|
|
1482
|
+
//#endregion
|
|
1483
|
+
//#region src/steps.ts
|
|
1484
|
+
const SPINNER = [
|
|
1485
|
+
"⠋",
|
|
1486
|
+
"⠙",
|
|
1487
|
+
"⠹",
|
|
1488
|
+
"⠸",
|
|
1489
|
+
"⠼",
|
|
1490
|
+
"⠴",
|
|
1491
|
+
"⠦",
|
|
1492
|
+
"⠧",
|
|
1493
|
+
"⠇",
|
|
1494
|
+
"⠏"
|
|
1495
|
+
];
|
|
1496
|
+
const FRAME_MS = 80;
|
|
1497
|
+
/** A step failed, and its line says why: nothing more to show. */
|
|
1498
|
+
var StepFailed = class extends Error {};
|
|
1499
|
+
var Steps = class {
|
|
1500
|
+
#out;
|
|
1501
|
+
#s;
|
|
1502
|
+
#steps;
|
|
1503
|
+
#keys;
|
|
1504
|
+
#describe;
|
|
1505
|
+
#frame = 0;
|
|
1506
|
+
#drawn = 0;
|
|
1507
|
+
/** The first step still redrawn: those above it have settled, and been printed for good. */
|
|
1508
|
+
#from = 0;
|
|
1509
|
+
#timer = null;
|
|
1510
|
+
#restoreCursor = () => this.#out.write("\x1B[?25h");
|
|
1511
|
+
/** Ctrl-C while a step runs, the terminal in its usual mode: the cursor back, then out, with nothing more to show. */
|
|
1512
|
+
#interrupt = () => {
|
|
1513
|
+
this.#stop();
|
|
1514
|
+
this.end();
|
|
1515
|
+
this.#out.write("\n");
|
|
1516
|
+
process.exit(130);
|
|
1517
|
+
};
|
|
1518
|
+
/**
|
|
1519
|
+
* `titles`, what each step does while it runs; `keys`, the keyboard for
|
|
1520
|
+
* questions; `describe`, an error as it may be shown, a secret it quotes
|
|
1521
|
+
* taken out.
|
|
1522
|
+
*/
|
|
1523
|
+
constructor(out, titles, keys, describe) {
|
|
1524
|
+
this.#out = out;
|
|
1525
|
+
this.#s = style(out);
|
|
1526
|
+
this.#keys = keys;
|
|
1527
|
+
this.#describe = describe;
|
|
1528
|
+
this.#steps = titles.map((text) => ({
|
|
1529
|
+
text,
|
|
1530
|
+
status: "pending",
|
|
1531
|
+
details: [],
|
|
1532
|
+
under: [],
|
|
1533
|
+
input: null,
|
|
1534
|
+
choice: null
|
|
1535
|
+
}));
|
|
1536
|
+
if (this.#s.ansi) {
|
|
1537
|
+
out.write("\x1B[?25l");
|
|
1538
|
+
process.once("exit", this.#restoreCursor);
|
|
1539
|
+
process.on("SIGINT", this.#interrupt);
|
|
1540
|
+
this.#draw();
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Run step `i`: its line is the outcome `work` returns, or the error it
|
|
1545
|
+
* throws, which comes out as StepFailed, unless it is Cancelled.
|
|
1546
|
+
*/
|
|
1547
|
+
async run(i, work) {
|
|
1548
|
+
const state = this.#steps[i];
|
|
1549
|
+
state.status = "running";
|
|
1550
|
+
this.#tick();
|
|
1551
|
+
const step = {
|
|
1552
|
+
note: (text) => {
|
|
1553
|
+
state.text = text;
|
|
1554
|
+
this.#draw();
|
|
1555
|
+
},
|
|
1556
|
+
under: (lines) => {
|
|
1557
|
+
state.under = lines;
|
|
1558
|
+
this.#draw();
|
|
1559
|
+
},
|
|
1560
|
+
ask: (question) => this.#ask(state, question),
|
|
1561
|
+
choose: (question, options) => this.#choose(state, question, options),
|
|
1562
|
+
paste: (prompt, accept, signal) => this.#paste(state, prompt, accept, signal)
|
|
1563
|
+
};
|
|
1564
|
+
try {
|
|
1565
|
+
const outcome = await work(step);
|
|
1566
|
+
Object.assign(state, typeof outcome === "string" ? {
|
|
1567
|
+
text: outcome,
|
|
1568
|
+
details: []
|
|
1569
|
+
} : outcome, {
|
|
1570
|
+
status: "done",
|
|
1571
|
+
under: [],
|
|
1572
|
+
input: null
|
|
1573
|
+
});
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
Object.assign(state, {
|
|
1576
|
+
under: [],
|
|
1577
|
+
input: null,
|
|
1578
|
+
choice: null
|
|
1579
|
+
});
|
|
1580
|
+
state.status = "failed";
|
|
1581
|
+
state.details = error instanceof Cancelled ? [] : [this.#describe(error)];
|
|
1582
|
+
if (error instanceof Cancelled) state.text = `${state.text}: cancelled`;
|
|
1583
|
+
this.#stop();
|
|
1584
|
+
throw error instanceof Cancelled ? error : new StepFailed(state.details[0]);
|
|
1585
|
+
}
|
|
1586
|
+
this.#stop();
|
|
1587
|
+
if (!this.#s.ansi) this.#out.write(`✓ ${state.text}\n${state.details.map((line) => ` ${line}\n`).join("")}`);
|
|
1588
|
+
else if (this.#steps.every((each) => each.status === "done")) this.end();
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Lines that stay, such as an address to open, printed whole, so that a
|
|
1592
|
+
* terminal can wrap them, and they can be copied or clicked. They go
|
|
1593
|
+
* under the steps done, above the one running: those done are printed
|
|
1594
|
+
* for good first, and only the rest is redrawn from then on.
|
|
1595
|
+
*/
|
|
1596
|
+
print(text) {
|
|
1597
|
+
if (!this.#s.ansi) {
|
|
1598
|
+
this.#out.write(`${text}\n`);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
const running = this.#steps.findIndex((each, i) => i >= this.#from && each.status !== "done");
|
|
1602
|
+
const settled = this.#lines(this.#from, running === -1 ? this.#steps.length : running);
|
|
1603
|
+
this.#from = running === -1 ? this.#steps.length : running;
|
|
1604
|
+
const up = this.#drawn > 0 ? `\x1b[${this.#drawn}F` : "";
|
|
1605
|
+
this.#out.write(`${up}\x1b[J${settled.map((line) => `${line}\n`).join("")}${text}\n`);
|
|
1606
|
+
this.#drawn = 0;
|
|
1607
|
+
this.#draw();
|
|
1608
|
+
}
|
|
1609
|
+
/** A remark that stays, dim, wrapped to the terminal's width. */
|
|
1610
|
+
aside(text) {
|
|
1611
|
+
this.print(this.#s.dim(wrap(text, Math.max(20, (this.#out.columns ?? 80) - 3)).map((line) => ` ${line}`).join("\n")));
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* An address to open, after what it is for: on one line when both fit;
|
|
1615
|
+
* otherwise the address on a line of its own, from the first column, so
|
|
1616
|
+
* that the terminal wraps it in one piece, to copy whole.
|
|
1617
|
+
*/
|
|
1618
|
+
link(label, address) {
|
|
1619
|
+
const fits = width(` ${label} ${address}`) < (this.#out.columns ?? 80);
|
|
1620
|
+
this.print(fits ? ` ${this.#s.dim(label)} ${address}` : ` ${this.#s.dim(label)}\n${address}`);
|
|
1621
|
+
}
|
|
1622
|
+
/** Leave the list as it stands, the cursor below it. */
|
|
1623
|
+
end() {
|
|
1624
|
+
this.#stop();
|
|
1625
|
+
this.#release();
|
|
1626
|
+
}
|
|
1627
|
+
/** The cursor back, and Ctrl-C the terminal's again. */
|
|
1628
|
+
#release() {
|
|
1629
|
+
if (this.#s.ansi) {
|
|
1630
|
+
this.#restoreCursor();
|
|
1631
|
+
process.off("exit", this.#restoreCursor);
|
|
1632
|
+
process.off("SIGINT", this.#interrupt);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
async #ask(state, question) {
|
|
1636
|
+
const keys = this.#keys();
|
|
1637
|
+
if (keys === null) return false;
|
|
1638
|
+
this.#stop();
|
|
1639
|
+
const before = state.text;
|
|
1640
|
+
state.text = `${question} ${this.#s.dim("y/N")}`;
|
|
1641
|
+
state.status = "asking";
|
|
1642
|
+
this.#draw();
|
|
1643
|
+
try {
|
|
1644
|
+
return await yes(keys);
|
|
1645
|
+
} finally {
|
|
1646
|
+
state.text = before;
|
|
1647
|
+
state.status = "running";
|
|
1648
|
+
this.#tick();
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
async #choose(state, question, options) {
|
|
1652
|
+
const keys = this.#keys();
|
|
1653
|
+
if (keys === null) return 0;
|
|
1654
|
+
this.#stop();
|
|
1655
|
+
const before = state.text;
|
|
1656
|
+
Object.assign(state, {
|
|
1657
|
+
text: `${question} ${this.#s.dim("↑↓ then Enter")}`,
|
|
1658
|
+
status: "asking",
|
|
1659
|
+
choice: {
|
|
1660
|
+
options,
|
|
1661
|
+
at: 0
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
let cancelled = false;
|
|
1665
|
+
try {
|
|
1666
|
+
const chosen = readKeys(keys, (key) => {
|
|
1667
|
+
const choice = state.choice;
|
|
1668
|
+
if (key.ctrl && key.name === "c") cancelled = true;
|
|
1669
|
+
else if (key.name === "return" || key.name === "enter") return true;
|
|
1670
|
+
else if (key.name === "up" || key.name === "k") choice.at = (choice.at - 1 + options.length) % options.length;
|
|
1671
|
+
else if (key.name === "down" || key.name === "j" || key.name === "tab") choice.at = (choice.at + 1) % options.length;
|
|
1672
|
+
if (cancelled) return true;
|
|
1673
|
+
this.#draw();
|
|
1674
|
+
return false;
|
|
1675
|
+
});
|
|
1676
|
+
this.#draw();
|
|
1677
|
+
await chosen;
|
|
1678
|
+
if (cancelled) throw new Cancelled();
|
|
1679
|
+
return state.choice.at;
|
|
1680
|
+
} finally {
|
|
1681
|
+
Object.assign(state, {
|
|
1682
|
+
text: before,
|
|
1683
|
+
status: "running",
|
|
1684
|
+
choice: null
|
|
1685
|
+
});
|
|
1686
|
+
this.#tick();
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
async #paste(state, prompt, accept, signal) {
|
|
1690
|
+
const keys = this.#keys();
|
|
1691
|
+
if (keys === null) {
|
|
1692
|
+
await new Promise((resolve) => signal.aborted ? resolve() : signal.addEventListener("abort", () => resolve()));
|
|
1693
|
+
return null;
|
|
1694
|
+
}
|
|
1695
|
+
state.input = {
|
|
1696
|
+
prompt,
|
|
1697
|
+
typed: "",
|
|
1698
|
+
error: null
|
|
1699
|
+
};
|
|
1700
|
+
let taken = null;
|
|
1701
|
+
let cancelled = false;
|
|
1702
|
+
try {
|
|
1703
|
+
const pasted = readKeys(keys, async (key, sequence) => {
|
|
1704
|
+
const input = state.input;
|
|
1705
|
+
const next = edit(input.typed, key, sequence);
|
|
1706
|
+
if (next === "cancel") cancelled = true;
|
|
1707
|
+
else if (next === "submit" && input.typed.trim() !== "") {
|
|
1708
|
+
const text = input.typed.trim();
|
|
1709
|
+
input.typed = "";
|
|
1710
|
+
input.error = await accept(text);
|
|
1711
|
+
if (input.error === null) taken = text;
|
|
1712
|
+
} else if (next !== "submit") {
|
|
1713
|
+
input.typed = next;
|
|
1714
|
+
input.error = null;
|
|
1715
|
+
}
|
|
1716
|
+
this.#draw();
|
|
1717
|
+
return cancelled || taken !== null;
|
|
1718
|
+
}, signal);
|
|
1719
|
+
this.#draw();
|
|
1720
|
+
await pasted;
|
|
1721
|
+
} finally {
|
|
1722
|
+
state.input = null;
|
|
1723
|
+
this.#draw();
|
|
1724
|
+
}
|
|
1725
|
+
if (cancelled) throw new Cancelled();
|
|
1726
|
+
return taken;
|
|
1727
|
+
}
|
|
1728
|
+
#tick() {
|
|
1729
|
+
this.#draw();
|
|
1730
|
+
if (this.#s.ansi && this.#timer === null) this.#timer = setInterval(() => {
|
|
1731
|
+
this.#frame = (this.#frame + 1) % SPINNER.length;
|
|
1732
|
+
this.#draw();
|
|
1733
|
+
}, FRAME_MS).unref();
|
|
1734
|
+
}
|
|
1735
|
+
#stop() {
|
|
1736
|
+
if (this.#timer !== null) clearInterval(this.#timer);
|
|
1737
|
+
this.#timer = null;
|
|
1738
|
+
this.#draw();
|
|
1739
|
+
if (!this.#s.ansi) {
|
|
1740
|
+
const failed = this.#steps.find((each) => each.status === "failed");
|
|
1741
|
+
if (failed !== void 0) this.#out.write(`✗ ${failed.text}\n${failed.details.map((line) => ` ${line}\n`).join("")}`);
|
|
1742
|
+
} else if (this.#steps.some((each) => each.status === "failed")) this.#release();
|
|
1743
|
+
}
|
|
1744
|
+
#draw() {
|
|
1745
|
+
if (!this.#s.ansi) return;
|
|
1746
|
+
const lines = this.#lines(this.#from, this.#steps.length);
|
|
1747
|
+
const up = this.#drawn > 0 ? `\x1b[${this.#drawn}F` : "";
|
|
1748
|
+
this.#out.write(`${up}${lines.map((line) => `${line}\x1b[K\n`).join("")}\x1b[J`);
|
|
1749
|
+
this.#drawn = lines.length;
|
|
1750
|
+
}
|
|
1751
|
+
/** Steps `from` to `to`, as lines cut to the terminal's width. */
|
|
1752
|
+
#lines(from, to) {
|
|
1753
|
+
const s = this.#s;
|
|
1754
|
+
const columns = Math.max(20, (this.#out.columns ?? 80) - 1);
|
|
1755
|
+
return this.#steps.slice(from, to).flatMap(({ text, status, details, under, input, choice }) => {
|
|
1756
|
+
const icon = {
|
|
1757
|
+
pending: s.dim("·"),
|
|
1758
|
+
running: s.accent(SPINNER[this.#frame]),
|
|
1759
|
+
asking: s.accent("?"),
|
|
1760
|
+
done: s.green("✓"),
|
|
1761
|
+
failed: s.red("✗")
|
|
1762
|
+
}[status];
|
|
1763
|
+
const line = status === "pending" ? s.dim(text) : status === "failed" ? s.red(text) : text;
|
|
1764
|
+
const below = status === "failed" ? details.flatMap((detail) => wrap(detail, columns - 4)) : details.map(s.dim);
|
|
1765
|
+
const live = [
|
|
1766
|
+
...under.map((text) => ` ${s.dim(text)}`),
|
|
1767
|
+
...choice === null ? [] : choice.options.map((option, i) => i === choice.at ? ` ${s.accent("›")} ${s.accent(option)}` : ` ${option}`),
|
|
1768
|
+
...input === null ? [] : [
|
|
1769
|
+
` ${input.prompt}`,
|
|
1770
|
+
` ${s.accent("›")} ${"•".repeat(input.typed.length)}`,
|
|
1771
|
+
...input.error === null ? [] : [` ${s.red(input.error)}`]
|
|
1772
|
+
]
|
|
1773
|
+
];
|
|
1774
|
+
return [
|
|
1775
|
+
` ${icon} ${line}`,
|
|
1776
|
+
...below.map((detail) => ` ${detail}`),
|
|
1777
|
+
...live
|
|
1778
|
+
];
|
|
1779
|
+
}).map((line) => truncate(line, columns));
|
|
1780
|
+
}
|
|
1781
|
+
};
|
|
1782
|
+
//#endregion
|
|
1783
|
+
//#region src/browser.ts
|
|
1784
|
+
/** Open `url` in this machine's browser, if it has one. Silent either way. */
|
|
1785
|
+
function openBrowser(url, platform = process.platform) {
|
|
1786
|
+
const [command, ...args] = platform === "darwin" ? ["open", url] : platform === "win32" ? [
|
|
1787
|
+
"cmd",
|
|
1788
|
+
"/c",
|
|
1789
|
+
"start",
|
|
1790
|
+
"",
|
|
1791
|
+
url
|
|
1792
|
+
] : ["xdg-open", url];
|
|
1793
|
+
try {
|
|
1794
|
+
const child = spawn(command, args, {
|
|
1795
|
+
stdio: "ignore",
|
|
1796
|
+
detached: true
|
|
1797
|
+
});
|
|
1798
|
+
child.on("error", () => {});
|
|
1799
|
+
child.unref();
|
|
1800
|
+
} catch {}
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* `text` as a callback address on this machine, or why not: it must name
|
|
1804
|
+
* localhost or 127.0.0.1, `port` and `path`, and carry each of `params`.
|
|
1805
|
+
* Only such an address is ever fetched, so a paste can never send this
|
|
1806
|
+
* machine anywhere else.
|
|
1807
|
+
*/
|
|
1808
|
+
function localCallback(text, port, path, params) {
|
|
1809
|
+
let url;
|
|
1810
|
+
try {
|
|
1811
|
+
url = new URL(text.trim());
|
|
1812
|
+
} catch {
|
|
1813
|
+
return "that is not an address: paste the whole one your browser shows";
|
|
1814
|
+
}
|
|
1815
|
+
if (url.protocol !== "http:" || url.hostname !== "localhost" && url.hostname !== "127.0.0.1") return `that is not a localhost address: paste the one that starts http://localhost:${port}`;
|
|
1816
|
+
if (url.port !== String(port) || url.pathname !== path) return `that is not this step's address: it starts http://localhost:${port}${path}`;
|
|
1817
|
+
const missing = params.filter((param) => !url.searchParams.get(param));
|
|
1818
|
+
if (missing.length > 0) return `that address has no ${missing.join(" or ")}: paste it whole`;
|
|
1819
|
+
return url;
|
|
1820
|
+
}
|
|
1821
|
+
//#endregion
|
|
1822
|
+
//#region src/cloudflare.ts
|
|
1823
|
+
/** Where wrangler's login listens for its callback, unless its link says otherwise. */
|
|
1824
|
+
const WRANGLER_CALLBACK = "http://localhost:8976/oauth/callback";
|
|
1825
|
+
/** Each wrangler running, by its process group: none outlives setup, nor holds on to a port after it. */
|
|
1826
|
+
const running = /* @__PURE__ */ new Set();
|
|
1827
|
+
let watching = false;
|
|
1828
|
+
/** Stop a wrangler, and all it started: its group, sh, cat and wrangler alike. */
|
|
1829
|
+
function stopGroup(pid) {
|
|
1830
|
+
try {
|
|
1831
|
+
process.kill(-pid, "SIGTERM");
|
|
1832
|
+
} catch {}
|
|
1833
|
+
}
|
|
1834
|
+
/** Stop every wrangler still running: on any way out of setup, its exit included. */
|
|
1835
|
+
function stopWranglers() {
|
|
1836
|
+
for (const pid of running) stopGroup(pid);
|
|
1837
|
+
running.clear();
|
|
1838
|
+
}
|
|
1839
|
+
/**
|
|
1840
|
+
* From the first wrangler on, setup's ends stop them all: its exit, and the
|
|
1841
|
+
* signals that end it without one. A closed terminal or a SIGTERM reaches
|
|
1842
|
+
* setup alone, wrangler being in a session of its own.
|
|
1843
|
+
*/
|
|
1844
|
+
function watch() {
|
|
1845
|
+
if (watching) return;
|
|
1846
|
+
watching = true;
|
|
1847
|
+
process.once("exit", stopWranglers);
|
|
1848
|
+
for (const [signal, code] of [["SIGHUP", 129], ["SIGTERM", 143]]) process.once(signal, () => {
|
|
1849
|
+
stopWranglers();
|
|
1850
|
+
process.exit(code);
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
function deploymentWrangler(dir) {
|
|
1854
|
+
const bin = join(dir, "node_modules", ".bin", process.platform === "win32" ? "wrangler.cmd" : "wrangler");
|
|
1855
|
+
return (args, { input, env, onOutput, signal } = {}) => new Promise((resolve, reject) => {
|
|
1856
|
+
const [command, argv] = input === void 0 ? [bin, args] : ["/bin/sh", [
|
|
1857
|
+
"-c",
|
|
1858
|
+
"cat | \"$0\" \"$@\"",
|
|
1859
|
+
bin,
|
|
1860
|
+
...args
|
|
1861
|
+
]];
|
|
1862
|
+
const child = spawn(command, argv, {
|
|
1863
|
+
cwd: dir,
|
|
1864
|
+
env: {
|
|
1865
|
+
...process.env,
|
|
1866
|
+
...env,
|
|
1867
|
+
WRANGLER_SEND_METRICS: "false",
|
|
1868
|
+
NO_COLOR: "1"
|
|
1869
|
+
},
|
|
1870
|
+
stdio: [
|
|
1871
|
+
"pipe",
|
|
1872
|
+
"pipe",
|
|
1873
|
+
"pipe"
|
|
1874
|
+
],
|
|
1875
|
+
detached: process.platform !== "win32"
|
|
1876
|
+
});
|
|
1877
|
+
const pid = child.pid;
|
|
1878
|
+
if (pid !== void 0 && process.platform !== "win32") {
|
|
1879
|
+
watch();
|
|
1880
|
+
running.add(pid);
|
|
1881
|
+
const stop = () => running.delete(pid) ? stopGroup(pid) : void 0;
|
|
1882
|
+
if (signal?.aborted) stop();
|
|
1883
|
+
else signal?.addEventListener("abort", stop, { once: true });
|
|
1884
|
+
child.on("close", () => {
|
|
1885
|
+
running.delete(pid);
|
|
1886
|
+
signal?.removeEventListener("abort", stop);
|
|
1887
|
+
});
|
|
1888
|
+
} else if (signal !== void 0) signal.addEventListener("abort", () => child.kill(), { once: true });
|
|
1889
|
+
let stdout = "";
|
|
1890
|
+
let stderr = "";
|
|
1891
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => {
|
|
1892
|
+
stdout += chunk;
|
|
1893
|
+
onOutput?.(chunk);
|
|
1894
|
+
});
|
|
1895
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => {
|
|
1896
|
+
stderr += chunk;
|
|
1897
|
+
onOutput?.(chunk);
|
|
1898
|
+
});
|
|
1899
|
+
child.on("error", reject);
|
|
1900
|
+
child.on("close", (code) => resolve({
|
|
1901
|
+
code,
|
|
1902
|
+
stdout,
|
|
1903
|
+
stderr
|
|
1904
|
+
}));
|
|
1905
|
+
child.stdin.on("error", () => {});
|
|
1906
|
+
child.stdin.end(input ?? "");
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* The tail of what a command said, for an error: its errors, which wrangler
|
|
1911
|
+
* writes to stderr, else what it printed; without its banner or log line,
|
|
1912
|
+
* nor the colours wrangler gives them even in a pipe.
|
|
1913
|
+
*/
|
|
1914
|
+
function said(run) {
|
|
1915
|
+
return (run.stderr.trim() === "" ? run.stdout : run.stderr).replace(/\x1b\[[0-9;]*m/g, "").split("\n").filter((line) => line.trim() !== "" && !line.startsWith("🪵")).slice(-3).map((line) => line.trim()).join(" ");
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* A token for Cloudflare's API: CLOUDFLARE_API_TOKEN when set, else
|
|
1919
|
+
* wrangler's own, after its browser login when there is none yet.
|
|
1920
|
+
*/
|
|
1921
|
+
async function cloudflareToken(wrangler, step, link) {
|
|
1922
|
+
const read = async () => {
|
|
1923
|
+
const run = await wrangler([
|
|
1924
|
+
"auth",
|
|
1925
|
+
"token",
|
|
1926
|
+
"--json"
|
|
1927
|
+
]);
|
|
1928
|
+
if (run.code !== 0) return null;
|
|
1929
|
+
try {
|
|
1930
|
+
return JSON.parse(run.stdout).token ?? null;
|
|
1931
|
+
} catch {
|
|
1932
|
+
return null;
|
|
1933
|
+
}
|
|
1934
|
+
};
|
|
1935
|
+
const ready = await read();
|
|
1936
|
+
if (ready !== null) return ready;
|
|
1937
|
+
await login(wrangler, step, link);
|
|
1938
|
+
const token = await read();
|
|
1939
|
+
if (token === null) throw new Error("signed in to Cloudflare, but wrangler gave no token");
|
|
1940
|
+
return token;
|
|
1941
|
+
}
|
|
1942
|
+
/** Where a sign-in link sends the browser back to: its `redirect_uri`, when that is on this machine. */
|
|
1943
|
+
function callbackOf(link) {
|
|
1944
|
+
try {
|
|
1945
|
+
const redirect = new URL(new URL(link).searchParams.get("redirect_uri") ?? "");
|
|
1946
|
+
if (redirect.protocol === "http:" && (redirect.hostname === "localhost" || redirect.hostname === "127.0.0.1")) return redirect;
|
|
1947
|
+
} catch {}
|
|
1948
|
+
return new URL(WRANGLER_CALLBACK);
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* wrangler's browser login, finished by whichever comes first: its own
|
|
1952
|
+
* listener hearing the browser's callback, or the callback address pasted
|
|
1953
|
+
* here, which setup requests itself, on this machine, where the listener is.
|
|
1954
|
+
*/
|
|
1955
|
+
async function login(wrangler, step, link) {
|
|
1956
|
+
step.note("Cloudflare: approve the sign-in in your browser");
|
|
1957
|
+
let callback = null;
|
|
1958
|
+
let output = "";
|
|
1959
|
+
let ended = false;
|
|
1960
|
+
let linked;
|
|
1961
|
+
const linkPrinted = new Promise((resolve) => linked = resolve);
|
|
1962
|
+
const done = new AbortController();
|
|
1963
|
+
const stop = new AbortController();
|
|
1964
|
+
const login = wrangler(["login", "--browser=false"], {
|
|
1965
|
+
signal: stop.signal,
|
|
1966
|
+
onOutput: (text) => {
|
|
1967
|
+
output += text;
|
|
1968
|
+
const address = /Visit this link to authenticate: (\S+)\r?\n/.exec(output)?.[1];
|
|
1969
|
+
if (address === void 0 || callback !== null) return;
|
|
1970
|
+
callback = callbackOf(address);
|
|
1971
|
+
linked();
|
|
1972
|
+
link("If no browser opened, sign in to Cloudflare at", address);
|
|
1973
|
+
openBrowser(address);
|
|
1974
|
+
}
|
|
1975
|
+
}).finally(() => {
|
|
1976
|
+
ended = true;
|
|
1977
|
+
linked();
|
|
1978
|
+
done.abort();
|
|
1979
|
+
});
|
|
1980
|
+
try {
|
|
1981
|
+
await step.paste("If your browser shows an error at a localhost address, paste that address here:", async (text) => {
|
|
1982
|
+
await linkPrinted;
|
|
1983
|
+
const listening = callback ?? new URL(WRANGLER_CALLBACK);
|
|
1984
|
+
const url = localCallback(text, Number(listening.port), listening.pathname, ["code", "state"]);
|
|
1985
|
+
if (typeof url === "string") return url;
|
|
1986
|
+
url.host = listening.host;
|
|
1987
|
+
return replay(url, () => ended);
|
|
1988
|
+
}, done.signal);
|
|
1989
|
+
const run = await login;
|
|
1990
|
+
if (run.code !== 0) throw new Error(`Cloudflare sign-in failed: ${said(run)}`);
|
|
1991
|
+
} finally {
|
|
1992
|
+
if (!ended) {
|
|
1993
|
+
stop.abort();
|
|
1994
|
+
await login.catch(() => {});
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
/** How long a pasted address is tried again while wrangler, still running, does not answer at it: it prints its link before it listens. */
|
|
1999
|
+
const REPLAY_FOR_MS = 2e3;
|
|
2000
|
+
/**
|
|
2001
|
+
* Request `url` here, for wrangler's listener, on a connection that closes
|
|
2002
|
+
* after it, so that wrangler, done, exits at once. Null once it answered;
|
|
2003
|
+
* else why not, for the person pasting to read, and paste again.
|
|
2004
|
+
*/
|
|
2005
|
+
async function replay(url, ended) {
|
|
2006
|
+
for (const start = Date.now();;) try {
|
|
2007
|
+
await new Promise((resolve, reject) => get(url, { agent: false }, (response) => response.resume().on("end", resolve)).on("error", reject));
|
|
2008
|
+
return null;
|
|
2009
|
+
} catch {
|
|
2010
|
+
if (ended()) return "wrangler's sign-in has ended, and nothing waits at that address: run setup again";
|
|
2011
|
+
if (Date.now() - start >= REPLAY_FOR_MS) return "wrangler is not answering at that address yet: paste it again in a moment";
|
|
2012
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
var CloudflareError = class extends Error {
|
|
2016
|
+
status;
|
|
2017
|
+
constructor(message, status) {
|
|
2018
|
+
super(message);
|
|
2019
|
+
this.status = status;
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2022
|
+
/** Cloudflare's API, under a token it never logs nor shows. */
|
|
2023
|
+
var CloudflareApi = class {
|
|
2024
|
+
#token;
|
|
2025
|
+
#base;
|
|
2026
|
+
/** `base`, Cloudflare's, or what CLOUDFLARE_API_BASE_URL names, as wrangler reads it too. */
|
|
2027
|
+
constructor(token, base = process.env.CLOUDFLARE_API_BASE_URL ?? "https://api.cloudflare.com/client/v4") {
|
|
2028
|
+
this.#token = token;
|
|
2029
|
+
this.#base = base.replace(/\/$/, "");
|
|
2030
|
+
}
|
|
2031
|
+
async #send(method, path, body) {
|
|
2032
|
+
const response = await fetch(`${this.#base}${path}`, {
|
|
2033
|
+
method,
|
|
2034
|
+
headers: {
|
|
2035
|
+
authorization: `Bearer ${this.#token}`,
|
|
2036
|
+
"content-type": "application/json"
|
|
2037
|
+
},
|
|
2038
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
2039
|
+
});
|
|
2040
|
+
const answer = await response.json().catch(() => ({}));
|
|
2041
|
+
if (!response.ok || answer.success === false) {
|
|
2042
|
+
const why = (answer.errors ?? []).map((error) => `${error.message ?? "error"}${error.code === void 0 ? "" : ` (${error.code})`}`).join("; ");
|
|
2043
|
+
throw new CloudflareError(`Cloudflare answered ${response.status} to ${method} ${path.replace(/\?.*$/, "")}${why === "" ? "" : `: ${why}`}`, response.status);
|
|
2044
|
+
}
|
|
2045
|
+
return {
|
|
2046
|
+
result: answer.result,
|
|
2047
|
+
pages: answer.result_info?.total_pages ?? 1
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
async #call(method, path, body) {
|
|
2051
|
+
return (await this.#send(method, path, body)).result;
|
|
2052
|
+
}
|
|
2053
|
+
/** Every page of a list: Cloudflare gives 50 at most to a page. */
|
|
2054
|
+
async #all(path) {
|
|
2055
|
+
const all = [];
|
|
2056
|
+
for (let page = 1;; page += 1) {
|
|
2057
|
+
const { result, pages } = await this.#send("GET", `${path}${path.includes("?") ? "&" : "?"}per_page=50&page=${page}`);
|
|
2058
|
+
all.push(...result);
|
|
2059
|
+
if (page >= pages || result.length === 0) return all;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
accounts() {
|
|
2063
|
+
return this.#all("/accounts");
|
|
2064
|
+
}
|
|
2065
|
+
zones(account) {
|
|
2066
|
+
return this.#all(`/zones?account.id=${account}`);
|
|
2067
|
+
}
|
|
2068
|
+
/** The signed-in person's email, when the token may read it. */
|
|
2069
|
+
async email() {
|
|
2070
|
+
try {
|
|
2071
|
+
return (await this.#call("GET", "/user")).email ?? null;
|
|
2072
|
+
} catch {
|
|
2073
|
+
return null;
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
hyperdriveConfigs(account) {
|
|
2077
|
+
return this.#call("GET", `/accounts/${account}/hyperdrive/configs`);
|
|
2078
|
+
}
|
|
2079
|
+
/** A Hyperdrive config with caching off: a revoked session must stop at once. The password goes in the body. */
|
|
2080
|
+
async createHyperdrive(account, name, origin) {
|
|
2081
|
+
return (await this.#call("POST", `/accounts/${account}/hyperdrive/configs`, {
|
|
2082
|
+
name,
|
|
2083
|
+
origin: {
|
|
2084
|
+
scheme: "postgres",
|
|
2085
|
+
...origin
|
|
2086
|
+
},
|
|
2087
|
+
caching: { disabled: true }
|
|
2088
|
+
})).id;
|
|
2089
|
+
}
|
|
2090
|
+
async updateHyperdrive(account, id, name, origin) {
|
|
2091
|
+
await this.#call("PUT", `/accounts/${account}/hyperdrive/configs/${id}`, {
|
|
2092
|
+
name,
|
|
2093
|
+
origin: {
|
|
2094
|
+
scheme: "postgres",
|
|
2095
|
+
...origin
|
|
2096
|
+
},
|
|
2097
|
+
caching: { disabled: true }
|
|
2098
|
+
});
|
|
2099
|
+
}
|
|
2100
|
+
async disableCaching(account, id) {
|
|
2101
|
+
await this.#call("PATCH", `/accounts/${account}/hyperdrive/configs/${id}`, { caching: { disabled: true } });
|
|
2102
|
+
}
|
|
2103
|
+
/** What a call answers, or null when what it names does not exist. */
|
|
2104
|
+
async #found(path) {
|
|
2105
|
+
try {
|
|
2106
|
+
return await this.#call("GET", path);
|
|
2107
|
+
} catch (error) {
|
|
2108
|
+
if (error instanceof CloudflareError && error.status === 404) return null;
|
|
2109
|
+
throw error;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
/** The names of a Worker's secrets, never their values; null when the Worker does not exist yet. */
|
|
2113
|
+
async secretNames(account, script) {
|
|
2114
|
+
const listed = await this.#found(`/accounts/${account}/workers/scripts/${script}/secrets`);
|
|
2115
|
+
return listed === null ? null : listed.map(({ name }) => name);
|
|
2116
|
+
}
|
|
2117
|
+
/** A Worker's bindings, which say whose it is; null when there is no such Worker. */
|
|
2118
|
+
async bindings(account, script) {
|
|
2119
|
+
const settings = await this.#found(`/accounts/${account}/workers/scripts/${script}/settings`);
|
|
2120
|
+
return settings === null ? null : settings.bindings ?? [];
|
|
2121
|
+
}
|
|
2122
|
+
};
|
|
2123
|
+
/** A login's URL as a Hyperdrive origin: Hyperdrive makes its own TLS connection, so the URL's parameters stay behind. */
|
|
2124
|
+
function originOf(url) {
|
|
2125
|
+
const parsed = new URL(url);
|
|
2126
|
+
return {
|
|
2127
|
+
host: parsed.hostname,
|
|
2128
|
+
port: Number(parsed.port || 5432),
|
|
2129
|
+
database: decodeURIComponent(parsed.pathname.slice(1)),
|
|
2130
|
+
user: decodeURIComponent(parsed.username),
|
|
2131
|
+
password: decodeURIComponent(parsed.password)
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Deploy a Worker with wrangler, under the account chosen. `secrets`, when
|
|
2136
|
+
* given, go on wrangler's stdin, as its secrets file: never in a file, nor
|
|
2137
|
+
* in its arguments. A new Worker needs them at its first deploy.
|
|
2138
|
+
*/
|
|
2139
|
+
async function deployWorker(wrangler, config, account, secrets) {
|
|
2140
|
+
const some = Object.keys(secrets).length > 0;
|
|
2141
|
+
const run = await wrangler([
|
|
2142
|
+
"deploy",
|
|
2143
|
+
"-c",
|
|
2144
|
+
config,
|
|
2145
|
+
...some ? ["--secrets-file", "/dev/stdin"] : []
|
|
2146
|
+
], {
|
|
2147
|
+
input: some ? JSON.stringify(secrets) : void 0,
|
|
2148
|
+
env: { CLOUDFLARE_ACCOUNT_ID: account }
|
|
2149
|
+
});
|
|
2150
|
+
if (run.code !== 0) throw new Error(`wrangler could not deploy ${config}: ${said(run)}`);
|
|
2151
|
+
}
|
|
2152
|
+
//#endregion
|
|
2153
|
+
//#region src/github-app.ts
|
|
2154
|
+
const GITHUB = {
|
|
2155
|
+
web: "https://github.com",
|
|
2156
|
+
api: "https://api.github.com"
|
|
2157
|
+
};
|
|
2158
|
+
/** GitHub's limit on an app's name. */
|
|
2159
|
+
const NAME_LIMIT = 34;
|
|
2160
|
+
/** `coffre-` and the address, its dots as dashes, as long as GitHub allows: unique, as GitHub needs, and editable on its page. */
|
|
2161
|
+
function appName(address) {
|
|
2162
|
+
return `coffre-${address.replace(/\./g, "-")}`.slice(0, NAME_LIMIT).replace(/-+$/, "");
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* The app coffre's sign-in needs: private, its callback coffre's, the one
|
|
2166
|
+
* permission to read a person's email addresses, and no webhook. A
|
|
2167
|
+
* manifest names that permission `emails`, as GitHub's form does, not
|
|
2168
|
+
* `email_addresses`, as its API does: GitHub refuses the latter here.
|
|
2169
|
+
*/
|
|
2170
|
+
function appManifest(publicUrl, redirect) {
|
|
2171
|
+
return {
|
|
2172
|
+
name: appName(new URL(publicUrl).host),
|
|
2173
|
+
url: publicUrl,
|
|
2174
|
+
redirect_url: redirect,
|
|
2175
|
+
callback_urls: [`${publicUrl}/auth/callback/github`],
|
|
2176
|
+
public: false,
|
|
2177
|
+
default_permissions: { emails: "read" },
|
|
2178
|
+
hook_attributes: {
|
|
2179
|
+
url: publicUrl,
|
|
2180
|
+
active: false
|
|
2181
|
+
}
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
const escape = (text) => text.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
2185
|
+
/** A page of a few words, in the browser's own type. */
|
|
2186
|
+
function page(title, body) {
|
|
2187
|
+
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title>
|
|
2188
|
+
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:32rem;margin:20vh auto;padding:0 1.5rem;color:#1f2328}h1{font-size:1.25rem;font-weight:600}p{color:#59636e}button{font:inherit;padding:.4rem 1rem}</style>
|
|
2189
|
+
${body}`;
|
|
2190
|
+
}
|
|
2191
|
+
/** The page that posts the manifest to GitHub, at once. */
|
|
2192
|
+
function manifestPage(github, manifest, state) {
|
|
2193
|
+
const action = `${github.web}/settings/apps/new?state=${encodeURIComponent(state)}`;
|
|
2194
|
+
return page("coffre: to GitHub", `<h1>Taking you to GitHub</h1><p>to create coffre's GitHub App.</p>
|
|
2195
|
+
<form method="post" action="${escape(action)}"><input type="hidden" name="manifest" value="${escape(JSON.stringify(manifest))}"><noscript><button>Continue to GitHub</button></noscript></form>
|
|
2196
|
+
<script>document.forms[0].submit()<\/script>`);
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* The same form as an address, for a browser on another machine: as short
|
|
2200
|
+
* as it can be, and readable, so that it plainly goes to GitHub and nowhere
|
|
2201
|
+
* else. A browser takes it typed or pasted, never from a link.
|
|
2202
|
+
*/
|
|
2203
|
+
function manifestAddress(github, manifest, state) {
|
|
2204
|
+
const action = `${github.web}/settings/apps/new?state=${encodeURIComponent(state)}`;
|
|
2205
|
+
const value = JSON.stringify(manifest).replace(/&/g, "&").replace(/'/g, "'");
|
|
2206
|
+
return `data:text/html,${`<form method=post action="${escape(action)}"><input type=hidden name=manifest value='${value}'></form><script>document.forms[0].submit()<\/script>`.replace(/[%#\s]/g, (c) => encodeURIComponent(c))}`;
|
|
2207
|
+
}
|
|
2208
|
+
/** GitHub's code for the app it made, as the app's client ID and secret. The code lasts an hour, and works once. */
|
|
2209
|
+
async function convert(github, code) {
|
|
2210
|
+
const response = await fetch(`${github.api}/app-manifests/${encodeURIComponent(code)}/conversions`, {
|
|
2211
|
+
method: "POST",
|
|
2212
|
+
headers: {
|
|
2213
|
+
accept: "application/vnd.github+json",
|
|
2214
|
+
"user-agent": "coffre-setup",
|
|
2215
|
+
"x-github-api-version": "2022-11-28"
|
|
2216
|
+
}
|
|
2217
|
+
});
|
|
2218
|
+
const made = await response.json().catch(() => ({}));
|
|
2219
|
+
if (!response.ok || made.client_id === void 0 || made.client_secret === void 0) throw new Error(`GitHub answered ${response.status}${made.message === void 0 ? "" : `: ${made.message}`}`);
|
|
2220
|
+
return {
|
|
2221
|
+
clientId: made.client_id,
|
|
2222
|
+
clientSecret: made.client_secret,
|
|
2223
|
+
slug: made.slug ?? "",
|
|
2224
|
+
url: made.html_url ?? ""
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
function listen(server) {
|
|
2228
|
+
return new Promise((resolve, reject) => {
|
|
2229
|
+
server.once("error", reject);
|
|
2230
|
+
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* coffre's GitHub App, made in the browser: on this machine, through
|
|
2235
|
+
* setup's page and its callback; on another, through the data: address,
|
|
2236
|
+
* and the callback's address pasted back. Whichever comes first.
|
|
2237
|
+
*/
|
|
2238
|
+
async function createGitHubApp(step, say, github, publicUrl) {
|
|
2239
|
+
const state = randomBytes(16).toString("base64url");
|
|
2240
|
+
const done = new AbortController();
|
|
2241
|
+
let app = null;
|
|
2242
|
+
let manifest = {};
|
|
2243
|
+
/** The way back from GitHub, by either road: the app, or why not. */
|
|
2244
|
+
const take = async (url) => {
|
|
2245
|
+
if (app !== null) return null;
|
|
2246
|
+
if (url.searchParams.get("state") !== state) return "that address is from another run of setup: create the app from this one";
|
|
2247
|
+
try {
|
|
2248
|
+
app = await convert(github, url.searchParams.get("code"));
|
|
2249
|
+
} catch (error) {
|
|
2250
|
+
return `GitHub did not take its code: ${error.message}`;
|
|
2251
|
+
}
|
|
2252
|
+
done.abort();
|
|
2253
|
+
return null;
|
|
2254
|
+
};
|
|
2255
|
+
const server = createServer((request, response) => {
|
|
2256
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
2257
|
+
const send = (status, html) => response.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(html);
|
|
2258
|
+
if (url.pathname === "/") return send(200, manifestPage(github, manifest, state));
|
|
2259
|
+
if (url.pathname !== "/created") return send(404, page("coffre", "<h1>Not here</h1>"));
|
|
2260
|
+
take(url).then((error) => error === null ? send(200, page("coffre's GitHub App", "<h1>coffre's GitHub App is made</h1><p>Back to your terminal: setup carries on there.</p>")) : send(400, page("coffre", `<h1>Not made</h1><p>${escape(error)}</p>`)));
|
|
2261
|
+
});
|
|
2262
|
+
const port = await listen(server);
|
|
2263
|
+
try {
|
|
2264
|
+
const here = `http://127.0.0.1:${port}/`;
|
|
2265
|
+
manifest = appManifest(publicUrl, `${here}created`);
|
|
2266
|
+
say.aside("coffre's GitHub App may read the email addresses of whoever signs in with it, and nothing else.");
|
|
2267
|
+
say.link("If no browser opened, create it at", here);
|
|
2268
|
+
say.link("From a browser on another machine, open this address instead:", manifestAddress(github, manifest, state));
|
|
2269
|
+
openBrowser(here);
|
|
2270
|
+
step.note("GitHub: create coffre's app in your browser; its name is yours to change");
|
|
2271
|
+
await step.paste("If your browser shows an error at a localhost address, paste that address here:", async (text) => {
|
|
2272
|
+
const url = localCallback(text, port, "/created", ["code", "state"]);
|
|
2273
|
+
return typeof url === "string" ? url : take(url);
|
|
2274
|
+
}, done.signal);
|
|
2275
|
+
return app;
|
|
2276
|
+
} finally {
|
|
2277
|
+
server.closeAllConnections();
|
|
2278
|
+
server.close();
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
//#endregion
|
|
2282
|
+
//#region src/workers.ts
|
|
2283
|
+
const COMPONENTS = ["app", "vault"];
|
|
2284
|
+
/** How long a new deployment has to answer, its domain's certificate perhaps still on its way. */
|
|
2285
|
+
const ANSWER_WITHIN_MS = 9e4;
|
|
2286
|
+
/** An address such as secrets.example.com, from what was typed: no scheme, no path, lower case. */
|
|
2287
|
+
function addressOf(answer) {
|
|
2288
|
+
return answer.trim().replace(/^https?:\/\//i, "").replace(/[/?#].*$/, "").toLowerCase();
|
|
2289
|
+
}
|
|
2290
|
+
/** Why an address will not do: not a host name, or under none of the account's domains. Null when it will. */
|
|
2291
|
+
function addressProblem(address, zones) {
|
|
2292
|
+
if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(address)) return "an address such as secrets.example.com";
|
|
2293
|
+
if (!zones.some((zone) => address === zone || address.endsWith(`.${zone}`))) return `not under a domain of this account: ${listed(zones, "or")}`;
|
|
2294
|
+
return null;
|
|
2295
|
+
}
|
|
2296
|
+
/** A vault ID as setup makes them, which names one vault; not the template's `vault-1`, which any deployment may keep. */
|
|
2297
|
+
const MADE_VAULT_ID = /^vault-\d{4}-\d{2}-\d{2}-[a-z2-7]{6}$/;
|
|
2298
|
+
function recordsOf(workers) {
|
|
2299
|
+
const id = workers.vault.vars.VAULT_KEY_ID;
|
|
2300
|
+
return {
|
|
2301
|
+
hyperdrive: {
|
|
2302
|
+
app: workers.app.hyperdrive,
|
|
2303
|
+
vault: workers.vault.hyperdrive
|
|
2304
|
+
},
|
|
2305
|
+
vaultKeyId: id !== void 0 && MADE_VAULT_ID.test(id) ? id : null
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
/** Whether a Worker that exists is this deployment's: it binds a Hyperdrive config this directory records, or, the vault, its vault ID. */
|
|
2309
|
+
function isOurs(component, bindings, records) {
|
|
2310
|
+
const config = records.hyperdrive[component];
|
|
2311
|
+
if (config !== null && bindings.some(({ type, id }) => type === "hyperdrive" && id === config)) return true;
|
|
2312
|
+
return component === "vault" && records.vaultKeyId !== null && bindings.some(({ type, name, text }) => type === "plain_text" && name === "VAULT_KEY_ID" && text === records.vaultKeyId);
|
|
2313
|
+
}
|
|
2314
|
+
/** Whether a Hyperdrive config points at this run's database: the same host, the same database. */
|
|
2315
|
+
function sameDatabase(origin, administrator) {
|
|
2316
|
+
return origin.host === administrator.hostname && origin.database === decodeURIComponent(administrator.pathname.slice(1));
|
|
2317
|
+
}
|
|
2318
|
+
/**
|
|
2319
|
+
* A Worker's Hyperdrive config: the one this directory records; else one
|
|
2320
|
+
* named after the Worker that points at this run's database, made by a
|
|
2321
|
+
* run that stopped before recording it. One under the name that points
|
|
2322
|
+
* elsewhere is another deployment's.
|
|
2323
|
+
*/
|
|
2324
|
+
function ourConfig(configs, name, recorded, administrator) {
|
|
2325
|
+
return configs.find(({ id }) => id === recorded) ?? configs.find((config) => config.name === name && sameDatabase(config.origin, administrator));
|
|
2326
|
+
}
|
|
2327
|
+
/**
|
|
2328
|
+
* A name for this deployment, from its address: `coffre-` and its first
|
|
2329
|
+
* label, `coffre-secrets` for secrets.example.com, the prefix not doubled:
|
|
2330
|
+
* `coffre-try` for coffre-try.example.com.
|
|
2331
|
+
*/
|
|
2332
|
+
function nameFrom(address) {
|
|
2333
|
+
const label = address.split(".")[0].replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2334
|
+
return (label === "coffre" || label.startsWith("coffre-") ? label : `coffre-${label}`.replace(/-$/, "")).slice(0, 57).replace(/-+$/, "");
|
|
2335
|
+
}
|
|
2336
|
+
/** Why a deployment's name will not do as a Worker's, with `-vault` after it for the vault's. Null when it will. */
|
|
2337
|
+
function nameProblem(name) {
|
|
2338
|
+
return /^[a-z0-9]([a-z0-9-]{0,55}[a-z0-9])?$/.test(name) ? null : "lower-case letters, digits and dashes, at most 57";
|
|
2339
|
+
}
|
|
2340
|
+
/** Why a list of root admins will not do. Null when it will. */
|
|
2341
|
+
function adminsProblem(answer) {
|
|
2342
|
+
const wrong = answer.split(",").map((email) => email.trim()).filter((email) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email));
|
|
2343
|
+
return wrong.length === 0 ? null : `not an email: ${wrong.map((email) => email || "(empty)").join(", ")}`;
|
|
2344
|
+
}
|
|
2345
|
+
/** What under these names, on the account, is another deployment's: its Workers, and its Hyperdrive configs. */
|
|
2346
|
+
async function othersUnder(api, account, configs, names, records, administrator) {
|
|
2347
|
+
const others = [];
|
|
2348
|
+
for (const component of COMPONENTS) {
|
|
2349
|
+
const bindings = await api.bindings(account, names[component]);
|
|
2350
|
+
if (bindings !== null && !isOurs(component, bindings, records)) others.push(`the Worker ${names[component]}`);
|
|
2351
|
+
const named = configs.find(({ name }) => name === names[component]);
|
|
2352
|
+
if (named !== void 0 && named !== ourConfig(configs, names[component], records.hyperdrive[component], administrator)) others.push(`the Hyperdrive config ${names[component]}`);
|
|
2353
|
+
}
|
|
2354
|
+
return others;
|
|
2355
|
+
}
|
|
2356
|
+
var Cloudflare = class Cloudflare {
|
|
2357
|
+
#dir;
|
|
2358
|
+
#wrangler;
|
|
2359
|
+
#found;
|
|
2360
|
+
#administrator;
|
|
2361
|
+
#github;
|
|
2362
|
+
/** Each secret this run handles, to take out of any error shown. */
|
|
2363
|
+
#secrets;
|
|
2364
|
+
workers;
|
|
2365
|
+
address;
|
|
2366
|
+
rootAdmins;
|
|
2367
|
+
/** The keys this run makes, for the Workers without theirs, once `checkKeys` has run. */
|
|
2368
|
+
keys = null;
|
|
2369
|
+
#missing = {
|
|
2370
|
+
app: false,
|
|
2371
|
+
vault: false
|
|
2372
|
+
};
|
|
2373
|
+
/** The GitHub App's client secret, when this run has it to install. */
|
|
2374
|
+
#clientSecret = null;
|
|
2375
|
+
/** What this run changed in each wrangler.jsonc, in a few words each. */
|
|
2376
|
+
#wrote = {
|
|
2377
|
+
app: /* @__PURE__ */ new Set(),
|
|
2378
|
+
vault: /* @__PURE__ */ new Set()
|
|
2379
|
+
};
|
|
2380
|
+
constructor(dir, wrangler, workers, found, administrator, answers, secrets) {
|
|
2381
|
+
this.#dir = dir;
|
|
2382
|
+
this.#wrangler = wrangler;
|
|
2383
|
+
this.workers = workers;
|
|
2384
|
+
this.#found = found;
|
|
2385
|
+
this.#administrator = administrator;
|
|
2386
|
+
this.address = answers.address;
|
|
2387
|
+
this.rootAdmins = answers.rootAdmins;
|
|
2388
|
+
this.#secrets = secrets;
|
|
2389
|
+
const vars = workers.app.vars;
|
|
2390
|
+
this.#github = {
|
|
2391
|
+
web: vars.GITHUB_URL ?? GITHUB.web,
|
|
2392
|
+
api: vars.GITHUB_API_URL ?? GITHUB.api
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
/**
|
|
2396
|
+
* Sign in to Cloudflare, choose the account, read what is there, and ask
|
|
2397
|
+
* what setup cannot know: coffre's address, its root admins, and, when
|
|
2398
|
+
* another deployment has its names, a name of its own.
|
|
2399
|
+
*/
|
|
2400
|
+
static async connect(dir, out, keys, describe, secrets, administrator) {
|
|
2401
|
+
const s = style(out);
|
|
2402
|
+
const workers = {
|
|
2403
|
+
app: readWorker(dir, "app/wrangler.jsonc"),
|
|
2404
|
+
vault: readWorker(dir, "vault/wrangler.jsonc")
|
|
2405
|
+
};
|
|
2406
|
+
const records = recordsOf(workers);
|
|
2407
|
+
const wrangler = deploymentWrangler(dir);
|
|
2408
|
+
const steps = new Steps(out, ["Sign in to Cloudflare"], () => keys, describe);
|
|
2409
|
+
let found;
|
|
2410
|
+
try {
|
|
2411
|
+
await steps.run(0, async (step) => {
|
|
2412
|
+
const token = await cloudflareToken(wrangler, step, (label, address) => steps.link(label, address));
|
|
2413
|
+
secrets.push(token);
|
|
2414
|
+
const api = new CloudflareApi(token);
|
|
2415
|
+
step.note("Reading your Cloudflare account");
|
|
2416
|
+
const accounts = await api.accounts();
|
|
2417
|
+
if (accounts.length === 0) throw new Error("this Cloudflare login has no account");
|
|
2418
|
+
const account = accounts.find(({ id }) => id === workers.app.accountId) ?? (accounts.length === 1 ? accounts[0] : accounts[await step.choose("Which Cloudflare account?", accounts.map(({ name }) => name))]);
|
|
2419
|
+
const [zones, configs, email] = await Promise.all([
|
|
2420
|
+
api.zones(account.id),
|
|
2421
|
+
api.hyperdriveConfigs(account.id),
|
|
2422
|
+
api.email()
|
|
2423
|
+
]);
|
|
2424
|
+
if (zones.length === 0) throw new Error(`the account ${account.name} has no domain, and coffre needs one for its address: add one to Cloudflare first`);
|
|
2425
|
+
const names = {
|
|
2426
|
+
app: workers.app.name,
|
|
2427
|
+
vault: workers.vault.name
|
|
2428
|
+
};
|
|
2429
|
+
found = {
|
|
2430
|
+
api,
|
|
2431
|
+
account,
|
|
2432
|
+
zones,
|
|
2433
|
+
configs,
|
|
2434
|
+
others: await othersUnder(api, account.id, configs, names, records, administrator),
|
|
2435
|
+
secrets: {
|
|
2436
|
+
app: /* @__PURE__ */ new Set(),
|
|
2437
|
+
vault: /* @__PURE__ */ new Set()
|
|
2438
|
+
},
|
|
2439
|
+
email
|
|
2440
|
+
};
|
|
2441
|
+
return `Signed in to Cloudflare${email === null ? "" : ` as ${email}`}, account ${account.name}`;
|
|
2442
|
+
});
|
|
2443
|
+
} finally {
|
|
2444
|
+
steps.end();
|
|
2445
|
+
}
|
|
2446
|
+
const zones = found.zones.map(({ name }) => name);
|
|
2447
|
+
const url = workers.app.vars.PUBLIC_URL;
|
|
2448
|
+
const address = addressOf(await textLine(keys, out, s, "coffre's address", zones.length === 1 ? `under ${zones[0]}` : `under ${listed(zones, "or")}`, {
|
|
2449
|
+
initial: placeholder(url) ? "" : new URL(url).host,
|
|
2450
|
+
check: (answer) => addressProblem(addressOf(answer), zones)
|
|
2451
|
+
}));
|
|
2452
|
+
if (found.others.length > 0) {
|
|
2453
|
+
const one = found.others.length === 1;
|
|
2454
|
+
out.write(`${s.dim(paragraph(out, `On this account, ${listed(found.others, "and")} ${one ? "is" : "are"} another deployment's. Setup leaves ${one ? "it as it is" : "them as they are"}.`, 2))}\n`);
|
|
2455
|
+
const name = await textLine(keys, out, s, "This deployment's name", "its Workers are <name> and <name>-vault", {
|
|
2456
|
+
initial: nameFrom(address),
|
|
2457
|
+
check: async (answer) => {
|
|
2458
|
+
const problem = nameProblem(answer);
|
|
2459
|
+
if (problem !== null) return problem;
|
|
2460
|
+
const others = await othersUnder(found.api, found.account.id, found.configs, {
|
|
2461
|
+
app: answer,
|
|
2462
|
+
vault: `${answer}-vault`
|
|
2463
|
+
}, records, administrator);
|
|
2464
|
+
return others.length === 0 ? null : `${listed(others, "and")}: another deployment's too`;
|
|
2465
|
+
}
|
|
2466
|
+
});
|
|
2467
|
+
workers.app.name = name;
|
|
2468
|
+
workers.vault.name = `${name}-vault`;
|
|
2469
|
+
}
|
|
2470
|
+
for (const component of COMPONENTS) found.secrets[component] = new Set(await found.api.secretNames(found.account.id, workers[component].name) ?? []);
|
|
2471
|
+
const admins = workers.vault.vars.ROOT_ADMINS;
|
|
2472
|
+
const prefilled = placeholder(admins) && found.email !== null;
|
|
2473
|
+
const rootAdmins = (await textLine(keys, out, s, "Root admins", "their GitHub emails, comma-separated: the first people in", {
|
|
2474
|
+
initial: placeholder(admins) ? found.email ?? "" : admins,
|
|
2475
|
+
note: `${prefilled ? "That's your Cloudflare email. " : ""}Each must be an email on the GitHub account its admin will sign in with, or root is locked out.`,
|
|
2476
|
+
check: adminsProblem
|
|
2477
|
+
})).split(",").map((email) => email.trim()).join(",");
|
|
2478
|
+
out.write("\n");
|
|
2479
|
+
return new Cloudflare(dir, wrangler, workers, found, administrator, {
|
|
2480
|
+
address,
|
|
2481
|
+
rootAdmins
|
|
2482
|
+
}, secrets);
|
|
2483
|
+
}
|
|
2484
|
+
/** A Worker's Hyperdrive config, when there is one of this deployment's (`ourConfig`). */
|
|
2485
|
+
#config(component) {
|
|
2486
|
+
const worker = this.workers[component];
|
|
2487
|
+
return ourConfig(this.#found.configs, worker.name, worker.hyperdrive, this.#administrator);
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* Whether a login needs a new password: Hyperdrive must have it, and the
|
|
2491
|
+
* database keeps only its verifier. So when its config is missing, or
|
|
2492
|
+
* points elsewhere, or when asked.
|
|
2493
|
+
*/
|
|
2494
|
+
needsPassword(component, administrator, login) {
|
|
2495
|
+
const origin = this.#config(component)?.origin;
|
|
2496
|
+
return origin === void 0 || origin.host !== administrator.hostname || (origin.port ?? 5432) !== Number(administrator.port || 5432) || origin.database !== decodeURIComponent(administrator.pathname.slice(1)) || origin.user !== login;
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* Stop before a login that exists gets a new password, when it is also
|
|
2500
|
+
* the login of another deployment's Hyperdrive config, on the same
|
|
2501
|
+
* server: a login is the server's, not a database's, and that deployment
|
|
2502
|
+
* would be cut off. On PlanetScale, a login names its branch, and never is.
|
|
2503
|
+
*/
|
|
2504
|
+
refuseShared(component, administrator, login) {
|
|
2505
|
+
const ours = new Set(COMPONENTS.map((each) => this.#config(each)));
|
|
2506
|
+
const other = this.#found.configs.find((config) => !ours.has(config) && config.origin.host === administrator.hostname && (config.origin.port ?? 5432) === Number(administrator.port || 5432) && config.origin.user === login);
|
|
2507
|
+
if (other === void 0) return;
|
|
2508
|
+
throw new Error(`${login} is also the login of the Hyperdrive config ${other.name}, another deployment's, on this database server: a new password for the ${component}'s config would cut that deployment off. Two deployments can't share a server's logins: give this one a server of its own. Nothing was changed.`);
|
|
2509
|
+
}
|
|
2510
|
+
/**
|
|
2511
|
+
* The keys this run makes: one for each Worker without its own, for a
|
|
2512
|
+
* database that holds no data yet. One that does has its keys already,
|
|
2513
|
+
* and a new one would leave what it holds unreadable: setup stops, before
|
|
2514
|
+
* changing anything.
|
|
2515
|
+
*/
|
|
2516
|
+
checkKeys(used) {
|
|
2517
|
+
const missing = {
|
|
2518
|
+
app: !this.#found.secrets.app.has("APP_KEY"),
|
|
2519
|
+
vault: !this.#found.secrets.vault.has("VAULT_KEY")
|
|
2520
|
+
};
|
|
2521
|
+
for (const component of COMPONENTS) {
|
|
2522
|
+
if (!used || !missing[component]) continue;
|
|
2523
|
+
const { name, path } = this.workers[component];
|
|
2524
|
+
const key = component === "app" ? "APP_KEY" : "VAULT_KEY";
|
|
2525
|
+
throw new Error(`The ${component} Worker ${name} has no ${key}, but the database already holds data, which needs the key it was set up with. Put that key back, with pnpm exec wrangler secret put ${key} -c ${path}, then run setup again. Setup never makes a new key for a database in use.`);
|
|
2526
|
+
}
|
|
2527
|
+
this.#missing = missing;
|
|
2528
|
+
if (missing.app || missing.vault) {
|
|
2529
|
+
this.keys = generateKeys();
|
|
2530
|
+
this.#secrets.push(this.keys.APP_KEY, this.keys.VAULT_KEY);
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
/** What each Cloudflare step does while it runs. */
|
|
2534
|
+
static TITLES = [
|
|
2535
|
+
"Point Hyperdrive at the database",
|
|
2536
|
+
"Make coffre's GitHub App",
|
|
2537
|
+
"Fill in app/ and vault/wrangler.jsonc"
|
|
2538
|
+
];
|
|
2539
|
+
/**
|
|
2540
|
+
* Make `changes`, each named in a few words, to a Worker's wrangler.jsonc,
|
|
2541
|
+
* and with them, what this deployment is: its account, its names, and, the
|
|
2542
|
+
* app, the vault it binds.
|
|
2543
|
+
*/
|
|
2544
|
+
#edit(component, changes) {
|
|
2545
|
+
const worker = this.workers[component];
|
|
2546
|
+
const all = [
|
|
2547
|
+
{
|
|
2548
|
+
path: ["name"],
|
|
2549
|
+
value: worker.name,
|
|
2550
|
+
what: "its name"
|
|
2551
|
+
},
|
|
2552
|
+
{
|
|
2553
|
+
path: ["account_id"],
|
|
2554
|
+
value: this.#found.account.id,
|
|
2555
|
+
after: "name",
|
|
2556
|
+
what: "the account"
|
|
2557
|
+
},
|
|
2558
|
+
...component === "app" ? [{
|
|
2559
|
+
path: [
|
|
2560
|
+
"services",
|
|
2561
|
+
0,
|
|
2562
|
+
"service"
|
|
2563
|
+
],
|
|
2564
|
+
value: this.workers.vault.name,
|
|
2565
|
+
what: "the vault's name"
|
|
2566
|
+
}] : [],
|
|
2567
|
+
...changes
|
|
2568
|
+
];
|
|
2569
|
+
const changed = editWorker(this.#dir, worker.path, all);
|
|
2570
|
+
all.forEach(({ what }, i) => changed[i] && this.#wrote[component].add(what));
|
|
2571
|
+
worker.accountId = this.#found.account.id;
|
|
2572
|
+
}
|
|
2573
|
+
/** A Hyperdrive config for each login, made, given its new password, or kept; with caching off, always. */
|
|
2574
|
+
async hyperdrive(logins) {
|
|
2575
|
+
const { api, account } = this.#found;
|
|
2576
|
+
const width = Math.max(...COMPONENTS.map((component) => this.workers[component].name.length)) + 2;
|
|
2577
|
+
const details = [];
|
|
2578
|
+
for (const component of COMPONENTS) {
|
|
2579
|
+
const { name } = this.workers[component];
|
|
2580
|
+
const login = logins[component];
|
|
2581
|
+
let config = this.#config(component);
|
|
2582
|
+
let what;
|
|
2583
|
+
if (login.url !== null) {
|
|
2584
|
+
const origin = originOf(login.url);
|
|
2585
|
+
if (config === void 0) {
|
|
2586
|
+
config = {
|
|
2587
|
+
id: await api.createHyperdrive(account.id, name, origin),
|
|
2588
|
+
name,
|
|
2589
|
+
origin
|
|
2590
|
+
};
|
|
2591
|
+
what = `made, for ${login.login}`;
|
|
2592
|
+
} else {
|
|
2593
|
+
await api.updateHyperdrive(account.id, config.id, name, origin);
|
|
2594
|
+
what = `given ${login.login}'s new password`;
|
|
2595
|
+
}
|
|
2596
|
+
} else if (config.caching?.disabled !== true) {
|
|
2597
|
+
await api.disableCaching(account.id, config.id);
|
|
2598
|
+
what = "kept, its caching turned off";
|
|
2599
|
+
} else what = "kept";
|
|
2600
|
+
details.push(`${name.padEnd(width)}${what}`);
|
|
2601
|
+
this.#edit(component, [{
|
|
2602
|
+
path: [
|
|
2603
|
+
"hyperdrive",
|
|
2604
|
+
0,
|
|
2605
|
+
"id"
|
|
2606
|
+
],
|
|
2607
|
+
value: config.id,
|
|
2608
|
+
what: "Hyperdrive"
|
|
2609
|
+
}]);
|
|
2610
|
+
this.workers[component].hyperdrive = config.id;
|
|
2611
|
+
}
|
|
2612
|
+
return {
|
|
2613
|
+
text: `Hyperdrive configs ${this.workers.app.name} and ${this.workers.vault.name}, caching off`,
|
|
2614
|
+
details
|
|
2615
|
+
};
|
|
2616
|
+
}
|
|
2617
|
+
/**
|
|
2618
|
+
* The GitHub App people sign in with: kept when the app Worker has its
|
|
2619
|
+
* secret; made, in the browser, when there is none yet. One made before
|
|
2620
|
+
* whose secret never reached Cloudflare takes a new secret, from its page.
|
|
2621
|
+
*/
|
|
2622
|
+
async github(step, say) {
|
|
2623
|
+
const id = this.workers.app.vars.GITHUB_CLIENT_ID;
|
|
2624
|
+
const url = `https://${this.address}`;
|
|
2625
|
+
const moved = !placeholder(this.workers.app.vars.PUBLIC_URL) && this.workers.app.vars.PUBLIC_URL !== url;
|
|
2626
|
+
if (!placeholder(id) && this.#found.secrets.app.has("GITHUB_CLIENT_SECRET")) return {
|
|
2627
|
+
text: `Kept coffre's GitHub App, client ID ${id}`,
|
|
2628
|
+
details: moved ? [`On GitHub, change its callback URL to ${url}/auth/callback/github`] : []
|
|
2629
|
+
};
|
|
2630
|
+
if (!placeholder(id)) {
|
|
2631
|
+
say.aside(`coffre's GitHub App, client ID ${id}, has no client secret on Cloudflare yet.`);
|
|
2632
|
+
say.link("Generate a new one on its page, under", `${this.#github.web}/settings/apps`);
|
|
2633
|
+
step.note("GitHub: a new client secret for coffre's app");
|
|
2634
|
+
await step.paste("Paste the new client secret, hidden as you paste it:", async (text) => {
|
|
2635
|
+
if (!/^\S{20,}$/.test(text)) return "that is not a client secret: copy it whole, from the app's page";
|
|
2636
|
+
this.#clientSecret = text;
|
|
2637
|
+
this.#secrets.push(text);
|
|
2638
|
+
return null;
|
|
2639
|
+
}, new AbortController().signal);
|
|
2640
|
+
return {
|
|
2641
|
+
text: `Took a new client secret for coffre's GitHub App, client ID ${id}`,
|
|
2642
|
+
details: []
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
const app = await createGitHubApp(step, say, this.#github, url);
|
|
2646
|
+
this.#clientSecret = app.clientSecret;
|
|
2647
|
+
this.#secrets.push(app.clientSecret);
|
|
2648
|
+
this.#edit("app", [{
|
|
2649
|
+
path: ["vars", "GITHUB_CLIENT_ID"],
|
|
2650
|
+
value: app.clientId,
|
|
2651
|
+
what: "GitHub's client ID"
|
|
2652
|
+
}]);
|
|
2653
|
+
this.workers.app.vars.GITHUB_CLIENT_ID = app.clientId;
|
|
2654
|
+
return {
|
|
2655
|
+
text: `Made coffre's GitHub App, ${app.slug}`,
|
|
2656
|
+
details: app.url === "" ? [] : [app.url]
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
/** The rest of each wrangler.jsonc: the address and its route, the root admins, and the vault ID of a new vault key. */
|
|
2660
|
+
write() {
|
|
2661
|
+
const url = `https://${this.address}`;
|
|
2662
|
+
const app = this.workers.app;
|
|
2663
|
+
const route = {
|
|
2664
|
+
pattern: this.address,
|
|
2665
|
+
custom_domain: true
|
|
2666
|
+
};
|
|
2667
|
+
this.#edit("app", [{
|
|
2668
|
+
path: ["vars", "PUBLIC_URL"],
|
|
2669
|
+
value: url,
|
|
2670
|
+
what: "the address"
|
|
2671
|
+
}, app.route === null ? {
|
|
2672
|
+
path: ["routes"],
|
|
2673
|
+
value: [route],
|
|
2674
|
+
after: "workers_dev",
|
|
2675
|
+
what: "its custom domain"
|
|
2676
|
+
} : {
|
|
2677
|
+
path: ["routes", 0],
|
|
2678
|
+
value: route,
|
|
2679
|
+
what: "its custom domain"
|
|
2680
|
+
}]);
|
|
2681
|
+
this.#edit("vault", [{
|
|
2682
|
+
path: ["vars", "ROOT_ADMINS"],
|
|
2683
|
+
value: this.rootAdmins,
|
|
2684
|
+
what: "the root admins"
|
|
2685
|
+
}, ...this.keys !== null && this.#missing.vault ? [{
|
|
2686
|
+
path: ["vars", "VAULT_KEY_ID"],
|
|
2687
|
+
value: this.keys.VAULT_KEY_ID,
|
|
2688
|
+
what: "the vault ID"
|
|
2689
|
+
}] : []]);
|
|
2690
|
+
const changed = COMPONENTS.filter((component) => this.#wrote[component].size > 0);
|
|
2691
|
+
const files = listed(changed.map((component) => this.workers[component].path), "and");
|
|
2692
|
+
return changed.length === 0 ? "app/ and vault/wrangler.jsonc, as they were" : {
|
|
2693
|
+
text: `Filled in ${files}`,
|
|
2694
|
+
details: changed.map((component) => `${component.padEnd(7)}${listed([...this.#wrote[component]], "and")}`)
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
/**
|
|
2698
|
+
* The keys to save, before they go to Cloudflare: only the ones this run
|
|
2699
|
+
* made. A set half installed, or a vault ID setup wrote for a key the
|
|
2700
|
+
* vault never got, says an earlier run showed keys that never reached
|
|
2701
|
+
* Cloudflare: these replace them.
|
|
2702
|
+
*/
|
|
2703
|
+
screen() {
|
|
2704
|
+
const values = keyValues(this.keys);
|
|
2705
|
+
const app = this.#missing.app ? values.app : [];
|
|
2706
|
+
const vault = this.#missing.vault ? values.vault : [];
|
|
2707
|
+
const one = app.length + vault.length === 1;
|
|
2708
|
+
const where = [...this.#missing.app ? ["APP_KEY goes in the app Worker"] : [], ...this.#missing.vault ? [`VAULT_KEY ${this.#missing.app ? "" : "goes "}in the vault Worker`] : []];
|
|
2709
|
+
const before = this.#missing.app !== this.#missing.vault || this.#missing.vault && /^vault-\d{4}-\d{2}-\d{2}-[a-z2-7]{6}$/.test(this.workers.vault.vars.VAULT_KEY_ID ?? "");
|
|
2710
|
+
return {
|
|
2711
|
+
title: "coffre setup",
|
|
2712
|
+
intro: `Save ${one ? "it" : "each one"} in your password manager now${before ? `, in place of ${one ? "the one" : "any"} an earlier run showed, which never reached Cloudflare` : ""}. Next, setup gives ${one ? "it" : "them"} to Cloudflare, which never shows ${one ? "it" : "them"} again.`,
|
|
2713
|
+
sections: [...app.length > 0 ? [{
|
|
2714
|
+
title: "App",
|
|
2715
|
+
values: app
|
|
2716
|
+
}] : [], ...vault.length > 0 ? [{
|
|
2717
|
+
title: "Vault",
|
|
2718
|
+
values: vault
|
|
2719
|
+
}] : []],
|
|
2720
|
+
guide: [{
|
|
2721
|
+
title: "On Cloudflare",
|
|
2722
|
+
lines: [
|
|
2723
|
+
`${listed(where, "and")}, as ${where.length === 1 ? "a secret" : "secrets"}: setup puts ${where.length === 1 ? "it" : "them"} there as it deploys, next.`,
|
|
2724
|
+
...this.#missing.vault ? ["VAULT_KEY_ID is in vault/wrangler.jsonc already."] : [],
|
|
2725
|
+
"Cloudflare never shows a secret again: your password manager holds the only copy. A restored database can't be read without the vault key."
|
|
2726
|
+
]
|
|
2727
|
+
}]
|
|
2728
|
+
};
|
|
2729
|
+
}
|
|
2730
|
+
/** Deploy the vault, then the app, which binds to it, each with the secrets it lacks; then wait for coffre to answer. */
|
|
2731
|
+
async deploy(out, describe) {
|
|
2732
|
+
const url = `https://${this.address}`;
|
|
2733
|
+
const steps = new Steps(out, [
|
|
2734
|
+
"Deploy the vault",
|
|
2735
|
+
"Deploy the app",
|
|
2736
|
+
`Wait for ${url} to answer`
|
|
2737
|
+
], () => null, describe);
|
|
2738
|
+
const account = this.#found.account.id;
|
|
2739
|
+
let answered = false;
|
|
2740
|
+
try {
|
|
2741
|
+
await steps.run(0, async () => {
|
|
2742
|
+
const secrets = this.#missing.vault ? { VAULT_KEY: this.keys.VAULT_KEY } : {};
|
|
2743
|
+
await deployWorker(this.#wrangler, this.workers.vault.path, account, secrets);
|
|
2744
|
+
return `Deployed the vault, ${this.workers.vault.name}${this.#missing.vault ? ", with its key" : ""}`;
|
|
2745
|
+
});
|
|
2746
|
+
await steps.run(1, async () => {
|
|
2747
|
+
const secrets = {
|
|
2748
|
+
...this.#missing.app ? { APP_KEY: this.keys.APP_KEY } : {},
|
|
2749
|
+
...this.#clientSecret === null ? {} : { GITHUB_CLIENT_SECRET: this.#clientSecret }
|
|
2750
|
+
};
|
|
2751
|
+
await deployWorker(this.#wrangler, this.workers.app.path, account, secrets);
|
|
2752
|
+
const what = [...this.#missing.app ? ["its key"] : [], ...this.#clientSecret === null ? [] : ["GitHub's secret"]];
|
|
2753
|
+
return `Deployed the app, ${this.workers.app.name}${what.length === 0 ? "" : `, with ${listed(what, "and")}`}`;
|
|
2754
|
+
});
|
|
2755
|
+
await steps.run(2, async (step) => {
|
|
2756
|
+
answered = await answers(`${url}/livez`, (seconds) => step.note(`Wait for ${url} to answer: its certificate can take a minute (${seconds}s)`));
|
|
2757
|
+
return answered ? `coffre answers at ${url}` : {
|
|
2758
|
+
text: `${url} does not answer yet`,
|
|
2759
|
+
details: ["A new domain's certificate can take a few minutes more. Nothing else is left to do."]
|
|
2760
|
+
};
|
|
2761
|
+
});
|
|
2762
|
+
} finally {
|
|
2763
|
+
steps.end();
|
|
2764
|
+
}
|
|
2765
|
+
return url;
|
|
2766
|
+
}
|
|
2767
|
+
};
|
|
2768
|
+
/** Whether `url` answers 200 within ANSWER_WITHIN_MS, asking every few seconds. */
|
|
2769
|
+
async function answers(url, waiting) {
|
|
2770
|
+
const start = Date.now();
|
|
2771
|
+
for (;;) {
|
|
2772
|
+
try {
|
|
2773
|
+
if ((await fetch(url, {
|
|
2774
|
+
signal: AbortSignal.timeout(5e3),
|
|
2775
|
+
redirect: "manual"
|
|
2776
|
+
})).status === 200) return true;
|
|
2777
|
+
} catch {}
|
|
2778
|
+
const elapsed = Date.now() - start;
|
|
2779
|
+
if (elapsed >= ANSWER_WITHIN_MS) return false;
|
|
2780
|
+
waiting(Math.round(elapsed / 1e3));
|
|
2781
|
+
await new Promise((resolve) => setTimeout(resolve, 3e3));
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
/** After the deploy: where coffre is, and what is left, with no secret in it. */
|
|
2785
|
+
function deployedSummary(cloudflare, out) {
|
|
2786
|
+
const s = style(out);
|
|
2787
|
+
const url = `https://${cloudflare.address}`;
|
|
2788
|
+
const first = cloudflare.rootAdmins.split(",")[0];
|
|
2789
|
+
return [
|
|
2790
|
+
"",
|
|
2791
|
+
` ${s.green("✓")} ${s.bold(`coffre is at ${url}`)}`,
|
|
2792
|
+
...cloudflare.keys === null ? [] : [s.dim(paragraph(out, "The keys shown are only in your password manager now, and in Cloudflare, which won't show them again.", 4))],
|
|
2793
|
+
"",
|
|
2794
|
+
` ${s.bold("Next")}`,
|
|
2795
|
+
row(out, s, "Sign in", `${url}, with GitHub, as ${first}.`),
|
|
2796
|
+
row(out, s, "CLI", `coffre login ${url}`),
|
|
2797
|
+
row(out, s, "Monitor", `${url}/readyz turns green at the first scheduled run, within 5 minutes.`),
|
|
2798
|
+
s.dim(paragraph(out, "After every upgrade of coffre, pnpm migrate, then pnpm run deploy. docs/deploy.md has each step.", 4)),
|
|
2799
|
+
"",
|
|
2800
|
+
""
|
|
2801
|
+
].join("\n");
|
|
2802
|
+
}
|
|
2803
|
+
//#endregion
|
|
2804
|
+
//#region src/setup.ts
|
|
2805
|
+
/** Where the administrator's connection string comes from, when not from a hidden prompt. */
|
|
2806
|
+
const URL_VARIABLE = "COFFRE_SETUP_DATABASE_URL";
|
|
2807
|
+
/** The two runtime roles, as the migration names them, and the Hyperdrive config each gets on Workers. */
|
|
2808
|
+
const ROLES = {
|
|
2809
|
+
app: "coffre_runtime",
|
|
2810
|
+
vault: "coffre_vault_runtime"
|
|
2811
|
+
};
|
|
2812
|
+
const HYPERDRIVE = {
|
|
2813
|
+
app: "coffre",
|
|
2814
|
+
vault: "coffre-vault"
|
|
2815
|
+
};
|
|
2816
|
+
var SetupError = class extends Error {};
|
|
2817
|
+
/**
|
|
2818
|
+
* The name a role logs in as. On PlanetScale Postgres, a login names its
|
|
2819
|
+
* branch: the administrator connects as `postgres.<branch id>`, and every
|
|
2820
|
+
* role on that branch as `<role>.<branch id>`. Elsewhere, the role's name.
|
|
2821
|
+
*/
|
|
2822
|
+
function loginFor(role, administrator) {
|
|
2823
|
+
const dot = administrator.indexOf(".");
|
|
2824
|
+
return dot === -1 ? role : `${role}${administrator.slice(dot)}`;
|
|
2825
|
+
}
|
|
2826
|
+
/** The administrator's URL, as `login` with `password`: same host, database and TLS settings. */
|
|
2827
|
+
function loginUrl(administrator, login, password) {
|
|
2828
|
+
const url = new URL(administrator.href);
|
|
2829
|
+
url.username = encodeURIComponent(login);
|
|
2830
|
+
url.password = password;
|
|
2831
|
+
return url.href;
|
|
2832
|
+
}
|
|
2833
|
+
/**
|
|
2834
|
+
* A wrangler hyperdrive command that reads the URL at a silent prompt, so
|
|
2835
|
+
* that no password reaches the shell's history, and drops its parameters:
|
|
2836
|
+
* Hyperdrive connects over TLS itself, checking the certificate against
|
|
2837
|
+
* public CAs, and takes no `sslrootcert`. `${v%%[?]*}` holds in bash and
|
|
2838
|
+
* zsh alike.
|
|
2839
|
+
*/
|
|
2840
|
+
function hyperdriveCommand(target) {
|
|
2841
|
+
return `read -rs COFFRE_DB_URL && pnpm exec wrangler hyperdrive ${target} --connection-string="\${COFFRE_DB_URL%%[?]*}"; unset COFFRE_DB_URL`;
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* What Postgres keeps for a password under SCRAM-SHA-256 (RFC 5802 and
|
|
2845
|
+
* 7677), made here, so that the password itself never reaches the server,
|
|
2846
|
+
* nor its logs.
|
|
2847
|
+
*/
|
|
2848
|
+
function scramVerifier(password, salt = randomBytes(16), iterations = 4096) {
|
|
2849
|
+
const salted = pbkdf2Sync(password, salt, iterations, 32, "sha256");
|
|
2850
|
+
const stored = createHash("sha256").update(createHmac("sha256", salted).update("Client Key").digest()).digest();
|
|
2851
|
+
const server = createHmac("sha256", salted).update("Server Key").digest();
|
|
2852
|
+
return `SCRAM-SHA-256$${iterations}:${salt.toString("base64")}$${stored.toString("base64")}:${server.toString("base64")}`;
|
|
2853
|
+
}
|
|
2854
|
+
/** 24 random bytes, URL-safe: nothing to encode in a connection string. */
|
|
2855
|
+
function newPassword() {
|
|
2856
|
+
return randomBytes(24).toString("base64url");
|
|
2857
|
+
}
|
|
2858
|
+
async function setup(args) {
|
|
2859
|
+
const secrets = [];
|
|
2860
|
+
const clean = (error) => redact(error instanceof Error ? error.message : String(error), secrets);
|
|
2861
|
+
let options;
|
|
2862
|
+
try {
|
|
2863
|
+
options = parseOptions(args);
|
|
2864
|
+
} catch (error) {
|
|
2865
|
+
return fail(process.stderr, clean(error));
|
|
2866
|
+
}
|
|
2867
|
+
const terminal = options.json ? null : openTerminal();
|
|
2868
|
+
if (!options.json && terminal === null) return fail(process.stderr, needsTerminal("coffre setup"));
|
|
2869
|
+
const out = terminal?.out ?? process.stderr;
|
|
2870
|
+
const s = style(out);
|
|
2871
|
+
const questions = () => terminal?.keys ?? (process.stdin.isTTY ? process.stdin : null);
|
|
2872
|
+
const dir = process.cwd();
|
|
2873
|
+
let kind = deploymentKind(dir);
|
|
2874
|
+
const offers = terminal !== null && process.platform !== "win32";
|
|
2875
|
+
try {
|
|
2876
|
+
const about = offers && (kind === "workers" || kind === "empty") ? "a deployment's database, keys and Cloudflare" : "a deployment's database logins, migrations and keys";
|
|
2877
|
+
if (s.ansi) out.write(`\n ${s.bold("coffre setup")} ${s.dim(about)}\n\n`);
|
|
2878
|
+
if (kind === "empty" && terminal !== null) kind = await scaffold(dir, terminal.keys, out, clean);
|
|
2879
|
+
const text = await readAdministrator(out, s);
|
|
2880
|
+
secrets.push(text);
|
|
2881
|
+
const administrator = administratorUrl(text);
|
|
2882
|
+
if (administrator.password !== "") secrets.push(administrator.password, decodeURIComponent(administrator.password));
|
|
2883
|
+
let cloudflare = null;
|
|
2884
|
+
if (offers && kind === "workers") {
|
|
2885
|
+
if (await select(terminal.keys, out, s, "Set Cloudflare up too?", ["Yes: Hyperdrive, GitHub sign-in, the keys as secrets, and the deploy", "No, I'll do Cloudflare myself"]) === 0) cloudflare = await Cloudflare.connect(dir, out, terminal.keys, clean, secrets, administrator);
|
|
2886
|
+
}
|
|
2887
|
+
const result = await run(administrator, out, questions, {
|
|
2888
|
+
resetPasswords: options.resetPasswords,
|
|
2889
|
+
cloudflare
|
|
2890
|
+
}, secrets, clean);
|
|
2891
|
+
if (cloudflare !== null) {
|
|
2892
|
+
if (cloudflare.keys !== null) await showSecrets(terminal, cloudflare.screen());
|
|
2893
|
+
await cloudflare.deploy(out, clean);
|
|
2894
|
+
out.write(deployedSummary(cloudflare, out));
|
|
2895
|
+
} else if (options.json) {
|
|
2896
|
+
jsonWarning(process.stderr, listed(shownNames(result), "and"));
|
|
2897
|
+
process.stdout.write(`${JSON.stringify(asJson(result))}\n`);
|
|
2898
|
+
} else if (result.keys === null && result.app.url === null && result.vault.url === null) out.write(nothingToSave(out));
|
|
2899
|
+
else {
|
|
2900
|
+
await showSecrets(terminal, setupScreen(result));
|
|
2901
|
+
out.write(summary(result, out));
|
|
2902
|
+
}
|
|
2903
|
+
} catch (error) {
|
|
2904
|
+
if (error instanceof Cancelled) return fail(out, "cancelled; nothing after the last step done was changed", 130);
|
|
2905
|
+
if (!(error instanceof StepFailed)) return fail(out, clean(error));
|
|
2906
|
+
process.exit(1);
|
|
2907
|
+
} finally {
|
|
2908
|
+
if (terminal !== null) release(terminal.keys);
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
function fail(out, message, code = 1) {
|
|
2912
|
+
const s = style(out);
|
|
2913
|
+
out.write(`${s.red("✗")} ${message}\n`);
|
|
2914
|
+
process.exit(code);
|
|
2915
|
+
}
|
|
2916
|
+
function parseOptions(args) {
|
|
2917
|
+
try {
|
|
2918
|
+
const { values } = parseArgs({
|
|
2919
|
+
args,
|
|
2920
|
+
options: {
|
|
2921
|
+
"reset-passwords": {
|
|
2922
|
+
type: "boolean",
|
|
2923
|
+
default: false
|
|
2924
|
+
},
|
|
2925
|
+
json: {
|
|
2926
|
+
type: "boolean",
|
|
2927
|
+
default: false
|
|
2928
|
+
}
|
|
2929
|
+
},
|
|
2930
|
+
allowPositionals: false,
|
|
2931
|
+
strict: true
|
|
2932
|
+
});
|
|
2933
|
+
return {
|
|
2934
|
+
resetPasswords: values["reset-passwords"],
|
|
2935
|
+
json: values.json
|
|
2936
|
+
};
|
|
2937
|
+
} catch {
|
|
2938
|
+
const leaked = args.some((arg) => /postgres(ql)?:|@/i.test(arg));
|
|
2939
|
+
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." : ""));
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
/** The connection string: from the environment, from stdin when it is piped, or typed at a hidden prompt. */
|
|
2943
|
+
async function readAdministrator(out, s) {
|
|
2944
|
+
const given = process.env[URL_VARIABLE];
|
|
2945
|
+
delete process.env[URL_VARIABLE];
|
|
2946
|
+
if (given !== void 0 && given.trim() !== "") return given.trim();
|
|
2947
|
+
if (!process.stdin.isTTY) {
|
|
2948
|
+
const line = readFileSync(0, "utf8").split("\n").find((candidate) => candidate.trim() !== "");
|
|
2949
|
+
if (line === void 0) throw new SetupError(`no connection string: pipe it to stdin, or set ${URL_VARIABLE}`);
|
|
2950
|
+
return line.trim();
|
|
2951
|
+
}
|
|
2952
|
+
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.");
|
|
2953
|
+
if (text === "") throw new SetupError("no connection string given");
|
|
2954
|
+
return text;
|
|
2955
|
+
}
|
|
2956
|
+
function administratorUrl(text) {
|
|
2957
|
+
let url;
|
|
2958
|
+
try {
|
|
2959
|
+
url = new URL(text);
|
|
2960
|
+
} catch {
|
|
2961
|
+
throw new SetupError("that is not a connection string, such as postgresql://user:password@host:5432/database");
|
|
2962
|
+
}
|
|
2963
|
+
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") throw new SetupError("coffre setup needs a Postgres connection string, postgresql://…");
|
|
2964
|
+
if (url.username === "" || url.hostname === "" || url.pathname.length <= 1) throw new SetupError("the connection string must name a user, a host and a database");
|
|
2965
|
+
return url;
|
|
2966
|
+
}
|
|
2967
|
+
/** This CLI's version: the schema it migrates to, and the version of coffre a deployment it makes runs. */
|
|
2968
|
+
function cliVersion() {
|
|
2969
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
2970
|
+
}
|
|
2971
|
+
/**
|
|
2972
|
+
* In an empty directory, a deployment of the kind chosen, its files written
|
|
2973
|
+
* as `coffre init` writes them and its packages installed; or none.
|
|
2974
|
+
*/
|
|
2975
|
+
async function scaffold(dir, keys, out, clean) {
|
|
2976
|
+
const s = style(out);
|
|
2977
|
+
const names = {
|
|
2978
|
+
workers: "Cloudflare Workers",
|
|
2979
|
+
node: "Node"
|
|
2980
|
+
};
|
|
2981
|
+
const empty = existsSync(join(dir, ".git")) ? "This directory holds only .git" : "This directory is empty";
|
|
2982
|
+
const choice = await select(keys, out, s, `${empty}. Make a deployment of coffre here?`, [...KINDS.map((kind) => names[kind]), "No, only the database"]);
|
|
2983
|
+
const kind = KINDS[choice];
|
|
2984
|
+
if (kind === void 0) return "empty";
|
|
2985
|
+
const steps = new Steps(out, [`Write a ${names[kind]} deployment`, "Install its packages"], () => null, clean);
|
|
2986
|
+
try {
|
|
2987
|
+
await steps.run(0, async () => `Wrote a ${names[kind]} deployment: ${init(kind, dir, cliVersion()).length} files`);
|
|
2988
|
+
await steps.run(1, async () => {
|
|
2989
|
+
await install(dir);
|
|
2990
|
+
return "Installed its packages, with pnpm";
|
|
2991
|
+
});
|
|
2992
|
+
} finally {
|
|
2993
|
+
steps.end();
|
|
2994
|
+
}
|
|
2995
|
+
out.write("\n");
|
|
2996
|
+
return kind;
|
|
2997
|
+
}
|
|
2998
|
+
async function run(administrator, out, questions, options, secrets, clean) {
|
|
2999
|
+
const { cloudflare } = options;
|
|
3000
|
+
const user = decodeURIComponent(administrator.username);
|
|
3001
|
+
const where = `${administrator.hostname}${decodeURIComponent(administrator.pathname)}`;
|
|
3002
|
+
const version = cliVersion();
|
|
3003
|
+
const steps = new Steps(out, [
|
|
3004
|
+
`Connect to ${where}`,
|
|
3005
|
+
`Check ${user} can create roles`,
|
|
3006
|
+
"Make the two logins",
|
|
3007
|
+
"Migrate the database",
|
|
3008
|
+
"Check each login's rights",
|
|
3009
|
+
...cloudflare === null ? [] : Cloudflare.TITLES
|
|
3010
|
+
], questions, clean);
|
|
3011
|
+
const step = (i, work) => steps.run(i, work);
|
|
3012
|
+
const client = new esm_default.Client({
|
|
3013
|
+
...postgresConnection(administrator.href),
|
|
3014
|
+
application_name: "coffre-setup"
|
|
3015
|
+
});
|
|
3016
|
+
try {
|
|
3017
|
+
await step(0, async () => {
|
|
3018
|
+
await client.connect();
|
|
3019
|
+
cloudflare?.checkKeys(await holdsData(client));
|
|
3020
|
+
return `Connected to ${where} as ${user}`;
|
|
3021
|
+
});
|
|
3022
|
+
await step(1, async () => {
|
|
3023
|
+
const [self] = (await client.query("SELECT rolsuper, rolcreaterole FROM pg_roles WHERE rolname = current_user")).rows;
|
|
3024
|
+
if (!self?.rolsuper && !self?.rolcreaterole) throw new SetupError(`${user} cannot create roles: connect as the database's administrator, which has CREATEROLE`);
|
|
3025
|
+
return `${user} can create roles`;
|
|
3026
|
+
});
|
|
3027
|
+
let logins;
|
|
3028
|
+
await step(2, async (progress) => {
|
|
3029
|
+
logins = await provision(client, administrator, user, cloudflare === null ? async (existing) => {
|
|
3030
|
+
const all = options.resetPasswords || existing.size > 0 && await progress.ask(existing.size === 1 ? `${[...existing][0]} exists already. Set new passwords?` : "Both logins exist already. Set new passwords?");
|
|
3031
|
+
return () => all;
|
|
3032
|
+
} : async (existing) => {
|
|
3033
|
+
const resets = {};
|
|
3034
|
+
for (const component of ["app", "vault"]) {
|
|
3035
|
+
const login = loginFor(ROLES[component], user);
|
|
3036
|
+
resets[component] = options.resetPasswords || cloudflare.needsPassword(component, administrator, login);
|
|
3037
|
+
if (resets[component] && existing.has(ROLES[component])) cloudflare.refuseShared(component, administrator, login);
|
|
3038
|
+
}
|
|
3039
|
+
return (component) => resets[component];
|
|
3040
|
+
}, secrets);
|
|
3041
|
+
return described(logins);
|
|
3042
|
+
});
|
|
3043
|
+
await step(3, async (progress) => {
|
|
3044
|
+
let plan = {
|
|
3045
|
+
applied: 0,
|
|
3046
|
+
total: 0
|
|
3047
|
+
};
|
|
3048
|
+
await migrateDatabase(administrator.href, (planned) => {
|
|
3049
|
+
plan = planned;
|
|
3050
|
+
const pending = planned.total - planned.applied;
|
|
3051
|
+
progress.note(pending === 0 ? "Checking the database is up to date" : `Migrating the database: ${count(pending, "migration")} to apply`);
|
|
3052
|
+
});
|
|
3053
|
+
const pending = plan.total - plan.applied;
|
|
3054
|
+
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`;
|
|
3055
|
+
});
|
|
3056
|
+
let used = false;
|
|
3057
|
+
await step(4, async () => {
|
|
3058
|
+
const details = [];
|
|
3059
|
+
for (const component of ["app", "vault"]) {
|
|
3060
|
+
const login = logins[component];
|
|
3061
|
+
const allowed = login.url === null ? await granted(client, login.role) : await probed(login.url);
|
|
3062
|
+
details.push(checked(component, login, allowed));
|
|
3063
|
+
}
|
|
3064
|
+
const kept = [logins.app, logins.vault].filter((login) => login.url === null);
|
|
3065
|
+
if (kept.length > 0) details.push(`${listed(kept.map(({ role }) => role), "and")}: from the catalog, with no new password to log in with`);
|
|
3066
|
+
used = await holdsData(client);
|
|
3067
|
+
return {
|
|
3068
|
+
text: "Each login holds only its rights",
|
|
3069
|
+
details
|
|
3070
|
+
};
|
|
3071
|
+
});
|
|
3072
|
+
if (cloudflare !== null) {
|
|
3073
|
+
await step(5, () => cloudflare.hyperdrive(logins));
|
|
3074
|
+
await step(6, (progress) => cloudflare.github(progress, {
|
|
3075
|
+
aside: (text) => steps.aside(text),
|
|
3076
|
+
link: (label, address) => steps.link(label, address)
|
|
3077
|
+
}));
|
|
3078
|
+
await step(7, async () => cloudflare.write());
|
|
3079
|
+
return {
|
|
3080
|
+
keys: null,
|
|
3081
|
+
...logins,
|
|
3082
|
+
version
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
return {
|
|
3086
|
+
keys: !used && (logins.app.url !== null || logins.vault.url !== null) ? generateKeys() : null,
|
|
3087
|
+
...logins,
|
|
3088
|
+
version
|
|
3089
|
+
};
|
|
3090
|
+
} finally {
|
|
3091
|
+
steps.end();
|
|
3092
|
+
await client.end().catch(() => {});
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
/** Whether the database holds data: coffre's log has an entry. Before the first migration, it holds none. */
|
|
3096
|
+
async function holdsData(client) {
|
|
3097
|
+
const [migrated] = (await client.query("SELECT to_regclass('public.audit_log') IS NOT NULL AS migrated")).rows;
|
|
3098
|
+
if (!migrated.migrated) return false;
|
|
3099
|
+
return (await client.query("SELECT EXISTS (SELECT 1 FROM audit_log) AS used")).rows[0].used;
|
|
3100
|
+
}
|
|
3101
|
+
/**
|
|
3102
|
+
* Each runtime login, created with a fresh password, or, if it exists, its
|
|
3103
|
+
* password set again when `reset`, given the logins that exist, says so;
|
|
3104
|
+
* otherwise left as it is.
|
|
3105
|
+
*/
|
|
3106
|
+
async function provision(client, administrator, user, reset, secrets) {
|
|
3107
|
+
const existing = new Set((await client.query("SELECT rolname FROM pg_roles WHERE rolname = ANY($1)", [Object.values(ROLES)])).rows.map((row) => row.rolname));
|
|
3108
|
+
const resets = await reset(existing);
|
|
3109
|
+
const logins = {};
|
|
3110
|
+
for (const component of ["app", "vault"]) {
|
|
3111
|
+
const role = ROLES[component];
|
|
3112
|
+
const login = loginFor(role, user);
|
|
3113
|
+
const create = !existing.has(role);
|
|
3114
|
+
if (!create && !resets(component)) {
|
|
3115
|
+
logins[component] = {
|
|
3116
|
+
role,
|
|
3117
|
+
login,
|
|
3118
|
+
password: "kept",
|
|
3119
|
+
url: null
|
|
3120
|
+
};
|
|
3121
|
+
continue;
|
|
3122
|
+
}
|
|
3123
|
+
const password = newPassword();
|
|
3124
|
+
secrets.push(password);
|
|
3125
|
+
const verifier = client.escapeLiteral(scramVerifier(password));
|
|
3126
|
+
await client.query(create ? `CREATE ROLE ${client.escapeIdentifier(role)} LOGIN INHERIT NOCREATEDB NOCREATEROLE PASSWORD ${verifier}` : `ALTER ROLE ${client.escapeIdentifier(role)} LOGIN PASSWORD ${verifier}`);
|
|
3127
|
+
logins[component] = {
|
|
3128
|
+
role,
|
|
3129
|
+
login,
|
|
3130
|
+
password: create ? "created" : "reset",
|
|
3131
|
+
url: loginUrl(administrator, login, password)
|
|
3132
|
+
};
|
|
3133
|
+
}
|
|
3134
|
+
return logins;
|
|
3135
|
+
}
|
|
3136
|
+
/** The logins step's line: what happened to each, and what they log in as on PlanetScale. */
|
|
3137
|
+
function described(logins) {
|
|
3138
|
+
const verbs = {
|
|
3139
|
+
created: "Created",
|
|
3140
|
+
reset: "Set new passwords for",
|
|
3141
|
+
kept: "Kept"
|
|
3142
|
+
};
|
|
3143
|
+
return {
|
|
3144
|
+
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}`,
|
|
3145
|
+
details: logins.app.login !== logins.app.role ? [`They log in as ${logins.app.login} and ${logins.vault.login}`] : []
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
/** What each login may do that matters, and what it must answer. */
|
|
3149
|
+
const BOUNDARY = [
|
|
3150
|
+
{
|
|
3151
|
+
what: "write members",
|
|
3152
|
+
expected: {
|
|
3153
|
+
app: false,
|
|
3154
|
+
vault: true
|
|
3155
|
+
},
|
|
3156
|
+
sql: `INSERT INTO vault_members (principal, status, owner, generation, created_at, created_by, status_changed_at, status_changed_by, access_seq, mac)
|
|
3157
|
+
VALUES ('user:coffre-setup@probe.invalid', 'active', false, 0, 0, 'system:coffre-setup', 0, 'system:coffre-setup', 0, decode(repeat('00', 32), 'hex'))`,
|
|
3158
|
+
privilege: "has_table_privilege($1, 'public.vault_members', 'INSERT')"
|
|
3159
|
+
},
|
|
3160
|
+
{
|
|
3161
|
+
what: "delete log entries",
|
|
3162
|
+
expected: {
|
|
3163
|
+
app: false,
|
|
3164
|
+
vault: false
|
|
3165
|
+
},
|
|
3166
|
+
sql: "DELETE FROM audit_log",
|
|
3167
|
+
privilege: "has_table_privilege($1, 'public.audit_log', 'DELETE')"
|
|
3168
|
+
},
|
|
3169
|
+
{
|
|
3170
|
+
what: "create tables",
|
|
3171
|
+
expected: {
|
|
3172
|
+
app: false,
|
|
3173
|
+
vault: false
|
|
3174
|
+
},
|
|
3175
|
+
sql: "CREATE TABLE coffre_setup_probe (probe integer)",
|
|
3176
|
+
privilege: "has_schema_privilege($1, 'public', 'CREATE') OR has_database_privilege($1, current_database(), 'CREATE')"
|
|
3177
|
+
}
|
|
3178
|
+
];
|
|
3179
|
+
/** Each probe, tried as the login in a transaction rolled back: allowed, or refused for want of a privilege. */
|
|
3180
|
+
async function probed(url) {
|
|
3181
|
+
const client = new esm_default.Client({
|
|
3182
|
+
...postgresConnection(url),
|
|
3183
|
+
application_name: "coffre-setup"
|
|
3184
|
+
});
|
|
3185
|
+
await client.connect();
|
|
3186
|
+
try {
|
|
3187
|
+
const allowed = [];
|
|
3188
|
+
for (const { sql } of BOUNDARY) {
|
|
3189
|
+
await client.query("BEGIN");
|
|
3190
|
+
try {
|
|
3191
|
+
await client.query(sql);
|
|
3192
|
+
allowed.push(true);
|
|
3193
|
+
} catch (error) {
|
|
3194
|
+
if (error.code !== "42501") throw error;
|
|
3195
|
+
allowed.push(false);
|
|
3196
|
+
} finally {
|
|
3197
|
+
await client.query("ROLLBACK");
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
return allowed;
|
|
3201
|
+
} finally {
|
|
3202
|
+
await client.end();
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
/** The same, from the catalog, for a login whose password this run did not set: the administrator asks for it. */
|
|
3206
|
+
async function granted(client, role) {
|
|
3207
|
+
const allowed = [];
|
|
3208
|
+
for (const { privilege } of BOUNDARY) allowed.push((await client.query(`SELECT ${privilege} AS allowed`, [role])).rows[0].allowed);
|
|
3209
|
+
return allowed;
|
|
3210
|
+
}
|
|
3211
|
+
/** A login's line under the check, or the error that stops setup when its rights are wrong. */
|
|
3212
|
+
function checked(component, login, allowed) {
|
|
3213
|
+
const wrong = BOUNDARY.flatMap(({ what, expected }, i) => allowed[i] === expected[component] ? [] : [`${allowed[i] ? "can" : "cannot"} ${what}`]);
|
|
3214
|
+
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.`);
|
|
3215
|
+
const can = BOUNDARY.filter(({ expected }) => expected[component]).map(({ what }) => what);
|
|
3216
|
+
const cannot = BOUNDARY.filter(({ expected }) => !expected[component]).map(({ what }) => what);
|
|
3217
|
+
return `${login.role.padEnd(20)} ${can.length > 0 ? `can ${listed(can, "and")}; ` : ""}cannot ${listed(cannot, "or")}`;
|
|
3218
|
+
}
|
|
3219
|
+
/** The values to save, by component: the app's key and URL, the vault's ID, key and URL, as this run made them. */
|
|
3220
|
+
function setupValues({ keys, app, vault }) {
|
|
3221
|
+
const made = keys === null ? {
|
|
3222
|
+
app: [],
|
|
3223
|
+
vault: []
|
|
3224
|
+
} : keyValues(keys);
|
|
3225
|
+
const database = (login, component) => login.url === null ? [] : [{
|
|
3226
|
+
label: `${component === "app" ? "App" : "Vault"} database URL`,
|
|
3227
|
+
value: login.url,
|
|
3228
|
+
mask: "password",
|
|
3229
|
+
about: `The ${component}'s own database login. Goes in the ${component}: its Hyperdrive config, or DATABASE_URL.`
|
|
3230
|
+
}];
|
|
3231
|
+
return {
|
|
3232
|
+
app: [...made.app, ...database(app, "app")],
|
|
3233
|
+
vault: [...made.vault, ...database(vault, "vault")]
|
|
3234
|
+
};
|
|
3235
|
+
}
|
|
3236
|
+
/** The screen: the values, and where each goes, with the Hyperdrive commands to copy. */
|
|
3237
|
+
function setupScreen(result) {
|
|
3238
|
+
const { app, vault } = setupValues(result);
|
|
3239
|
+
const set = ["app", "vault"].filter((component) => result[component].url !== null);
|
|
3240
|
+
const created = set.some((component) => result[component].password === "created");
|
|
3241
|
+
const keys = keyGuide();
|
|
3242
|
+
const hyperdrive = set.map((component) => {
|
|
3243
|
+
return { command: hyperdriveCommand(result[component].password === "created" ? `create ${HYPERDRIVE[component]} --caching-disabled` : `update <the ${component}'s config id>`) };
|
|
3244
|
+
});
|
|
3245
|
+
const workers = [...set.length === 0 ? [] : [
|
|
3246
|
+
`${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.`,
|
|
3247
|
+
...hyperdrive.flatMap((command, i) => [`The ${set[i]} database URL:`, command]),
|
|
3248
|
+
"Or make them in the Cloudflare dashboard, under Hyperdrive.",
|
|
3249
|
+
...created ? ["Their ids go under hyperdrive, in app/wrangler.jsonc and vault/wrangler.jsonc."] : []
|
|
3250
|
+
], ...result.keys === null ? [] : keys.workers];
|
|
3251
|
+
const node = [
|
|
3252
|
+
...result.keys === null ? [] : keys.node,
|
|
3253
|
+
...set.map((component) => `${component === "app" ? "server.env" : "vault.env"} takes the ${component} database URL, as DATABASE_URL.`),
|
|
3254
|
+
"Keep each file readable only by its process (chmod 600)."
|
|
3255
|
+
];
|
|
3256
|
+
return {
|
|
3257
|
+
title: "coffre setup",
|
|
3258
|
+
sections: [...app.length > 0 ? [{
|
|
3259
|
+
title: "App",
|
|
3260
|
+
values: app
|
|
3261
|
+
}] : [], ...vault.length > 0 ? [{
|
|
3262
|
+
title: "Vault",
|
|
3263
|
+
values: vault
|
|
3264
|
+
}] : []],
|
|
3265
|
+
guide: [{
|
|
3266
|
+
title: "On Cloudflare Workers",
|
|
3267
|
+
lines: workers
|
|
3268
|
+
}, {
|
|
3269
|
+
title: "On Node",
|
|
3270
|
+
lines: node
|
|
3271
|
+
}]
|
|
3272
|
+
};
|
|
3273
|
+
}
|
|
3274
|
+
/** What the values were, by name, for the --json warning. */
|
|
3275
|
+
function shownNames(result) {
|
|
3276
|
+
const { app, vault } = setupValues(result);
|
|
3277
|
+
return [...app, ...vault].filter(({ mask }) => mask !== "none").map(({ label }) => `the ${label.replace(/^[A-Z]/, (c) => c.toLowerCase())}`);
|
|
3278
|
+
}
|
|
3279
|
+
/** The same values, for a script: each component's, under the examples' names, and what happened to each login. */
|
|
3280
|
+
function asJson({ keys, app, vault }) {
|
|
3281
|
+
const database = (login) => login.url === null ? {} : { DATABASE_URL: login.url };
|
|
3282
|
+
return {
|
|
3283
|
+
app: {
|
|
3284
|
+
...keys === null ? {} : { APP_KEY: keys.APP_KEY },
|
|
3285
|
+
...database(app)
|
|
3286
|
+
},
|
|
3287
|
+
vault: {
|
|
3288
|
+
...keys === null ? {} : {
|
|
3289
|
+
VAULT_KEY_ID: keys.VAULT_KEY_ID,
|
|
3290
|
+
VAULT_KEY: keys.VAULT_KEY
|
|
3291
|
+
},
|
|
3292
|
+
...database(vault)
|
|
3293
|
+
},
|
|
3294
|
+
logins: Object.fromEntries([app, vault].map(({ role, login, password }) => [role, {
|
|
3295
|
+
login,
|
|
3296
|
+
password
|
|
3297
|
+
}]))
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
/** After the screen: what happened, and what comes next, with no secret in it. */
|
|
3301
|
+
function summary(result, out) {
|
|
3302
|
+
const s = style(out);
|
|
3303
|
+
const { app, vault } = setupValues(result);
|
|
3304
|
+
const names = [...app, ...vault].filter(({ mask }) => mask !== "none").map(({ label }) => label.replace(/^[A-Z]/, (c) => c.toLowerCase()));
|
|
3305
|
+
const created = result.app.password === "created" || result.vault.password === "created";
|
|
3306
|
+
const reset = result.app.password === "reset" || result.vault.password === "reset";
|
|
3307
|
+
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"]];
|
|
3308
|
+
const fresh = created || result.keys !== null;
|
|
3309
|
+
return [
|
|
3310
|
+
"",
|
|
3311
|
+
` ${s.green("✓")} ${s.bold(`The ${listed(names, "and")} were shown once.`)}`,
|
|
3312
|
+
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)),
|
|
3313
|
+
"",
|
|
3314
|
+
...result.keys === null ? [] : [row(out, s, "Vault ID", `${result.keys.VAULT_KEY_ID}, not a secret`), ""],
|
|
3315
|
+
` ${s.bold("Next")}`,
|
|
3316
|
+
row(out, s, "Workers", `${workers.join("; ")}${fresh ? "; then pnpm run deploy" : ", which needs no redeploy"}.`),
|
|
3317
|
+
row(out, s, "Node", fresh ? "server.env and vault.env, then pnpm vault and pnpm start." : "each DATABASE_URL updated, then both processes restarted."),
|
|
3318
|
+
s.dim(paragraph(out, "After every upgrade of coffre, pnpm migrate. docs/deploy.md has each step.", 4)),
|
|
3319
|
+
"",
|
|
3320
|
+
""
|
|
3321
|
+
].join("\n");
|
|
3322
|
+
}
|
|
3323
|
+
function nothingToSave(out) {
|
|
3324
|
+
const s = style(out);
|
|
3325
|
+
return [
|
|
3326
|
+
"",
|
|
3327
|
+
` ${s.green("✓")} ${s.bold("Nothing new to save.")}`,
|
|
3328
|
+
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)),
|
|
3329
|
+
"",
|
|
3330
|
+
""
|
|
3331
|
+
].join("\n");
|
|
3332
|
+
}
|
|
3333
|
+
/** An error's text without any secret in it: a driver may quote what it was given. */
|
|
3334
|
+
function redact(text, secrets) {
|
|
3335
|
+
return secrets.filter((secret) => secret.length >= 4).reduce((out, secret) => out.split(secret).join("…"), text);
|
|
3336
|
+
}
|
|
3337
|
+
function count(n, noun) {
|
|
3338
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
3339
|
+
}
|
|
3340
|
+
//#endregion
|
|
3341
|
+
export { setup };
|