@coffre/cli 0.1.13 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{connect-BXDkiWOL.js → connect-k2qEwjqZ.js} +1 -1
- package/dist/database-url-CJ9yqoi_.js +213 -0
- package/dist/deployment-S8za5nUu.js +1369 -0
- package/dist/init-mO8V087p.js +71 -0
- package/dist/{keys-BCKUEb-Y.js → keys-DDdMwm0N.js} +2 -352
- package/dist/main.js +121 -2
- package/dist/migrate-ERNNgYO0.js +163 -0
- package/dist/{setup-BMmFAR3N.js → setup-qGoLPykX.js} +17 -1810
- package/dist/templates/node/README.md +4 -2
- package/dist/templates/node/package.json +3 -3
- package/dist/templates/workers/README.md +4 -2
- package/dist/templates/workers/package.json +4 -4
- package/dist/tty-BDa14JX2.js +283 -0
- package/dist/update-DwGbG-eM.js +263 -0
- package/dist/version-CPk6zTTK.js +317 -0
- package/package.json +4 -4
- package/dist/{connect-tekAM4hC.js → connect-C4wevMJ5.js} +34 -34
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
//#region src/init.ts
|
|
5
|
+
const KINDS = ["workers", "node"];
|
|
6
|
+
/** Where a template lives: in the package when published, else in the repository. */
|
|
7
|
+
function templateDir(kind) {
|
|
8
|
+
const packed = fileURLToPath(new URL(`./templates/${kind}/`, import.meta.url));
|
|
9
|
+
if (existsSync(packed)) return packed;
|
|
10
|
+
return fileURLToPath(new URL(`../../../examples/${kind}/`, import.meta.url));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A template's files, relative and sorted: everything but what its own
|
|
14
|
+
* `.gitignore` names, such as installed packages, local state and secrets.
|
|
15
|
+
* Patterns are file or directory names, `*` standing for any run of
|
|
16
|
+
* characters.
|
|
17
|
+
*/
|
|
18
|
+
function templateFiles(dir) {
|
|
19
|
+
const ignoreFile = [join(dir, ".gitignore"), join(dir, "gitignore")].find((path) => existsSync(path));
|
|
20
|
+
const ignored = (ignoreFile === void 0 ? "" : readFileSync(ignoreFile, "utf8")).split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")).map((glob) => new RegExp(`^${glob.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`));
|
|
21
|
+
const files = [];
|
|
22
|
+
const walk = (relative) => {
|
|
23
|
+
for (const entry of readdirSync(join(dir, relative), { withFileTypes: true })) {
|
|
24
|
+
if (ignored.some((pattern) => pattern.test(entry.name))) continue;
|
|
25
|
+
const path = relative === "" ? entry.name : `${relative}/${entry.name}`;
|
|
26
|
+
if (entry.isDirectory()) walk(path);
|
|
27
|
+
else files.push(path);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
walk("");
|
|
31
|
+
return files.sort();
|
|
32
|
+
}
|
|
33
|
+
/** The name a template file has in a project. */
|
|
34
|
+
function projectPath(templatePath) {
|
|
35
|
+
return templatePath === "gitignore" ? ".gitignore" : templatePath;
|
|
36
|
+
}
|
|
37
|
+
/** A package name from a directory's, e.g. `Acme Secrets` → `acme-secrets`. */
|
|
38
|
+
function packageName(dir) {
|
|
39
|
+
const name = basename(dir).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-._]+|-+$/g, "");
|
|
40
|
+
return name === "" ? "coffre" : name;
|
|
41
|
+
}
|
|
42
|
+
/** The template's package.json, for this project and this version of coffre. */
|
|
43
|
+
function manifest(source, name, version) {
|
|
44
|
+
const pkg = JSON.parse(source);
|
|
45
|
+
pkg.name = name;
|
|
46
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
47
|
+
const deps = pkg[field];
|
|
48
|
+
for (const dep of Object.keys(deps ?? {})) if (dep.startsWith("@coffre/")) deps[dep] = version;
|
|
49
|
+
}
|
|
50
|
+
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
51
|
+
}
|
|
52
|
+
/** Whether a directory holds nothing yet, or nothing but `.git`: a repository made first, then cloned. */
|
|
53
|
+
function isEmpty(dir) {
|
|
54
|
+
return !existsSync(dir) || readdirSync(dir).every((entry) => entry === ".git");
|
|
55
|
+
}
|
|
56
|
+
/** Write a new deployment into `target`, which must be empty (`isEmpty`) or absent. Returns the files written. */
|
|
57
|
+
function init(kind, target, version) {
|
|
58
|
+
if (!isEmpty(target)) throw new Error(`${target} is not empty; give \`coffre init\` a new directory`);
|
|
59
|
+
const source = templateDir(kind);
|
|
60
|
+
const written = [];
|
|
61
|
+
for (const file of templateFiles(source)) {
|
|
62
|
+
const path = projectPath(file);
|
|
63
|
+
const content = readFileSync(join(source, file), "utf8");
|
|
64
|
+
mkdirSync(dirname(join(target, path)), { recursive: true });
|
|
65
|
+
writeFileSync(join(target, path), path === "package.json" ? manifest(content, packageName(target), version) : content);
|
|
66
|
+
written.push(path);
|
|
67
|
+
}
|
|
68
|
+
return written;
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
export { init as n, isEmpty as r, KINDS as t };
|
|
@@ -1,78 +1,7 @@
|
|
|
1
|
+
import { a as openTerminal, c as release, d as style, h as wrap, l as row, m as width, o as paragraph, p as truncate, s as readKeys } from "./tty-BDa14JX2.js";
|
|
1
2
|
import { parseArgs } from "node:util";
|
|
2
3
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, mkdirSync, openSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { basename, dirname, join } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
4
|
import { randomBytes } from "node:crypto";
|
|
7
|
-
import { emitKeypressEvents } from "node:readline";
|
|
8
|
-
import tty from "node:tty";
|
|
9
|
-
//#region src/init.ts
|
|
10
|
-
const KINDS = ["workers", "node"];
|
|
11
|
-
/** Where a template lives: in the package when published, else in the repository. */
|
|
12
|
-
function templateDir(kind) {
|
|
13
|
-
const packed = fileURLToPath(new URL(`./templates/${kind}/`, import.meta.url));
|
|
14
|
-
if (existsSync(packed)) return packed;
|
|
15
|
-
return fileURLToPath(new URL(`../../../examples/${kind}/`, import.meta.url));
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* A template's files, relative and sorted: everything but what its own
|
|
19
|
-
* `.gitignore` names, such as installed packages, local state and secrets.
|
|
20
|
-
* Patterns are file or directory names, `*` standing for any run of
|
|
21
|
-
* characters.
|
|
22
|
-
*/
|
|
23
|
-
function templateFiles(dir) {
|
|
24
|
-
const ignoreFile = [join(dir, ".gitignore"), join(dir, "gitignore")].find((path) => existsSync(path));
|
|
25
|
-
const ignored = (ignoreFile === void 0 ? "" : readFileSync(ignoreFile, "utf8")).split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")).map((glob) => new RegExp(`^${glob.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`));
|
|
26
|
-
const files = [];
|
|
27
|
-
const walk = (relative) => {
|
|
28
|
-
for (const entry of readdirSync(join(dir, relative), { withFileTypes: true })) {
|
|
29
|
-
if (ignored.some((pattern) => pattern.test(entry.name))) continue;
|
|
30
|
-
const path = relative === "" ? entry.name : `${relative}/${entry.name}`;
|
|
31
|
-
if (entry.isDirectory()) walk(path);
|
|
32
|
-
else files.push(path);
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
walk("");
|
|
36
|
-
return files.sort();
|
|
37
|
-
}
|
|
38
|
-
/** The name a template file has in a project. */
|
|
39
|
-
function projectPath(templatePath) {
|
|
40
|
-
return templatePath === "gitignore" ? ".gitignore" : templatePath;
|
|
41
|
-
}
|
|
42
|
-
/** A package name from a directory's, e.g. `Acme Secrets` → `acme-secrets`. */
|
|
43
|
-
function packageName(dir) {
|
|
44
|
-
const name = basename(dir).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[-._]+|-+$/g, "");
|
|
45
|
-
return name === "" ? "coffre" : name;
|
|
46
|
-
}
|
|
47
|
-
/** The template's package.json, for this project and this version of coffre. */
|
|
48
|
-
function manifest(source, name, version) {
|
|
49
|
-
const pkg = JSON.parse(source);
|
|
50
|
-
pkg.name = name;
|
|
51
|
-
for (const field of ["dependencies", "devDependencies"]) {
|
|
52
|
-
const deps = pkg[field];
|
|
53
|
-
for (const dep of Object.keys(deps ?? {})) if (dep.startsWith("@coffre/")) deps[dep] = version;
|
|
54
|
-
}
|
|
55
|
-
return `${JSON.stringify(pkg, null, 2)}\n`;
|
|
56
|
-
}
|
|
57
|
-
/** Whether a directory holds nothing yet, or nothing but `.git`: a repository made first, then cloned. */
|
|
58
|
-
function isEmpty(dir) {
|
|
59
|
-
return !existsSync(dir) || readdirSync(dir).every((entry) => entry === ".git");
|
|
60
|
-
}
|
|
61
|
-
/** Write a new deployment into `target`, which must be empty (`isEmpty`) or absent. Returns the files written. */
|
|
62
|
-
function init(kind, target, version) {
|
|
63
|
-
if (!isEmpty(target)) throw new Error(`${target} is not empty; give \`coffre init\` a new directory`);
|
|
64
|
-
const source = templateDir(kind);
|
|
65
|
-
const written = [];
|
|
66
|
-
for (const file of templateFiles(source)) {
|
|
67
|
-
const path = projectPath(file);
|
|
68
|
-
const content = readFileSync(join(source, file), "utf8");
|
|
69
|
-
mkdirSync(dirname(join(target, path)), { recursive: true });
|
|
70
|
-
writeFileSync(join(target, path), path === "package.json" ? manifest(content, packageName(target), version) : content);
|
|
71
|
-
written.push(path);
|
|
72
|
-
}
|
|
73
|
-
return written;
|
|
74
|
-
}
|
|
75
|
-
//#endregion
|
|
76
5
|
//#region src/clipboard.ts
|
|
77
6
|
const CLEAR_AFTER_MS = 3e4;
|
|
78
7
|
/** The system's clipboard tool, by platform and display server; null for none known. */
|
|
@@ -200,285 +129,6 @@ function read([command, ...args]) {
|
|
|
200
129
|
});
|
|
201
130
|
}
|
|
202
131
|
//#endregion
|
|
203
|
-
//#region src/tty.ts
|
|
204
|
-
const plain = (text) => text;
|
|
205
|
-
/**
|
|
206
|
-
* The web UI's accent, green and red, at the depth the terminal takes:
|
|
207
|
-
* 24-bit, 256 colours or the 16 named ones. NO_COLOR (and FORCE_COLOR) are
|
|
208
|
-
* the stream's own `hasColors`; bold and dim stay, being no colour.
|
|
209
|
-
*/
|
|
210
|
-
function style(stream) {
|
|
211
|
-
if (stream.isTTY !== true) return {
|
|
212
|
-
ansi: false,
|
|
213
|
-
bold: plain,
|
|
214
|
-
dim: plain,
|
|
215
|
-
accent: plain,
|
|
216
|
-
green: plain,
|
|
217
|
-
red: plain
|
|
218
|
-
};
|
|
219
|
-
const terminal = stream;
|
|
220
|
-
const depth = typeof terminal.hasColors === "function" && terminal.hasColors() ? terminal.getColorDepth() : 1;
|
|
221
|
-
const sgr = (open, close) => (text) => `\x1b[${open}m${text}\x1b[${close}m`;
|
|
222
|
-
const colour = (rgb, index256, index16) => depth >= 24 ? sgr(`38;2;${rgb.join(";")}`, "39") : depth >= 8 ? sgr(`38;5;${index256}`, "39") : depth >= 4 ? sgr(String(index16), "39") : plain;
|
|
223
|
-
return {
|
|
224
|
-
ansi: true,
|
|
225
|
-
bold: sgr("1", "22"),
|
|
226
|
-
dim: sgr("2", "22"),
|
|
227
|
-
accent: colour([
|
|
228
|
-
91,
|
|
229
|
-
141,
|
|
230
|
-
239
|
|
231
|
-
], 69, 94),
|
|
232
|
-
green: colour([
|
|
233
|
-
63,
|
|
234
|
-
174,
|
|
235
|
-
111
|
|
236
|
-
], 71, 32),
|
|
237
|
-
red: colour([
|
|
238
|
-
229,
|
|
239
|
-
83,
|
|
240
|
-
75
|
|
241
|
-
], 167, 31)
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
const ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g;
|
|
245
|
-
/** Columns `text` takes on screen: its characters, less its escape codes. */
|
|
246
|
-
function width(text) {
|
|
247
|
-
return [...text.replace(ESCAPE, "")].length;
|
|
248
|
-
}
|
|
249
|
-
/** `text` cut to `columns` on screen, escape codes kept, an ellipsis where it was cut. */
|
|
250
|
-
function truncate(text, columns) {
|
|
251
|
-
if (width(text) <= columns) return text;
|
|
252
|
-
let out = "";
|
|
253
|
-
let used = 0;
|
|
254
|
-
for (let i = 0; i < text.length;) {
|
|
255
|
-
ESCAPE.lastIndex = i;
|
|
256
|
-
const code = text[i] === "\x1B" ? ESCAPE.exec(text) : null;
|
|
257
|
-
if (code !== null && code.index === i) {
|
|
258
|
-
out += code[0];
|
|
259
|
-
i += code[0].length;
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
const char = String.fromCodePoint(text.codePointAt(i));
|
|
263
|
-
if (used === columns - 1) return `${out}…${text.includes("\x1B") ? "\x1B[0m" : ""}`;
|
|
264
|
-
out += char;
|
|
265
|
-
used += 1;
|
|
266
|
-
i += char.length;
|
|
267
|
-
}
|
|
268
|
-
return out;
|
|
269
|
-
}
|
|
270
|
-
/** Plain `text` in lines of at most `columns`, broken at spaces, or anywhere in a word longer than a line. */
|
|
271
|
-
function wrap(text, columns) {
|
|
272
|
-
const lines = [];
|
|
273
|
-
let line = "";
|
|
274
|
-
for (const word of text.split(" ")) {
|
|
275
|
-
let rest = word;
|
|
276
|
-
while (width(rest) > columns) {
|
|
277
|
-
if (line !== "") lines.push(line);
|
|
278
|
-
line = "";
|
|
279
|
-
lines.push([...rest].slice(0, columns).join(""));
|
|
280
|
-
rest = [...rest].slice(columns).join("");
|
|
281
|
-
}
|
|
282
|
-
if (line === "") line = rest;
|
|
283
|
-
else if (width(line) + 1 + width(rest) <= columns) line += ` ${rest}`;
|
|
284
|
-
else {
|
|
285
|
-
lines.push(line);
|
|
286
|
-
line = rest;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
return [...lines, line];
|
|
290
|
-
}
|
|
291
|
-
/** `text` wrapped to the stream's width, each line indented by `indent` spaces. */
|
|
292
|
-
function paragraph(out, text, indent = 2) {
|
|
293
|
-
const pad = " ".repeat(indent);
|
|
294
|
-
return wrap(text, Math.max(20, (out.columns || 80) - indent - 1)).map((line) => `${pad}${line}`).join("\n");
|
|
295
|
-
}
|
|
296
|
-
/** "a, b or c". */
|
|
297
|
-
function listed(items, last) {
|
|
298
|
-
return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} ${last} ${items.at(-1)}`;
|
|
299
|
-
}
|
|
300
|
-
/** A labelled row: `label` in a column of `column`, `text` wrapped beside it with a hanging indent. */
|
|
301
|
-
function row(out, s, label, text, indent = 4, column = 10) {
|
|
302
|
-
const lines = wrap(text, Math.max(20, (out.columns || 80) - indent - column - 1));
|
|
303
|
-
const pad = " ".repeat(indent);
|
|
304
|
-
return lines.map((line, i) => `${pad}${i === 0 ? s.dim(label.padEnd(column)) : " ".repeat(column)}${line}`).join("\n");
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* The keyboard: stdin when it is a terminal, otherwise the terminal itself,
|
|
308
|
-
* as `less` reads it when its input is piped. Null without one.
|
|
309
|
-
*/
|
|
310
|
-
function keyboard() {
|
|
311
|
-
if (process.stdin.isTTY) return process.stdin;
|
|
312
|
-
try {
|
|
313
|
-
return new tty.ReadStream(openSync("/dev/tty", "r"));
|
|
314
|
-
} catch {
|
|
315
|
-
return null;
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
/** Where a screen is drawn and read from: stdout, and the keyboard. Null unless both are a terminal. */
|
|
319
|
-
function openTerminal() {
|
|
320
|
-
if (!process.stdout.isTTY) return null;
|
|
321
|
-
const keys = keyboard();
|
|
322
|
-
return keys === null ? null : {
|
|
323
|
-
keys,
|
|
324
|
-
out: process.stdout
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
/** Release a keyboard `keyboard()` opened: stdin stays, /dev/tty closes. */
|
|
328
|
-
function release(keys) {
|
|
329
|
-
keys.setRawMode?.(false);
|
|
330
|
-
keys.pause();
|
|
331
|
-
if (keys !== process.stdin) keys.destroy?.();
|
|
332
|
-
}
|
|
333
|
-
/**
|
|
334
|
-
* Every key pressed, in raw mode, until `until` returns true, or `signal`
|
|
335
|
-
* aborts the reading. Raw mode is on when this returns: a prompt shown after
|
|
336
|
-
* it gets every key, none echoed or edited by the terminal first.
|
|
337
|
-
*/
|
|
338
|
-
function readKeys(keys, until, signal) {
|
|
339
|
-
emitKeypressEvents(keys);
|
|
340
|
-
keys.setRawMode?.(true);
|
|
341
|
-
keys.resume();
|
|
342
|
-
return new Promise((resolve, reject) => {
|
|
343
|
-
let busy = Promise.resolve();
|
|
344
|
-
const stop = () => {
|
|
345
|
-
keys.off("keypress", onKey);
|
|
346
|
-
signal?.removeEventListener("abort", stop);
|
|
347
|
-
keys.setRawMode?.(false);
|
|
348
|
-
keys.pause();
|
|
349
|
-
resolve();
|
|
350
|
-
};
|
|
351
|
-
const onKey = (sequence, key) => {
|
|
352
|
-
busy = busy.then(async () => {
|
|
353
|
-
if (await until(key ?? { sequence }, sequence ?? key?.sequence ?? "")) stop();
|
|
354
|
-
}).catch(reject);
|
|
355
|
-
};
|
|
356
|
-
keys.on("keypress", onKey);
|
|
357
|
-
if (signal?.aborted) stop();
|
|
358
|
-
else signal?.addEventListener("abort", stop);
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
/** What a typed key does to a line: its new text, or `submit`, or `cancel`. */
|
|
362
|
-
function edit(text, key, sequence) {
|
|
363
|
-
if (key.ctrl && key.name === "c") return "cancel";
|
|
364
|
-
if (key.name === "return" || key.name === "enter") return "submit";
|
|
365
|
-
if (key.name === "backspace") return text.slice(0, -1);
|
|
366
|
-
if (key.ctrl && key.name === "u") return "";
|
|
367
|
-
if (!key.ctrl && !key.meta && sequence.length > 0 && !sequence.startsWith("\x1B") && sequence >= " ") return text + sequence;
|
|
368
|
-
return text;
|
|
369
|
-
}
|
|
370
|
-
/**
|
|
371
|
-
* A question answered by typing, shown as typed, `initial` to start from;
|
|
372
|
-
* `note`, dim under it while it is asked; `check` says why an answer will
|
|
373
|
-
* not do, under it. Once given, the question becomes one line: what was
|
|
374
|
-
* asked, and the answer. Ctrl-C cancels.
|
|
375
|
-
*/
|
|
376
|
-
async function textLine(keys, out, s, question, hint, options = {}) {
|
|
377
|
-
const columns = Math.max(20, (out.columns || 80) - 1);
|
|
378
|
-
let typed = options.initial ?? "";
|
|
379
|
-
let error = null;
|
|
380
|
-
const draw = () => {
|
|
381
|
-
const below = [...error === null ? [] : [s.red(error)], ...options.note === void 0 ? [] : wrap(options.note, columns - 4).map(s.dim)].map((line) => `\n${truncate(` ${line}`, columns)}`);
|
|
382
|
-
out.write(`\r\x1b[J${below.join("")}${below.length > 0 ? `\x1b[${below.length}A` : ""}\r${truncate(` ${s.accent("›")} ${typed}`, columns)}`);
|
|
383
|
-
};
|
|
384
|
-
let cancelled = false;
|
|
385
|
-
const answered = readKeys(keys, async (key, sequence) => {
|
|
386
|
-
const next = edit(typed, key, sequence);
|
|
387
|
-
if (next === "cancel") cancelled = true;
|
|
388
|
-
else if (next === "submit") {
|
|
389
|
-
if (typed.trim() === "") return false;
|
|
390
|
-
error = await options.check?.(typed.trim()) ?? null;
|
|
391
|
-
if (error === null) return true;
|
|
392
|
-
} else {
|
|
393
|
-
typed = next;
|
|
394
|
-
error = null;
|
|
395
|
-
}
|
|
396
|
-
if (cancelled) return true;
|
|
397
|
-
draw();
|
|
398
|
-
return false;
|
|
399
|
-
});
|
|
400
|
-
out.write(`${truncate(` ${s.accent("?")} ${s.bold(question)} ${s.dim(hint)}`, columns)}\n`);
|
|
401
|
-
draw();
|
|
402
|
-
await answered;
|
|
403
|
-
out.write("\r\x1B[J\x1B[1A\x1B[2K");
|
|
404
|
-
if (cancelled) throw new Cancelled();
|
|
405
|
-
out.write(`${truncate(` ${s.green("✓")} ${s.dim(question)} ${typed.trim()}`, columns)}\n`);
|
|
406
|
-
return typed.trim();
|
|
407
|
-
}
|
|
408
|
-
/** One of `options`, chosen with the arrows and Enter; then one line, as `textLine` leaves. Ctrl-C cancels. */
|
|
409
|
-
async function select(keys, out, s, question, options) {
|
|
410
|
-
const columns = Math.max(20, (out.columns || 80) - 1);
|
|
411
|
-
let at = 0;
|
|
412
|
-
let drawn = 0;
|
|
413
|
-
const draw = () => {
|
|
414
|
-
const lines = [` ${s.accent("?")} ${s.bold(question)} ${s.dim("↑↓ then Enter")}`, ...options.map((option, i) => i === at ? ` ${s.accent("›")} ${s.accent(option)}` : ` ${option}`)];
|
|
415
|
-
out.write(`${drawn > 0 ? `\x1b[${drawn}F` : ""}${lines.map((line) => `${truncate(line, columns)}\x1b[K\n`).join("")}`);
|
|
416
|
-
drawn = lines.length;
|
|
417
|
-
};
|
|
418
|
-
let cancelled = false;
|
|
419
|
-
const chosen = readKeys(keys, (key) => {
|
|
420
|
-
if (key.ctrl && key.name === "c") cancelled = true;
|
|
421
|
-
else if (key.name === "return" || key.name === "enter") return true;
|
|
422
|
-
else if (key.name === "up" || key.name === "k") at = (at - 1 + options.length) % options.length;
|
|
423
|
-
else if (key.name === "down" || key.name === "j" || key.name === "tab") at = (at + 1) % options.length;
|
|
424
|
-
if (cancelled) return true;
|
|
425
|
-
draw();
|
|
426
|
-
return false;
|
|
427
|
-
});
|
|
428
|
-
draw();
|
|
429
|
-
await chosen;
|
|
430
|
-
out.write(`\x1b[${drawn}F\x1b[J`);
|
|
431
|
-
if (cancelled) throw new Cancelled();
|
|
432
|
-
out.write(`${truncate(` ${s.green("✓")} ${s.dim(question)} ${options[at]}`, columns)}\n`);
|
|
433
|
-
return at;
|
|
434
|
-
}
|
|
435
|
-
var Cancelled = class extends Error {
|
|
436
|
-
constructor() {
|
|
437
|
-
super("cancelled");
|
|
438
|
-
}
|
|
439
|
-
};
|
|
440
|
-
/**
|
|
441
|
-
* A line typed without echo: a bullet for each character, so that a paste
|
|
442
|
-
* shows it arrived. The question, its hint and the bullets go once it is
|
|
443
|
-
* given. Ctrl-C cancels.
|
|
444
|
-
*/
|
|
445
|
-
async function hiddenLine(keys, out, s, question, hint) {
|
|
446
|
-
const columns = Math.max(20, (out.columns || 80) - 1);
|
|
447
|
-
let typed = "";
|
|
448
|
-
const draw = () => {
|
|
449
|
-
const count = typed.length > 0 ? s.dim(` ${typed.length} characters`) : "";
|
|
450
|
-
out.write(`\r\x1b[2K${truncate(` ${s.accent("›")} ${"•".repeat(typed.length)}`, columns - width(count))}${count}`);
|
|
451
|
-
};
|
|
452
|
-
let cancelled = false;
|
|
453
|
-
const typedIn = readKeys(keys, (key, sequence) => {
|
|
454
|
-
const next = edit(typed, key, sequence);
|
|
455
|
-
if (next === "cancel") cancelled = true;
|
|
456
|
-
else if (next === "submit") return true;
|
|
457
|
-
else typed = next;
|
|
458
|
-
if (cancelled) return true;
|
|
459
|
-
draw();
|
|
460
|
-
return false;
|
|
461
|
-
});
|
|
462
|
-
out.write(`${truncate(` ${s.accent("?")} ${s.bold(question)}`, columns)}\n${truncate(` ${s.dim(hint)}`, columns)}\n`);
|
|
463
|
-
draw();
|
|
464
|
-
await typedIn;
|
|
465
|
-
out.write("\r\x1B[2K\x1B[1A\x1B[2K\x1B[1A\x1B[2K");
|
|
466
|
-
if (cancelled) throw new Cancelled();
|
|
467
|
-
return typed.trim();
|
|
468
|
-
}
|
|
469
|
-
/** One key: y for yes, anything else for no. Ctrl-C cancels. */
|
|
470
|
-
async function yes(keys) {
|
|
471
|
-
let answer = false;
|
|
472
|
-
let cancelled = false;
|
|
473
|
-
await readKeys(keys, (key) => {
|
|
474
|
-
if (key.ctrl && key.name === "c") cancelled = true;
|
|
475
|
-
answer = key.name === "y";
|
|
476
|
-
return true;
|
|
477
|
-
});
|
|
478
|
-
if (cancelled) throw new Cancelled();
|
|
479
|
-
return answer;
|
|
480
|
-
}
|
|
481
|
-
//#endregion
|
|
482
132
|
//#region src/secrets.ts
|
|
483
133
|
function initialState() {
|
|
484
134
|
return {
|
|
@@ -878,4 +528,4 @@ async function keys(args) {
|
|
|
878
528
|
].join("\n"));
|
|
879
529
|
}
|
|
880
530
|
//#endregion
|
|
881
|
-
export {
|
|
531
|
+
export { keys as a, keyValues as i, jsonWarning as n, needsTerminal as o, keyGuide as r, showSecrets as s, generateKeys as t };
|
package/dist/main.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { n as init, t as KINDS } from "./init-mO8V087p.js";
|
|
3
|
+
import { a as keys } from "./keys-DDdMwm0N.js";
|
|
4
|
+
import { a as openTerminal, c as release, d as style, r as hiddenLine, t as Cancelled, u as select } from "./tty-BDa14JX2.js";
|
|
3
5
|
import { i as isRole, r as assignableToEnvironment, t as ROLES } from "./access-CO1kGjWW.js";
|
|
4
6
|
import { inspect, parseArgs, promisify } from "node:util";
|
|
5
7
|
import { execFile, spawn } from "node:child_process";
|
|
@@ -7,6 +9,37 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync,
|
|
|
7
9
|
import { homedir, hostname } from "node:os";
|
|
8
10
|
import { dirname, join, resolve } from "node:path";
|
|
9
11
|
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
12
|
+
/**
|
|
13
|
+
* The line to show, or null: nothing pending, or nothing told. An instance
|
|
14
|
+
* older than this notice has no `instance` on `/me` at all.
|
|
15
|
+
*/
|
|
16
|
+
function noticeFor(origin, instance) {
|
|
17
|
+
if (instance === null || instance === void 0 || !Array.isArray(instance.migrations?.known)) return null;
|
|
18
|
+
const pending = instance.migrations.known.slice(instance.migrations.applied);
|
|
19
|
+
if (pending.length === 0) return null;
|
|
20
|
+
return `${pending.length === 1 ? "1 migration" : `${pending.length} migrations`} pending on ${origin} (${pending.join(", ")}): run \`coffre migrate\``;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The notice for `origin`, at most once a day: null without asking when it
|
|
24
|
+
* was asked less than a day ago; otherwise `me` is asked, and the time kept,
|
|
25
|
+
* whatever the answer. A failure to ask says nothing, and is not kept.
|
|
26
|
+
*/
|
|
27
|
+
async function dailyNotice(origin, now, checked, me) {
|
|
28
|
+
const last = checked.read()[origin];
|
|
29
|
+
if (last !== void 0 && now - last < 864e5) return null;
|
|
30
|
+
let instance;
|
|
31
|
+
try {
|
|
32
|
+
({instance} = await me());
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
checked.write({
|
|
37
|
+
...checked.read(),
|
|
38
|
+
[origin]: now
|
|
39
|
+
});
|
|
40
|
+
return noticeFor(origin, instance);
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
10
43
|
//#region ../client/dist/index.js
|
|
11
44
|
/** The API's error shape, `{ error, message }`, with the HTTP status. */
|
|
12
45
|
const COFFRE_ERROR = Symbol.for("@coffre/client:CoffreError");
|
|
@@ -1797,8 +1830,59 @@ function client(to = target()) {
|
|
|
1797
1830
|
transport: (request) => send(request, to)
|
|
1798
1831
|
});
|
|
1799
1832
|
}
|
|
1833
|
+
/** Whether this run has asked about pending migrations yet: once, before its first request. */
|
|
1834
|
+
let noticed = false;
|
|
1835
|
+
/** The commands that say nothing of pending migrations: migrate says as much itself; login has no session yet; verify sends only what it checks. */
|
|
1836
|
+
const QUIET = /* @__PURE__ */ new Set([
|
|
1837
|
+
"migrate",
|
|
1838
|
+
"login",
|
|
1839
|
+
"verify"
|
|
1840
|
+
]);
|
|
1841
|
+
/**
|
|
1842
|
+
* A line on stderr, at most once a day per instance, when its database is
|
|
1843
|
+
* behind the code it runs (`notice.ts`). Not for a machine's credential:
|
|
1844
|
+
* only owners are told. Whatever goes wrong here, the command goes on.
|
|
1845
|
+
*/
|
|
1846
|
+
async function noticePending(to) {
|
|
1847
|
+
if (noticed) return;
|
|
1848
|
+
noticed = true;
|
|
1849
|
+
if (QUIET.has(command ?? "") || process.env.COFFRE_TOKEN?.trim() || to.credential.kind === "access-service-token") return;
|
|
1850
|
+
const line = await dailyNotice(to.origin, Date.now(), checkedFile, async () => {
|
|
1851
|
+
const response = await fetch(`${to.origin}/api/me`, {
|
|
1852
|
+
headers: await headersFor(to),
|
|
1853
|
+
redirect: "manual"
|
|
1854
|
+
});
|
|
1855
|
+
if (!response.ok || !isJsonContentType(response.headers.get("content-type"))) throw new Error("no answer");
|
|
1856
|
+
return await response.json();
|
|
1857
|
+
}).catch(() => null);
|
|
1858
|
+
if (line !== null) {
|
|
1859
|
+
const s = style(process.stderr);
|
|
1860
|
+
process.stderr.write(`${s.dim(`coffre: ${line}`)}\n`);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
const CHECKED_PATH = join(homedir(), ".coffre", "checked.json");
|
|
1864
|
+
/** When each instance was last asked about its migrations: nothing secret, beside the credentials. */
|
|
1865
|
+
const checkedFile = {
|
|
1866
|
+
read() {
|
|
1867
|
+
try {
|
|
1868
|
+
return JSON.parse(readFileSync(CHECKED_PATH, "utf8"));
|
|
1869
|
+
} catch {
|
|
1870
|
+
return {};
|
|
1871
|
+
}
|
|
1872
|
+
},
|
|
1873
|
+
write(checked) {
|
|
1874
|
+
try {
|
|
1875
|
+
mkdirSync(dirname(CHECKED_PATH), {
|
|
1876
|
+
recursive: true,
|
|
1877
|
+
mode: 448
|
|
1878
|
+
});
|
|
1879
|
+
writeFileSync(CHECKED_PATH, `${JSON.stringify(checked)}\n`, { mode: 384 });
|
|
1880
|
+
} catch {}
|
|
1881
|
+
}
|
|
1882
|
+
};
|
|
1800
1883
|
/** One request; every way it can fail is explained in terms of what to do next. */
|
|
1801
1884
|
async function send(request, to) {
|
|
1885
|
+
await noticePending(to);
|
|
1802
1886
|
let response;
|
|
1803
1887
|
try {
|
|
1804
1888
|
response = await fetch(request, { redirect: "manual" });
|
|
@@ -2392,6 +2476,30 @@ function initProject(args) {
|
|
|
2392
2476
|
const at = dir === process.cwd() ? null : positionals[0] ?? null;
|
|
2393
2477
|
process.stdout.write(`\nA ${kind === "workers" ? "Cloudflare Workers" : "Node"} deployment of coffre ${version}${at === null ? " here" : ` in ${at}`}. README.md takes it from there:\n\n${at === null ? "" : ` cd ${/\s/.test(at) ? `'${at}'` : at}\n`} pnpm install\n`);
|
|
2394
2478
|
}
|
|
2479
|
+
/**
|
|
2480
|
+
* The instance `coffre migrate` works on: the current one, or the one
|
|
2481
|
+
* `--url` names, with the session saved for it.
|
|
2482
|
+
*/
|
|
2483
|
+
function migrateTarget(url) {
|
|
2484
|
+
if (url !== void 0) process.env.COFFRE_API_URL = url;
|
|
2485
|
+
const to = target();
|
|
2486
|
+
const api = client(to);
|
|
2487
|
+
return {
|
|
2488
|
+
origin: to.origin,
|
|
2489
|
+
me: () => api.me(),
|
|
2490
|
+
ready: async () => {
|
|
2491
|
+
try {
|
|
2492
|
+
return await (await fetch(`${to.origin}/readyz`, { redirect: "manual" })).json();
|
|
2493
|
+
} catch {
|
|
2494
|
+
return {
|
|
2495
|
+
ok: false,
|
|
2496
|
+
heartbeatAgeSeconds: null,
|
|
2497
|
+
checkpointed: false
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2395
2503
|
const USAGE = `coffre - secrets, with an audit log
|
|
2396
2504
|
|
|
2397
2505
|
New deployment
|
|
@@ -2403,6 +2511,11 @@ const USAGE = `coffre - secrets, with an audit log
|
|
|
2403
2511
|
Cloudflare too, and in an empty directory, the deployment
|
|
2404
2512
|
coffre keys [--json] the app key, vault key and vault ID alone, shown the same way
|
|
2405
2513
|
|
|
2514
|
+
Upgrade
|
|
2515
|
+
coffre update [--yes] this CLI, and in a deployment, its coffre packages
|
|
2516
|
+
coffre migrate [--url <url>] [--yes] the instance's database, to the schema its version ships,
|
|
2517
|
+
with the owner's connection string, asked for hidden
|
|
2518
|
+
|
|
2406
2519
|
Session
|
|
2407
2520
|
coffre login [<url>] [--no-browser] sign in, and make <url> the current instance
|
|
2408
2521
|
coffre logout [<url>]
|
|
@@ -2453,7 +2566,13 @@ switch (command) {
|
|
|
2453
2566
|
await keys(rest);
|
|
2454
2567
|
break;
|
|
2455
2568
|
case "setup":
|
|
2456
|
-
await (await import("./setup-
|
|
2569
|
+
await (await import("./setup-qGoLPykX.js")).setup(rest);
|
|
2570
|
+
break;
|
|
2571
|
+
case "update":
|
|
2572
|
+
await (await import("./update-DwGbG-eM.js")).update(rest);
|
|
2573
|
+
break;
|
|
2574
|
+
case "migrate":
|
|
2575
|
+
await (await import("./migrate-ERNNgYO0.js")).migrate(rest, migrateTarget);
|
|
2457
2576
|
break;
|
|
2458
2577
|
case "login":
|
|
2459
2578
|
await login(rest);
|