1/**
2 * Where the command line finds an upload key, and how it saves one.
3 *
4 * Three places, in order: the --key option, the environment, and a file the
5 * `login` subcommand writes. The environment is what the page's copyable
6 * command uses, since a key on the command line is visible to every user on
7 * the machine through `ps` while another process's environment is not.
8 */
9import { homedir } from 'node:os';
10import { join } from 'node:path';
11import { mkdir, readFile, writeFile } from 'node:fs/promises';
13export const KEY_ENV = 'TURING_SURFACE_CACHE_KEY';
15const configDir = (): string =>
16 join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'turing-surface-cache');
18export const keyPath = (): string => join(configDir(), 'key');
20/** The saved key, or '' if there is none. */
21export async function savedKey(): Promise<string> {
22 try {
23 return (await readFile(keyPath(), 'utf8')).trim();
24 } catch {
25 return '';
26 }
27}
29/** Save a key for later runs, readable only by this user. */
30export async function saveKey(key: string): Promise<string> {
31 await mkdir(configDir(), { recursive: true, mode: 0o700 });
32 const path = keyPath();
33 await writeFile(path, `${key}\n`, { mode: 0o600 });
34 return path;
35}
37/** --key, else the environment, else the saved key. */
38export async function resolveKey(fromOption: string | undefined): Promise<string> {
39 return (fromOption || process.env[KEY_ENV] || (await savedKey()) || '').trim();
40}
42/** Enough of a key to recognize it by, and no more. */
43export const maskKey = (key: string): string =>
44 key.length <= 4 ? '·'.repeat(key.length) : `····${key.slice(-4)}`;
46/**
47 * Read a secret from the terminal without echoing it. A piped stdin is read as
48 * a plain line, so `echo $KEY | … login` works too.
49 */
50export async function promptSecret(prompt: string): Promise<string> {
51 const stdin = process.stdin;
52 process.stdout.write(prompt);
53 if (!stdin.isTTY) {
54 const chunks: Buffer[] = [];
55 for await (const chunk of stdin) chunks.push(chunk as Buffer);
56 process.stdout.write('\n');
57 return Buffer.concat(chunks).toString('utf8').split('\n')[0].trim();
58 }
59 return new Promise<string>((resolve, reject) => {
60 let typed = '';
61 stdin.setRawMode(true);
62 stdin.resume();
63 stdin.setEncoding('utf8');
64 const done = (finish: () => void): void => {
65 stdin.setRawMode(false);
66 stdin.pause();
67 stdin.removeListener('data', onData);
68 process.stdout.write('\n');
69 finish();
70 };
71 const onData = (chunk: string): void => {
72 for (const ch of chunk) {
73 if (ch === '\r' || ch === '\n') return done(() => resolve(typed.trim()));
74 if (ch === '\u0003') return done(() => reject(new Error('cancelled')));
75 if (ch === '\u007f' || ch === '\b') typed = typed.slice(0, -1);
76 else if (ch >= ' ') typed += ch;
77 }
78 };
79 stdin.on('data', onData);
80 });
81}