@coffre/cli 0.1.2 → 0.1.4

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.
@@ -0,0 +1,726 @@
1
+ import { parseArgs } from "node:util";
2
+ import { spawn } from "node:child_process";
3
+ import { openSync } from "node:fs";
4
+ import { randomBytes } from "node:crypto";
5
+ import { emitKeypressEvents } from "node:readline";
6
+ import tty from "node:tty";
7
+ //#region src/clipboard.ts
8
+ const CLEAR_AFTER_MS = 3e4;
9
+ /** The system's clipboard tool, by platform and display server; null for none known. */
10
+ function systemTool(platform = process.platform, env = process.env) {
11
+ const windows = {
12
+ copy: ["clip.exe"],
13
+ clear: ["clip.exe"],
14
+ paste: [
15
+ "powershell.exe",
16
+ "-NoProfile",
17
+ "-Command",
18
+ "Get-Clipboard"
19
+ ]
20
+ };
21
+ if (platform === "darwin") return {
22
+ copy: ["pbcopy"],
23
+ clear: ["pbcopy"],
24
+ paste: ["pbpaste"]
25
+ };
26
+ if (platform === "win32") return windows;
27
+ if (platform !== "linux") return null;
28
+ if (env.WSL_DISTRO_NAME) return windows;
29
+ if (env.WAYLAND_DISPLAY) return {
30
+ copy: ["wl-copy"],
31
+ clear: ["wl-copy", "--clear"],
32
+ paste: ["wl-paste", "--no-newline"]
33
+ };
34
+ if (env.DISPLAY) {
35
+ const xclip = [
36
+ "xclip",
37
+ "-selection",
38
+ "clipboard"
39
+ ];
40
+ return {
41
+ copy: [...xclip],
42
+ clear: [...xclip],
43
+ paste: [...xclip, "-o"]
44
+ };
45
+ }
46
+ return null;
47
+ }
48
+ var Clipboard = class {
49
+ #tool;
50
+ #terminal;
51
+ #pending = null;
52
+ #onCleared;
53
+ /** `terminal` writes an escape sequence to the screen, for OSC 52; `onCleared` hears of a clear. */
54
+ constructor(terminal, onCleared = () => {}, tool = systemTool()) {
55
+ this.#tool = tool;
56
+ this.#terminal = terminal;
57
+ this.#onCleared = onCleared;
58
+ }
59
+ async copy(text) {
60
+ if (this.#tool !== null) try {
61
+ await feed(this.#tool.copy, text);
62
+ this.#schedule(text);
63
+ return "system";
64
+ } catch {
65
+ this.#tool = null;
66
+ }
67
+ this.#terminal(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
68
+ return "terminal";
69
+ }
70
+ /** Clear a copy still pending now, as the screen closes. */
71
+ async settle() {
72
+ const pending = this.#pending;
73
+ if (pending === null) return;
74
+ clearTimeout(pending.timer);
75
+ this.#pending = null;
76
+ await this.#clearIfHolds(pending.text);
77
+ }
78
+ #schedule(text) {
79
+ if (this.#pending !== null) clearTimeout(this.#pending.timer);
80
+ const timer = setTimeout(() => {
81
+ this.#pending = null;
82
+ this.#clearIfHolds(text).then(this.#onCleared, () => {});
83
+ }, CLEAR_AFTER_MS);
84
+ this.#pending = {
85
+ text,
86
+ timer
87
+ };
88
+ }
89
+ /** Clear the clipboard if it still holds `text`: whatever was copied since stays. */
90
+ async #clearIfHolds(text) {
91
+ const tool = this.#tool;
92
+ if (tool === null) return;
93
+ try {
94
+ if (await read(tool.paste) === text) await feed(tool.clear, "");
95
+ } catch {}
96
+ }
97
+ };
98
+ const TOOL_TIMEOUT_MS = 3e3;
99
+ /** Run `command` with `input` on its stdin; its output is not waited for, since xclip and wl-copy stay to serve it. */
100
+ function feed([command, ...args], input) {
101
+ return new Promise((resolve, reject) => {
102
+ const child = spawn(command, args, {
103
+ stdio: [
104
+ "pipe",
105
+ "ignore",
106
+ "ignore"
107
+ ],
108
+ timeout: TOOL_TIMEOUT_MS
109
+ });
110
+ child.on("error", reject);
111
+ child.on("close", (code) => code === 0 ? resolve() : reject(/* @__PURE__ */ new Error(`${command} exited with ${code}`)));
112
+ child.stdin.on("error", () => {});
113
+ if (input === "") child.stdin.end();
114
+ else child.stdin.end(input);
115
+ });
116
+ }
117
+ function read([command, ...args]) {
118
+ return new Promise((resolve, reject) => {
119
+ const child = spawn(command, args, {
120
+ stdio: [
121
+ "ignore",
122
+ "pipe",
123
+ "ignore"
124
+ ],
125
+ timeout: TOOL_TIMEOUT_MS
126
+ });
127
+ let out = "";
128
+ child.stdout.setEncoding("utf8").on("data", (chunk) => out += chunk);
129
+ child.on("error", reject);
130
+ child.on("close", (code) => code === 0 ? resolve(out.replace(/\r?\n$/, "")) : reject(/* @__PURE__ */ new Error(`${command} exited with ${code}`)));
131
+ });
132
+ }
133
+ //#endregion
134
+ //#region src/tty.ts
135
+ const plain = (text) => text;
136
+ /**
137
+ * The web UI's accent, green and red, at the depth the terminal takes:
138
+ * 24-bit, 256 colours or the 16 named ones. NO_COLOR (and FORCE_COLOR) are
139
+ * the stream's own `hasColors`; bold and dim stay, being no colour.
140
+ */
141
+ function style(stream) {
142
+ if (stream.isTTY !== true) return {
143
+ ansi: false,
144
+ bold: plain,
145
+ dim: plain,
146
+ accent: plain,
147
+ green: plain,
148
+ red: plain
149
+ };
150
+ const terminal = stream;
151
+ const depth = typeof terminal.hasColors === "function" && terminal.hasColors() ? terminal.getColorDepth() : 1;
152
+ const sgr = (open, close) => (text) => `\x1b[${open}m${text}\x1b[${close}m`;
153
+ 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;
154
+ return {
155
+ ansi: true,
156
+ bold: sgr("1", "22"),
157
+ dim: sgr("2", "22"),
158
+ accent: colour([
159
+ 91,
160
+ 141,
161
+ 239
162
+ ], 69, 94),
163
+ green: colour([
164
+ 63,
165
+ 174,
166
+ 111
167
+ ], 71, 32),
168
+ red: colour([
169
+ 229,
170
+ 83,
171
+ 75
172
+ ], 167, 31)
173
+ };
174
+ }
175
+ const ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g;
176
+ /** Columns `text` takes on screen: its characters, less its escape codes. */
177
+ function width(text) {
178
+ return [...text.replace(ESCAPE, "")].length;
179
+ }
180
+ /** `text` cut to `columns` on screen, escape codes kept, an ellipsis where it was cut. */
181
+ function truncate(text, columns) {
182
+ if (width(text) <= columns) return text;
183
+ let out = "";
184
+ let used = 0;
185
+ for (let i = 0; i < text.length;) {
186
+ ESCAPE.lastIndex = i;
187
+ const code = text[i] === "\x1B" ? ESCAPE.exec(text) : null;
188
+ if (code !== null && code.index === i) {
189
+ out += code[0];
190
+ i += code[0].length;
191
+ continue;
192
+ }
193
+ const char = String.fromCodePoint(text.codePointAt(i));
194
+ if (used === columns - 1) return `${out}…${text.includes("\x1B") ? "\x1B[0m" : ""}`;
195
+ out += char;
196
+ used += 1;
197
+ i += char.length;
198
+ }
199
+ return out;
200
+ }
201
+ /** Plain `text` in lines of at most `columns`, broken at spaces, or anywhere in a word longer than a line. */
202
+ function wrap(text, columns) {
203
+ const lines = [];
204
+ let line = "";
205
+ for (const word of text.split(" ")) {
206
+ let rest = word;
207
+ while (width(rest) > columns) {
208
+ if (line !== "") lines.push(line);
209
+ line = "";
210
+ lines.push([...rest].slice(0, columns).join(""));
211
+ rest = [...rest].slice(columns).join("");
212
+ }
213
+ if (line === "") line = rest;
214
+ else if (width(line) + 1 + width(rest) <= columns) line += ` ${rest}`;
215
+ else {
216
+ lines.push(line);
217
+ line = rest;
218
+ }
219
+ }
220
+ return [...lines, line];
221
+ }
222
+ /** `text` wrapped to the stream's width, each line indented by `indent` spaces. */
223
+ function paragraph(out, text, indent = 2) {
224
+ const pad = " ".repeat(indent);
225
+ return wrap(text, Math.max(20, (out.columns || 80) - indent - 1)).map((line) => `${pad}${line}`).join("\n");
226
+ }
227
+ /** A labelled row: `label` in a column of `column`, `text` wrapped beside it with a hanging indent. */
228
+ function row(out, s, label, text, indent = 4, column = 10) {
229
+ const lines = wrap(text, Math.max(20, (out.columns || 80) - indent - column - 1));
230
+ const pad = " ".repeat(indent);
231
+ return lines.map((line, i) => `${pad}${i === 0 ? s.dim(label.padEnd(column)) : " ".repeat(column)}${line}`).join("\n");
232
+ }
233
+ /**
234
+ * The keyboard: stdin when it is a terminal, otherwise the terminal itself,
235
+ * as `less` reads it when its input is piped. Null without one.
236
+ */
237
+ function keyboard() {
238
+ if (process.stdin.isTTY) return process.stdin;
239
+ try {
240
+ return new tty.ReadStream(openSync("/dev/tty", "r"));
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+ /** Where a screen is drawn and read from: stdout, and the keyboard. Null unless both are a terminal. */
246
+ function openTerminal() {
247
+ if (!process.stdout.isTTY) return null;
248
+ const keys = keyboard();
249
+ return keys === null ? null : {
250
+ keys,
251
+ out: process.stdout
252
+ };
253
+ }
254
+ /** Release a keyboard `keyboard()` opened: stdin stays, /dev/tty closes. */
255
+ function release(keys) {
256
+ keys.setRawMode?.(false);
257
+ keys.pause();
258
+ if (keys !== process.stdin) keys.destroy?.();
259
+ }
260
+ /** Every key pressed, in raw mode, until `until` returns true. */
261
+ function readKeys(keys, until) {
262
+ emitKeypressEvents(keys);
263
+ keys.setRawMode?.(true);
264
+ keys.resume();
265
+ return new Promise((resolve, reject) => {
266
+ let busy = Promise.resolve();
267
+ const onKey = (sequence, key) => {
268
+ busy = busy.then(async () => {
269
+ if (await until(key ?? { sequence }, sequence ?? key?.sequence ?? "")) {
270
+ keys.off("keypress", onKey);
271
+ keys.setRawMode?.(false);
272
+ keys.pause();
273
+ resolve();
274
+ }
275
+ }).catch(reject);
276
+ };
277
+ keys.on("keypress", onKey);
278
+ });
279
+ }
280
+ var Cancelled = class extends Error {
281
+ constructor() {
282
+ super("cancelled");
283
+ }
284
+ };
285
+ /**
286
+ * A line typed without echo: a bullet for each character, so that a paste
287
+ * shows it arrived. The question, its hint and the bullets go once it is
288
+ * given. Ctrl-C cancels.
289
+ */
290
+ async function hiddenLine(keys, out, s, question, hint) {
291
+ const columns = Math.max(20, (out.columns || 80) - 1);
292
+ out.write(`${truncate(` ${s.accent("?")} ${s.bold(question)}`, columns)}\n${truncate(` ${s.dim(hint)}`, columns)}\n`);
293
+ let typed = "";
294
+ const draw = () => {
295
+ const count = typed.length > 0 ? s.dim(` ${typed.length} characters`) : "";
296
+ out.write(`\r\x1b[2K${truncate(` ${s.accent("›")} ${"•".repeat(typed.length)}`, columns - width(count))}${count}`);
297
+ };
298
+ draw();
299
+ let cancelled = false;
300
+ await readKeys(keys, (key, sequence) => {
301
+ if (key.ctrl && key.name === "c") cancelled = true;
302
+ else if (key.name === "return" || key.name === "enter") return true;
303
+ else if (key.name === "backspace") typed = typed.slice(0, -1);
304
+ else if (key.ctrl && key.name === "u") typed = "";
305
+ else if (!key.ctrl && !key.meta && sequence.length > 0 && !sequence.startsWith("\x1B") && sequence >= " ") typed += sequence;
306
+ if (cancelled) return true;
307
+ draw();
308
+ return false;
309
+ });
310
+ out.write("\r\x1B[2K\x1B[1A\x1B[2K\x1B[1A\x1B[2K");
311
+ if (cancelled) throw new Cancelled();
312
+ return typed.trim();
313
+ }
314
+ /** One key: y for yes, anything else for no. Ctrl-C cancels. */
315
+ async function yes(keys) {
316
+ let answer = false;
317
+ let cancelled = false;
318
+ await readKeys(keys, (key) => {
319
+ if (key.ctrl && key.name === "c") cancelled = true;
320
+ answer = key.name === "y";
321
+ return true;
322
+ });
323
+ if (cancelled) throw new Cancelled();
324
+ return answer;
325
+ }
326
+ //#endregion
327
+ //#region src/secrets.ts
328
+ function initialState() {
329
+ return {
330
+ view: "values",
331
+ selected: {
332
+ values: 0,
333
+ guide: 0
334
+ },
335
+ scroll: {
336
+ values: 0,
337
+ guide: 0
338
+ },
339
+ revealed: /* @__PURE__ */ new Set(),
340
+ revealAll: false,
341
+ copied: /* @__PURE__ */ new Set(),
342
+ status: "",
343
+ confirming: false
344
+ };
345
+ }
346
+ /** What the arrows move between: a view's values, or its commands. */
347
+ function items(screen, view) {
348
+ if (view === "values") return screen.sections.flatMap(({ values }) => values.map(({ label, value, mask }) => ({
349
+ id: label,
350
+ text: value,
351
+ secret: mask !== "none",
352
+ name: `the ${lower(label)}`
353
+ })));
354
+ return screen.guide.flatMap(({ lines }) => lines.flatMap((line) => typeof line === "string" ? [] : [{
355
+ id: line.command,
356
+ text: line.command,
357
+ secret: false,
358
+ name: "the command"
359
+ }]));
360
+ }
361
+ const DOTS = "•".repeat(32);
362
+ const URL_PASSWORD = /(:\/\/[^:@/\s']*:)([^@\s']+)(@)/g;
363
+ function masked(value, mask) {
364
+ if (mask === "all") return DOTS;
365
+ if (mask === "password") return value.replace(URL_PASSWORD, "$1••••••••$3");
366
+ return value;
367
+ }
368
+ /** The values' count in words, for the question before leaving. */
369
+ function saved(count) {
370
+ return count === 1 ? "the value" : count === 2 ? "both values" : `all ${[
371
+ "three",
372
+ "four",
373
+ "five",
374
+ "six"
375
+ ][count - 3] ?? count} values`;
376
+ }
377
+ /**
378
+ * The screen as `rows` lines of at most `columns`: a header, the view's
379
+ * lines, scrolled to keep the selected item in sight, and the keys. Sets
380
+ * the view's scroll in `state`.
381
+ */
382
+ function render(screen, state, columns, rows, s) {
383
+ const inner = Math.max(20, columns - 6);
384
+ const rule = ` ${s.dim("─".repeat(Math.max(10, columns - 4)))}`;
385
+ const count = screen.sections.reduce((n, { values }) => n + values.length, 0);
386
+ const right = s.dim(`${count} value${count === 1 ? "" : "s"} · shown once`);
387
+ const title = ` ${s.bold(screen.title)}`;
388
+ const header = [
389
+ "",
390
+ `${title}${" ".repeat(Math.max(1, columns - width(title) - width(right) - 2))}${right}`,
391
+ rule,
392
+ ...state.view === "values" ? wrap("Save each one in your password manager now. They aren't stored anywhere, and won't be shown again.", columns - 4).map((line) => ` ${line}`) : [` ${s.bold("Where these go")}`],
393
+ ""
394
+ ];
395
+ const body = [];
396
+ const spans = [];
397
+ const list = items(screen, state.view);
398
+ const selected = Math.min(state.selected[state.view], list.length - 1);
399
+ const shown = (item) => state.revealAll || state.revealed.has(item.id);
400
+ const bar = (on) => on ? ` ${s.accent("▎")} ` : " ";
401
+ if (state.view === "values") {
402
+ let index = 0;
403
+ for (const section of screen.sections) {
404
+ body.push(` ${s.dim(s.bold(section.title.toUpperCase()))}`);
405
+ for (const value of section.values) {
406
+ const on = index === selected;
407
+ const item = list[index];
408
+ const start = body.length;
409
+ const copied = state.copied.has(item.id) ? ` ${s.green("✓ copied")}` : "";
410
+ body.push(`${bar(on)}${on ? s.accent(s.bold(value.label)) : s.bold(value.label)}${copied}`);
411
+ if (shown(item) || value.mask === "none") for (const line of wrap(value.value, inner)) body.push(`${bar(on)}${line}`);
412
+ else body.push(`${bar(on)}${truncate(dimDots(masked(value.value, value.mask), s), inner)}`);
413
+ for (const line of wrap(value.about, inner)) body.push(`${bar(on)}${s.dim(line)}`);
414
+ spans.push([start, body.length]);
415
+ body.push("");
416
+ index += 1;
417
+ }
418
+ }
419
+ } else {
420
+ let index = 0;
421
+ for (const block of screen.guide) {
422
+ body.push(` ${s.dim(s.bold(block.title.toUpperCase()))}`);
423
+ let after = false;
424
+ for (const line of block.lines) {
425
+ if (typeof line === "string") {
426
+ if (after) body.push("");
427
+ for (const part of wrap(line, inner)) body.push(` ${part}`);
428
+ after = false;
429
+ continue;
430
+ }
431
+ after = true;
432
+ const on = index === selected;
433
+ const item = list[index];
434
+ const start = body.length;
435
+ const text = line.command;
436
+ const copied = state.copied.has(item.id) ? ` ${s.green("✓")}` : "";
437
+ wrap(text, inner - 2).forEach((part, i) => {
438
+ const prompt = i === 0 ? s.dim("$ ") : " ";
439
+ body.push(`${bar(on)}${prompt}${on ? s.accent(part) : part}${i === 0 ? copied : ""}`);
440
+ });
441
+ spans.push([start, body.length]);
442
+ index += 1;
443
+ }
444
+ body.push("");
445
+ }
446
+ }
447
+ const keys = (pairs) => {
448
+ const lines = [];
449
+ let line = "";
450
+ for (const [key, what] of pairs) {
451
+ const hint = `${s.accent(key)} ${s.dim(what)}`;
452
+ if (line !== "" && width(line) + 3 + width(hint) > columns - 4) {
453
+ lines.push(` ${line}`);
454
+ line = hint;
455
+ } else line = line === "" ? hint : `${line} ${hint}`;
456
+ }
457
+ return [...lines, ` ${line}`];
458
+ };
459
+ const footer = state.confirming ? [
460
+ rule,
461
+ ...wrap(`Have you saved ${saved(count)}? They won't be shown again. (y/N)`, columns - 6).map((line, i) => ` ${i === 0 ? s.accent("?") : " "} ${s.bold(line)}`),
462
+ ...keys([["y", "yes, leave"], ["n", "stay"]])
463
+ ] : [
464
+ rule,
465
+ ...keys([
466
+ ["↑↓", "move"],
467
+ ["c", "copy"],
468
+ ...state.view === "values" ? [["r", "reveal"], ["R", "reveal all"]] : [],
469
+ ["w", state.view === "values" ? "where these go" : "back to the values"],
470
+ ["q", "done"]
471
+ ]),
472
+ state.status === "" ? "" : ` ${state.status}`
473
+ ];
474
+ while (body.at(-1) === "") body.pop();
475
+ const height = Math.max(1, rows - header.length - footer.length - 1);
476
+ const [start, end] = spans[selected] ?? [0, 0];
477
+ let scroll = state.scroll[state.view];
478
+ if (start < scroll) scroll = start;
479
+ if (end > scroll + height) scroll = end - height;
480
+ scroll = Math.max(0, Math.min(scroll, Math.max(0, body.length - height)));
481
+ state.scroll[state.view] = scroll;
482
+ const visible = body.slice(scroll, scroll + height);
483
+ const more = [scroll > 0 ? "↑" : "", scroll + height < body.length ? "↓" : ""].join("");
484
+ const filler = Array.from({ length: height - visible.length }, () => "");
485
+ const scrolled = more === "" ? "" : ` ${s.dim(`${more} more`)}`;
486
+ return [
487
+ ...header,
488
+ ...visible,
489
+ ...filler,
490
+ scrolled,
491
+ ...footer
492
+ ].slice(0, rows).map((line) => truncate(line, columns));
493
+ }
494
+ /**
495
+ * Show `screen` on the alternate screen until the operator leaves it,
496
+ * having said the values are saved. Nothing it shows reaches the main
497
+ * screen, on any way out: q, Ctrl-C, a signal or an error.
498
+ */
499
+ async function showSecrets(terminal, screen, clipboard) {
500
+ const { keys, out } = terminal;
501
+ const s = style(out);
502
+ const state = initialState();
503
+ const draw = () => {
504
+ const lines = render(screen, state, out.columns || 80, out.rows || 24, s);
505
+ out.write(`\x1b[H${lines.map((line, i) => `\x1b[${i + 1};1H${line}\x1b[K`).join("")}\x1b[J`);
506
+ };
507
+ const board = clipboard ?? new Clipboard((sequence) => out.write(sequence), () => {
508
+ if (state.status.startsWith("Copied")) {
509
+ state.status = s.dim("Cleared the clipboard.");
510
+ draw();
511
+ }
512
+ });
513
+ let shown = true;
514
+ const leave = () => {
515
+ if (!shown) return;
516
+ shown = false;
517
+ out.write("\x1B[?25h\x1B[?1049l");
518
+ };
519
+ const signals = [
520
+ "SIGINT",
521
+ "SIGTERM",
522
+ "SIGHUP"
523
+ ].map((signal) => {
524
+ const handler = () => {
525
+ leave();
526
+ keys.setRawMode?.(false);
527
+ process.exit(128 + {
528
+ SIGINT: 2,
529
+ SIGTERM: 15,
530
+ SIGHUP: 1
531
+ }[signal]);
532
+ };
533
+ process.on(signal, handler);
534
+ return () => process.off(signal, handler);
535
+ });
536
+ process.on("exit", leave);
537
+ const resize = () => draw();
538
+ out.on("resize", resize);
539
+ out.write("\x1B[?1049h\x1B[?25l\x1B[2J");
540
+ try {
541
+ draw();
542
+ await readKeys(keys, async (key) => {
543
+ const done = await press(key, screen, state, board, s);
544
+ if (!done) draw();
545
+ return done;
546
+ });
547
+ } finally {
548
+ await board.settle();
549
+ leave();
550
+ out.off("resize", resize);
551
+ process.off("exit", leave);
552
+ for (const off of signals) off();
553
+ }
554
+ }
555
+ /** Act on one key; true to leave. */
556
+ async function press(key, screen, state, clipboard, s) {
557
+ const quit = key.name === "q" || key.name === "escape" || key.ctrl === true && key.name === "c";
558
+ if (state.confirming) {
559
+ if (key.name === "y" || key.ctrl === true && key.name === "c") return true;
560
+ state.confirming = false;
561
+ return false;
562
+ }
563
+ if (quit) {
564
+ state.confirming = true;
565
+ return false;
566
+ }
567
+ const list = items(screen, state.view);
568
+ const at = state.selected[state.view];
569
+ const item = list[at];
570
+ if (key.name === "up" || key.name === "k" || key.name === "tab" && key.shift === true) state.selected[state.view] = (at - 1 + list.length) % list.length;
571
+ else if (key.name === "down" || key.name === "j" || key.name === "tab") state.selected[state.view] = (at + 1) % list.length;
572
+ else if (key.name === "w") {
573
+ state.view = state.view === "values" ? "guide" : "values";
574
+ state.status = "";
575
+ } else if (key.name === "r" && key.shift === true) {
576
+ state.revealAll = !state.revealAll;
577
+ if (!state.revealAll) state.revealed.clear();
578
+ } else if (key.name === "r" && item !== void 0) {
579
+ if (state.revealed.has(item.id)) state.revealed.delete(item.id);
580
+ else state.revealed.add(item.id);
581
+ } else if ((key.name === "c" || key.name === "return" || key.name === "enter") && item !== void 0) try {
582
+ const where = await clipboard.copy(item.text);
583
+ state.copied.add(item.id);
584
+ state.status = where === "system" ? `Copied ${item.name}. ${s.dim(`The clipboard clears in ${CLEAR_AFTER_MS / 1e3} s, or when you leave.`)}` : `Copied ${item.name}, through the terminal. ${s.dim("Clear the clipboard once it is pasted.")}`;
585
+ } catch (error) {
586
+ state.status = s.red(`Couldn't copy ${item.name}: ${error instanceof Error ? error.message : String(error)}. Reveal it with r.`);
587
+ }
588
+ return false;
589
+ }
590
+ /** Masking dots, dimmed: what is hidden recedes. */
591
+ function dimDots(text, s) {
592
+ return text.replace(/•+/g, (dots) => s.dim(dots));
593
+ }
594
+ function lower(label) {
595
+ return label.replace(/^[A-Z](?![A-Z])/, (first) => first.toLowerCase());
596
+ }
597
+ //#endregion
598
+ //#region src/keys.ts
599
+ const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
600
+ /**
601
+ * A vault ID: the day, so that ids read in order, and six random base32
602
+ * characters, so that two made the same day differ. The vault refuses two
603
+ * keys under one id.
604
+ */
605
+ function vaultKeyId(now = /* @__PURE__ */ new Date()) {
606
+ const suffix = [...randomBytes(6)].map((byte) => BASE32[byte % 32]).join("");
607
+ return `vault-${now.toISOString().slice(0, 10)}-${suffix}`;
608
+ }
609
+ /** Two fresh keys, each 32 random bytes in base64, and the vault key's id. */
610
+ function generateKeys(now = /* @__PURE__ */ new Date()) {
611
+ const key = () => randomBytes(32).toString("base64");
612
+ return {
613
+ APP_KEY: key(),
614
+ VAULT_KEY_ID: vaultKeyId(now),
615
+ VAULT_KEY: key()
616
+ };
617
+ }
618
+ /** The keys as values to save, each with what it is for and where it goes. */
619
+ function keyValues(keys) {
620
+ return {
621
+ app: [{
622
+ label: "App key",
623
+ value: keys.APP_KEY,
624
+ mask: "all",
625
+ about: "Signs the app's sessions, tokens and log entries. Goes in the app, as APP_KEY."
626
+ }],
627
+ vault: [{
628
+ label: "Vault ID",
629
+ value: keys.VAULT_KEY_ID,
630
+ mask: "none",
631
+ about: "Names the vault key, and isn't secret. Goes in the vault, as VAULT_KEY_ID."
632
+ }, {
633
+ label: "Vault key",
634
+ value: keys.VAULT_KEY,
635
+ mask: "all",
636
+ about: "Decrypts every value: lose it, and they're lost for good. Goes in the vault, as VAULT_KEY."
637
+ }]
638
+ };
639
+ }
640
+ /** Where the keys go: the vault ID as a var and the keys as secrets on Workers, the env files on Node. */
641
+ function keyGuide() {
642
+ return {
643
+ workers: [
644
+ "The vault ID goes under vars in vault/wrangler.jsonc, as VAULT_KEY_ID. The keys are secrets; each command asks for its value:",
645
+ { command: "pnpm exec wrangler secret put APP_KEY -c app/wrangler.jsonc" },
646
+ { command: "pnpm exec wrangler secret put VAULT_KEY -c vault/wrangler.jsonc" }
647
+ ],
648
+ node: ["server.env takes APP_KEY; vault.env takes VAULT_KEY_ID and VAULT_KEY."]
649
+ };
650
+ }
651
+ function keysScreen(keys) {
652
+ const { app, vault } = keyValues(keys);
653
+ const guide = keyGuide();
654
+ return {
655
+ title: "coffre keys",
656
+ sections: [{
657
+ title: "App",
658
+ values: app
659
+ }, {
660
+ title: "Vault",
661
+ values: vault
662
+ }],
663
+ guide: [
664
+ {
665
+ title: "On Cloudflare Workers",
666
+ lines: guide.workers
667
+ },
668
+ {
669
+ title: "On Node",
670
+ lines: guide.node
671
+ },
672
+ {
673
+ title: "For a rotation",
674
+ lines: ["Take only the vault ID and key. The vault key they replace moves to previousKeks, and stays there: what it wrapped still needs it. The app key cannot change. docs/keys.md has the steps."]
675
+ }
676
+ ]
677
+ };
678
+ }
679
+ /** Why a command that shows secrets will not print them to a pipe or a file. */
680
+ function needsTerminal(command) {
681
+ return `${command} shows the values it makes on a screen of their own, so that they never reach a file, a pipe or the scrollback, and that needs a terminal. In a script, --json prints them to stdout instead.`;
682
+ }
683
+ /** The stderr warning before --json prints secrets. */
684
+ function jsonWarning(out, secrets) {
685
+ const s = style(out);
686
+ out.write(`${s.red("!")} ${s.bold(`--json prints ${secrets} to stdout.`)} ${s.dim("Keep it out of logs, and out of files others can read.")}\n`);
687
+ }
688
+ async function keys(args) {
689
+ const { values } = parseArgs({
690
+ args,
691
+ options: { json: {
692
+ type: "boolean",
693
+ default: false
694
+ } },
695
+ allowPositionals: false
696
+ });
697
+ if (values.json) {
698
+ jsonWarning(process.stderr, "the app key and the vault key");
699
+ process.stdout.write(`${JSON.stringify(generateKeys())}\n`);
700
+ return;
701
+ }
702
+ const terminal = openTerminal();
703
+ if (terminal === null) {
704
+ process.stderr.write(`coffre keys: ${needsTerminal("coffre keys")}\n`);
705
+ process.exit(1);
706
+ }
707
+ const fresh = generateKeys();
708
+ try {
709
+ await showSecrets(terminal, keysScreen(fresh));
710
+ } finally {
711
+ release(terminal.keys);
712
+ }
713
+ const { out } = terminal;
714
+ const s = style(out);
715
+ out.write([
716
+ "",
717
+ ` ${s.green("✓")} ${s.bold("The app key and the vault key were shown once.")}`,
718
+ s.dim(paragraph(out, "They aren't stored anywhere: a lost key can't be recovered.", 4)),
719
+ "",
720
+ row(out, s, "Vault ID", `${fresh.VAULT_KEY_ID}, not a secret`),
721
+ "",
722
+ ""
723
+ ].join("\n"));
724
+ }
725
+ //#endregion
726
+ export { yes as _, keys as a, Cancelled as c, paragraph as d, release as f, wrap as g, truncate as h, keyValues as i, hiddenLine as l, style as m, jsonWarning as n, needsTerminal as o, row as p, keyGuide as r, showSecrets as s, generateKeys as t, openTerminal as u };