@coffre/cli 0.1.2 → 0.1.3
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/keys-RhB7ayOY.js +726 -0
- package/dist/main.js +6 -6
- package/dist/setup-3OJc_VCA.js +756 -0
- package/dist/templates/node/README.md +20 -17
- package/dist/templates/node/package.json +3 -3
- package/dist/templates/node/server.env.example +3 -3
- package/dist/templates/node/src/server.ts +2 -2
- package/dist/templates/node/src/vault.ts +7 -7
- package/dist/templates/node/vault.env.example +6 -5
- package/dist/templates/workers/README.md +28 -23
- package/dist/templates/workers/app/src/worker.ts +2 -2
- package/dist/templates/workers/app/wrangler.jsonc +4 -4
- package/dist/templates/workers/package.json +4 -4
- package/dist/templates/workers/vault/src/worker.ts +8 -8
- package/dist/templates/workers/vault/wrangler.jsonc +8 -7
- package/package.json +4 -4
- package/dist/keys-D9CG_wYV.js +0 -64
- package/dist/setup-DPHVyr2N.js +0 -521
|
@@ -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 };
|