@mnemahq/cli 0.3.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -1
- package/package.json +8 -4
- package/src/browser.mjs +93 -0
- package/src/cli.mjs +207 -55
- package/src/login.mjs +82 -46
- package/src/paging.mjs +50 -0
- package/src/read-commands.mjs +138 -58
- package/src/render/layout.mjs +117 -0
- package/src/render/pick.mjs +131 -0
- package/src/render/theme.mjs +255 -0
- package/src/render/wait.mjs +96 -0
- package/src/tui/app.mjs +89 -0
- package/src/tui/components/list.mjs +57 -0
- package/src/tui/h.mjs +23 -0
- package/src/tui/launch.mjs +61 -0
- package/src/tui/screens/briefing.mjs +74 -0
- package/src/tui/screens/finding.mjs +53 -0
- package/src/tui/store.mjs +65 -0
- package/src/tui-gate.mjs +79 -0
- package/src/util.mjs +78 -19
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lazy boundary (t-632).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THIS MODULE IS ONLY EVER REACHED THROUGH `await import()`. That is the whole
|
|
5
|
+
* design: one seam in cli.mjs, so that inside src/tui/** the imports can be
|
|
6
|
+
* ordinary static ones rather than dozens of dynamic imports scattered through
|
|
7
|
+
* components. Nothing outside src/tui/ may import anything in it statically.
|
|
8
|
+
*
|
|
9
|
+
* Measured, on this machine:
|
|
10
|
+
*
|
|
11
|
+
* import('ink') 136–141 ms
|
|
12
|
+
* mnema --version 59–65 ms
|
|
13
|
+
*
|
|
14
|
+
* So a static import would roughly triple the cost of every one-shot command —
|
|
15
|
+
* and `mnema` runs from capture hooks and shell prompts, not just by hand.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ A TUI THAT CRASHES WITHOUT RESTORING THE TERMINAL IS WORSE THAN NO TUI. Ink
|
|
18
|
+
* takes the alternate screen and hides the cursor; if the process dies without
|
|
19
|
+
* unmounting, the user gets back a shell with an invisible cursor and no echo. So
|
|
20
|
+
* unmount runs from an uncaughtException handler as well as from the normal exit,
|
|
21
|
+
* and the cursor is shown again explicitly rather than trusting the library to do
|
|
22
|
+
* it on the way out of an abnormal exit.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { render } from 'ink';
|
|
26
|
+
import { html } from './h.mjs';
|
|
27
|
+
import { App } from './app.mjs';
|
|
28
|
+
|
|
29
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
30
|
+
|
|
31
|
+
export async function startTui(ctx) {
|
|
32
|
+
const instance = render(html`<${App} ctx=${ctx} />`, {
|
|
33
|
+
// Ink's own exitOnCtrlC would leave our own key handling out of the loop.
|
|
34
|
+
exitOnCtrlC: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const restore = () => {
|
|
38
|
+
try { instance.unmount(); } catch { /* already gone */ }
|
|
39
|
+
try { process.stdout.write(SHOW_CURSOR); } catch { /* stream closed */ }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const onFatal = (err) => {
|
|
43
|
+
restore();
|
|
44
|
+
// Print AFTER restoring, or the message lands on the alternate screen and
|
|
45
|
+
// vanishes with it — the user sees a crash with no text.
|
|
46
|
+
process.stderr.write(`\nmnema: ${err?.stack ?? err}\n`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
process.on('uncaughtException', onFatal);
|
|
51
|
+
process.on('unhandledRejection', onFatal);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await instance.waitUntilExit();
|
|
55
|
+
} finally {
|
|
56
|
+
process.off('uncaughtException', onFatal);
|
|
57
|
+
process.off('unhandledRejection', onFatal);
|
|
58
|
+
try { process.stdout.write(SHOW_CURSOR); } catch { /* stream closed */ }
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The home screen: what deserves attention (t-632).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THE COVERAGE CAVEATS RENDER ABOVE THE LIST, and that is not a layout
|
|
5
|
+
* preference. read-commands.mjs:198 prints them first on purpose: a short findings
|
|
6
|
+
* list has three quite different causes and only one of them is good news. On a
|
|
7
|
+
* core build six of the seven finding families cannot produce anything at all, and
|
|
8
|
+
* `coverage.notice` is the only thing that says so. Put the banner under the list,
|
|
9
|
+
* where it is off-screen on a short terminal, and the reader concludes "all clear"
|
|
10
|
+
* from a list that is short because the engine never ran.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Box, Text, useInput } from 'ink';
|
|
14
|
+
import { html } from '../h.mjs';
|
|
15
|
+
import { useResource, ago } from '../store.mjs';
|
|
16
|
+
import { List } from '../components/list.mjs';
|
|
17
|
+
|
|
18
|
+
export function Briefing({ ctx, onOpen, focused }) {
|
|
19
|
+
const { status, data, error, elapsed, at, reload } = useResource('briefing', () =>
|
|
20
|
+
ctx.call((m) => m.findings.briefing()));
|
|
21
|
+
|
|
22
|
+
// Refresh is explicit. See store.mjs: a list that reorders under a moving cursor
|
|
23
|
+
// means pressing enter on row three opens what is now row four.
|
|
24
|
+
useInput((input) => { if (input === 'r') reload(); }, { isActive: Boolean(focused) });
|
|
25
|
+
|
|
26
|
+
if (status === 'loading') {
|
|
27
|
+
return html`<${Box} paddingX=${1}>
|
|
28
|
+
<${Text} dimColor>Loading the briefing… ${elapsed > 1 ? `${elapsed}s` : ''}<//>
|
|
29
|
+
<//>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (error && !data) {
|
|
33
|
+
return html`<${Box} flexDirection="column" paddingX=${1}>
|
|
34
|
+
<${Text} color="red">${error.message ?? String(error)}<//>
|
|
35
|
+
<${Text} dimColor>r to retry · q to quit<//>
|
|
36
|
+
<//>`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const b = data ?? {};
|
|
40
|
+
const p = b.pulse ?? {};
|
|
41
|
+
const findings = b.findings ?? [];
|
|
42
|
+
|
|
43
|
+
return html`
|
|
44
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
45
|
+
<${Box} gap=${3}>
|
|
46
|
+
<${Text}><${Text} dimColor>projects </>${p.activeProjects ?? 0}<//>
|
|
47
|
+
<${Text}><${Text} dimColor>problems </><${Text} color=${(p.problemsInActiveProjects ?? 0) > 0 ? 'yellow' : undefined}>${p.problemsInActiveProjects ?? 0}<//><//>
|
|
48
|
+
<${Text}><${Text} dimColor>spend 7d </>$${p.cost7dUsd ?? 0}<//>
|
|
49
|
+
<${Text} dimColor>${ago(at)}${status === 'refreshing' ? ' · refreshing' : ''}<//>
|
|
50
|
+
<//>
|
|
51
|
+
|
|
52
|
+
${b.neverComputed ? html`<${Box} marginTop=${1}><${Text} color="yellow">! The findings engine has never run for this workspace.<//><//>` : null}
|
|
53
|
+
${b.coverage?.degraded ? html`<${Box} marginTop=${1}><${Text} color="yellow">! ${b.coverage.notice}<//><//>` : null}
|
|
54
|
+
|
|
55
|
+
<${Box} marginTop=${1}>
|
|
56
|
+
<${Text} bold>Findings<//>
|
|
57
|
+
<${Text} dimColor> ${findings.length}<//>
|
|
58
|
+
<//>
|
|
59
|
+
|
|
60
|
+
${findings.length === 0
|
|
61
|
+
? html`<${Text} dimColor> Nothing surfaced.<//>`
|
|
62
|
+
: html`<${List}
|
|
63
|
+
items=${findings.map((f) => ({
|
|
64
|
+
key: f.id ?? f.headline,
|
|
65
|
+
left: String(f.kind ?? ''),
|
|
66
|
+
label: `${f.headline ?? ''}${f.grouped ? ` ×${f.count}` : ''}`,
|
|
67
|
+
}))}
|
|
68
|
+
focused=${focused}
|
|
69
|
+
onSelect=${(i) => onOpen(findings[i])}
|
|
70
|
+
/>`}
|
|
71
|
+
|
|
72
|
+
${error ? html`<${Box} marginTop=${1}><${Text} color="yellow">refresh failed — ${error.message ?? String(error)}<//><//>` : null}
|
|
73
|
+
<//>`;
|
|
74
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One finding, in full (t-632).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THE HEADLINE IS A SUMMARY, AND A SUMMARY OF A FINDING IS EXACTLY WHERE A
|
|
5
|
+
* NUMBER LOSES ITS CAVEATS. "0 of 1238 decisions turned into work" reads as an
|
|
6
|
+
* indictment until you know the window is 90 days and the engine only sees
|
|
7
|
+
* decisions that were recorded. So the detail view shows what the engine actually
|
|
8
|
+
* had — subject, evidence, window — rather than restating the headline larger.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Box, Text } from 'ink';
|
|
12
|
+
import { html } from '../h.mjs';
|
|
13
|
+
|
|
14
|
+
const Row = ({ label, children }) => html`
|
|
15
|
+
<${Box}>
|
|
16
|
+
<${Text} dimColor>${String(label).padEnd(10)} <//>
|
|
17
|
+
<${Text}>${children}<//>
|
|
18
|
+
<//>`;
|
|
19
|
+
|
|
20
|
+
export function Finding({ finding }) {
|
|
21
|
+
if (!finding) {
|
|
22
|
+
return html`<${Box} paddingX=${1}><${Text} dimColor>Nothing selected.<//><//>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const evidence = finding.evidence ?? finding.subjects ?? [];
|
|
26
|
+
|
|
27
|
+
return html`
|
|
28
|
+
<${Box} flexDirection="column" paddingX=${1}>
|
|
29
|
+
<${Text} bold>${finding.headline ?? '(no headline)'}<//>
|
|
30
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
31
|
+
${html`<${Row} label="kind">${finding.kind ?? '—'}<//>`}
|
|
32
|
+
${finding.grouped ? html`<${Row} label="grouped">${finding.count} occurrences<//>` : null}
|
|
33
|
+
${finding.severity ? html`<${Row} label="severity">${finding.severity}<//>` : null}
|
|
34
|
+
${finding.window ? html`<${Row} label="window">${finding.window}<//>` : null}
|
|
35
|
+
<//>
|
|
36
|
+
|
|
37
|
+
${finding.detail
|
|
38
|
+
? html`<${Box} marginTop=${1}><${Text} dimColor>${finding.detail}<//><//>`
|
|
39
|
+
: null}
|
|
40
|
+
|
|
41
|
+
${Array.isArray(evidence) && evidence.length
|
|
42
|
+
? html`
|
|
43
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
44
|
+
<${Text} bold>Evidence<//>
|
|
45
|
+
${evidence.slice(0, 8).map((e, i) => html`
|
|
46
|
+
<${Text} key=${i} dimColor> ${typeof e === 'string' ? e : (e.label ?? e.title ?? JSON.stringify(e))}<//>`)}
|
|
47
|
+
${evidence.length > 8
|
|
48
|
+
? html`<${Text} dimColor> … ${evidence.length - 8} more — mnema briefing --json<//>`
|
|
49
|
+
: null}
|
|
50
|
+
<//>`
|
|
51
|
+
: html`<${Box} marginTop=${1}><${Text} dimColor>No evidence rows came back with this finding.<//><//>`}
|
|
52
|
+
<//>`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetching, for the TUI (t-632).
|
|
3
|
+
*
|
|
4
|
+
* Hand-rolled rather than a data library: the requirements are small, and a
|
|
5
|
+
* dependency here would land on the lazy side of the boundary but still be
|
|
6
|
+
* installed for every user of the package.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ THE ELAPSED CLOCK ONLY TICKS WHILE LOADING. An interval that runs whenever
|
|
9
|
+
* the component is mounted re-renders the whole tree ten times a second forever,
|
|
10
|
+
* which on a terminal means a visible flicker and a pegged CPU on an idle screen.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ AND IT DOES NOT POLL. A list that reorders itself under a moving cursor is
|
|
13
|
+
* hostile — you press enter on what was row three and open what is now row four.
|
|
14
|
+
* Load on mount, show how stale it is, refresh on `r`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
18
|
+
|
|
19
|
+
export function useResource(key, fetcher) {
|
|
20
|
+
const [state, setState] = useState({ status: 'loading', data: null, error: null, at: null });
|
|
21
|
+
const [elapsed, setElapsed] = useState(0);
|
|
22
|
+
const [nonce, setNonce] = useState(0);
|
|
23
|
+
const alive = useRef(true);
|
|
24
|
+
|
|
25
|
+
useEffect(() => () => { alive.current = false; }, []);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
setState((s) => ({ ...s, status: s.data ? 'refreshing' : 'loading', error: null }));
|
|
30
|
+
setElapsed(0);
|
|
31
|
+
Promise.resolve()
|
|
32
|
+
.then(fetcher)
|
|
33
|
+
.then((data) => {
|
|
34
|
+
if (cancelled || !alive.current) return;
|
|
35
|
+
setState({ status: 'ready', data, error: null, at: Date.now() });
|
|
36
|
+
})
|
|
37
|
+
.catch((error) => {
|
|
38
|
+
if (cancelled || !alive.current) return;
|
|
39
|
+
// ⚠️ KEEP THE OLD DATA. A failed refresh should not blank a screen the
|
|
40
|
+
// user is reading; it should say the refresh failed.
|
|
41
|
+
setState((s) => ({ status: 'error', data: s.data, error, at: s.at }));
|
|
42
|
+
});
|
|
43
|
+
return () => { cancelled = true; };
|
|
44
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
|
+
}, [key, nonce]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (state.status !== 'loading' && state.status !== 'refreshing') return undefined;
|
|
49
|
+
const t = setInterval(() => setElapsed((n) => n + 1), 1000);
|
|
50
|
+
return () => clearInterval(t);
|
|
51
|
+
}, [state.status]);
|
|
52
|
+
|
|
53
|
+
const reload = useCallback(() => setNonce((n) => n + 1), []);
|
|
54
|
+
return { ...state, elapsed, reload };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** "3m ago" — so a screen that is not polling still says how old it is. */
|
|
58
|
+
export function ago(at) {
|
|
59
|
+
if (!at) return '';
|
|
60
|
+
const s = Math.floor((Date.now() - at) / 1000);
|
|
61
|
+
if (s < 5) return 'just now';
|
|
62
|
+
if (s < 60) return `${s}s ago`;
|
|
63
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
64
|
+
return `${Math.floor(s / 3600)}h ago`;
|
|
65
|
+
}
|
package/src/tui-gate.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether to open the interactive UI, and why not when not (t-632).
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ THIS FILE MUST NEVER IMPORT INK. It is loaded on every single invocation of
|
|
5
|
+
* `mnema`, including `mnema --version` from a shell prompt and the capture hook.
|
|
6
|
+
* `import('ink')` costs ~140ms measured, against a ~60ms `mnema --version`, so a
|
|
7
|
+
* static import here would make every one-shot several times slower for a feature
|
|
8
|
+
* almost no invocation uses. The decision has to be cheap; only the answer "yes"
|
|
9
|
+
* is allowed to be expensive.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ AND `node --check` WILL NOT CATCH A MISTAKE HERE. It parses without resolving,
|
|
12
|
+
* so `import { Box } from 'ink'` at the top of this file is syntactically fine and
|
|
13
|
+
* nothing goes red until a user's `mnema status` crashes or crawls. That is what
|
|
14
|
+
* test/no-ink-in-oneshot.test.mjs is for.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** ink 6 requires Node 20 — the same floor @mnemahq/sdk already declares. */
|
|
18
|
+
export const MIN_NODE = 20;
|
|
19
|
+
|
|
20
|
+
const nodeMajor = () => Number(process.versions.node.split('.')[0]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @returns {{ok: boolean, reason?: string, detail?: string}}
|
|
24
|
+
*/
|
|
25
|
+
export function tuiEligibility(flags = {}, env = process.env, out = process.stdout, inp = process.stdin) {
|
|
26
|
+
// An explicit no wins over everything, including MNEMA_TUI=always.
|
|
27
|
+
if (flags['no-tui'] || env.MNEMA_TUI === 'never' || env.MNEMA_NO_TUI) {
|
|
28
|
+
return { ok: false, reason: 'opted-out' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ⚠️ THE NODE CHECK IS NOT OVERRIDABLE. MNEMA_TUI=always exists so tests can
|
|
32
|
+
// exercise the positive path without a pty; it must not be able to force a
|
|
33
|
+
// runtime that cannot load the library into trying.
|
|
34
|
+
if (nodeMajor() < MIN_NODE) {
|
|
35
|
+
return { ok: false, reason: 'node', detail: `v${process.versions.node}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ⚠️ RAW MODE IS A HARD REQUIREMENT OF INK, so this is not overridable either.
|
|
39
|
+
// Found by running `MNEMA_TUI=always mnema` through a pipe: Ink starts, cannot
|
|
40
|
+
// put stdin in raw mode, and renders its own internal fallback — which emits a
|
|
41
|
+
// React "two children with the same key" warning straight into the user's output
|
|
42
|
+
// and leaves a half-drawn screen that responds to nothing. I spent a while
|
|
43
|
+
// hunting that key in MY components before proving it came from Ink's internals:
|
|
44
|
+
// with a raw-mode-capable stdin the same tree renders clean.
|
|
45
|
+
//
|
|
46
|
+
// A UI that cannot receive a keypress is not a UI, so refuse rather than start.
|
|
47
|
+
if (typeof inp.setRawMode !== 'function') {
|
|
48
|
+
return { ok: false, reason: 'no-raw-mode' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (env.MNEMA_TUI === 'always') return { ok: true };
|
|
52
|
+
|
|
53
|
+
if (!out.isTTY || !inp.isTTY) return { ok: false, reason: 'not-a-tty' };
|
|
54
|
+
if (env.TERM === 'dumb') return { ok: false, reason: 'dumb-terminal' };
|
|
55
|
+
// Ink degrades to line-by-line under CI, but we would rather not start at all.
|
|
56
|
+
if (env.CI) return { ok: false, reason: 'ci' };
|
|
57
|
+
|
|
58
|
+
return { ok: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What to tell the user, and on which stream.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ `not-a-tty` SAYS NOTHING AT ALL. Piping `mnema` to read its help is a
|
|
65
|
+
* perfectly good thing to do, and a nag in that case is noise in someone's data.
|
|
66
|
+
* The node case does explain itself, because "I ran mnema and got help" deserves
|
|
67
|
+
* a reason — but on stderr, so `mnema | cat` still gets clean help on stdout.
|
|
68
|
+
*/
|
|
69
|
+
export function explainUnavailable(reason, detail) {
|
|
70
|
+
if (reason === 'node') {
|
|
71
|
+
return [
|
|
72
|
+
`The interactive UI needs Node ${MIN_NODE} or newer — you are on ${detail}.`,
|
|
73
|
+
'Everything below works on your version. For the UI: nvm install 22',
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
if (reason === 'dumb-terminal') return ['This terminal reports TERM=dumb, so the interactive UI is off.'];
|
|
77
|
+
if (reason === 'no-raw-mode') return ['This terminal cannot enter raw mode, so the interactive UI cannot read keys.'];
|
|
78
|
+
return [];
|
|
79
|
+
}
|
package/src/util.mjs
CHANGED
|
@@ -28,14 +28,16 @@ export const DEFAULT_ORIGIN = process.env.MNEMA_API_ORIGIN || 'https://api.thebo
|
|
|
28
28
|
*/
|
|
29
29
|
export const DEFAULT_APP_URL = process.env.MNEMA_APP_URL || 'https://mnema.theboringpeople.in';
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
/**
|
|
32
|
+
* ⭐ RE-EXPORTED, NOT REDEFINED. `c` used to live here and emitted escapes
|
|
33
|
+
* unconditionally — no isTTY, no NO_COLOR — so `mnema tasks | less` was full of
|
|
34
|
+
* escape bytes and `mnema docs > out.txt` wrote them to disk.
|
|
35
|
+
*
|
|
36
|
+
* Re-exporting from render/theme.mjs means every existing
|
|
37
|
+
* `import { c } from './util.mjs'` picks up the TTY-aware version without a
|
|
38
|
+
* single call site changing. One line, whole-CLI effect.
|
|
39
|
+
*/
|
|
40
|
+
export { c, truncate, pad, table, section, width, link, mark, strip, displayWidth } from './render/theme.mjs';
|
|
39
41
|
|
|
40
42
|
// ── git ──────────────────────────────────────────────────────────────────────
|
|
41
43
|
|
|
@@ -118,35 +120,92 @@ export async function apiFetch(origin, path, { token, method = 'GET', body } = {
|
|
|
118
120
|
|
|
119
121
|
// ── prompts ────────────────────────────────────────────────────────────────────
|
|
120
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Ask a question and read one line.
|
|
125
|
+
*
|
|
126
|
+
* ⚠️ CTRL-D USED TO VANISH. There was no `close` handler, so EOF never resolved
|
|
127
|
+
* the promise — the process simply ran out of work and exited 0, halfway through
|
|
128
|
+
* `init`, with no message. A setup that stops silently and claims success is
|
|
129
|
+
* worse than one that fails, so EOF now rejects and the caller reports it.
|
|
130
|
+
*/
|
|
121
131
|
export function prompt(question) {
|
|
122
|
-
|
|
123
|
-
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
134
|
+
let answered = false;
|
|
135
|
+
rl.question(question, (a) => { answered = true; rl.close(); resolve(a.trim()); });
|
|
136
|
+
rl.on('close', () => {
|
|
137
|
+
if (!answered) reject(new Error('Cancelled.'));
|
|
138
|
+
});
|
|
139
|
+
});
|
|
124
140
|
}
|
|
125
141
|
|
|
126
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Prompt for a secret without echoing it.
|
|
144
|
+
*
|
|
145
|
+
* ⚠️ THREE THINGS WERE WRONG HERE, and two of them were security-adjacent.
|
|
146
|
+
*
|
|
147
|
+
* 1. NOTHING WAS ECHOED AT ALL — not even a mask. Pasting a 106-character API key
|
|
148
|
+
* looked exactly like pressing nothing, so the only way to find out whether it
|
|
149
|
+
* had registered was to hit Enter and see what happened. A dot per grapheme
|
|
150
|
+
* fixes that without revealing length-sensitive content any more than the
|
|
151
|
+
* cursor position already does.
|
|
152
|
+
*
|
|
153
|
+
* 2. CTRL-C EXITED WITHOUT RESTORING RAW MODE. Node usually repairs the tty on
|
|
154
|
+
* exit, so it *usually* looked fine — which is exactly what makes it the kind
|
|
155
|
+
* of bug that surfaces on someone else's terminal months later.
|
|
156
|
+
*
|
|
157
|
+
* 3. ESCAPE SEQUENCES WERE APPENDED VERBATIM. An arrow key is `\x1b[A`; that went
|
|
158
|
+
* straight into the secret, as did Ctrl-U, Ctrl-W and bracketed-paste markers.
|
|
159
|
+
* The user saw nothing (see 1) and got an authentication failure with no clue.
|
|
160
|
+
* Control bytes are now filtered rather than stored.
|
|
161
|
+
*
|
|
162
|
+
* Falls back to a visible readline when stdin is not a TTY — deliberate, so
|
|
163
|
+
* `echo $TOKEN | mnema init` still works in CI.
|
|
164
|
+
*/
|
|
127
165
|
export function promptHidden(question) {
|
|
128
|
-
return new Promise((resolve) => {
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
129
167
|
const { stdin, stdout } = process;
|
|
130
168
|
if (!stdin.isTTY) {
|
|
131
|
-
// Non-interactive: read one line plainly.
|
|
132
169
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
133
|
-
|
|
170
|
+
let answered = false;
|
|
171
|
+
rl.question(question, (a) => { answered = true; rl.close(); resolve(a.trim()); });
|
|
172
|
+
rl.on('close', () => { if (!answered) reject(new Error('Cancelled.')); });
|
|
134
173
|
return;
|
|
135
174
|
}
|
|
175
|
+
|
|
136
176
|
stdout.write(question);
|
|
137
177
|
stdin.setRawMode(true);
|
|
138
178
|
stdin.resume();
|
|
139
179
|
let buf = '';
|
|
180
|
+
|
|
181
|
+
// One restore path for every exit, so raw mode cannot leak.
|
|
182
|
+
const restore = () => {
|
|
183
|
+
stdin.setRawMode(false);
|
|
184
|
+
stdin.pause();
|
|
185
|
+
stdin.removeListener('data', onData);
|
|
186
|
+
};
|
|
187
|
+
|
|
140
188
|
const onData = (ch) => {
|
|
141
189
|
const s = ch.toString('utf8');
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
190
|
+
|
|
191
|
+
if (s === '\r' || s === '\n') { restore(); stdout.write('\n'); resolve(buf.trim()); return; }
|
|
192
|
+
if (s === '\u0003') { restore(); stdout.write('\n'); reject(new Error('Cancelled.')); return; } // Ctrl-C
|
|
193
|
+
if (s === '\u0004') { restore(); stdout.write('\n'); reject(new Error('Cancelled.')); return; } // Ctrl-D
|
|
194
|
+
if (s === '\u0015') { stdout.write(`\r${question}${' '.repeat(buf.length)}\r${question}`); buf = ''; return; } // Ctrl-U
|
|
195
|
+
|
|
196
|
+
if (s === '\u007f' || s === '\b') {
|
|
197
|
+
if (buf.length) { buf = buf.slice(0, -1); stdout.write('\b \b'); }
|
|
198
|
+
return;
|
|
145
199
|
}
|
|
146
|
-
|
|
147
|
-
|
|
200
|
+
|
|
201
|
+
// ⚠️ Drop anything with a control byte in it — arrow keys, function keys,
|
|
202
|
+
// bracketed-paste markers. Storing these was silent corruption of a secret.
|
|
203
|
+
if (/[\u0000-\u001f\u007f]/.test(s)) return;
|
|
204
|
+
|
|
148
205
|
buf += s;
|
|
206
|
+
stdout.write('•'.repeat([...s].length));
|
|
149
207
|
};
|
|
208
|
+
|
|
150
209
|
stdin.on('data', onData);
|
|
151
210
|
});
|
|
152
211
|
}
|