@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.
@@ -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
+ }
@@ -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
- export const c = {
32
- dim: (s) => `\x1b[2m${s}\x1b[0m`,
33
- green: (s) => `\x1b[32m${s}\x1b[0m`,
34
- red: (s) => `\x1b[31m${s}\x1b[0m`,
35
- yellow: (s) => `\x1b[33m${s}\x1b[0m`,
36
- bold: (s) => `\x1b[1m${s}\x1b[0m`,
37
- cyan: (s) => `\x1b[36m${s}\x1b[0m`,
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
- const rl = createInterface({ input: process.stdin, output: process.stdout });
123
- return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
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
- /** Prompt without echoing (for secrets). Falls back to visible if not a TTY. */
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
- rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
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
- if (s === '\r' || s === '\n') {
143
- stdin.setRawMode(false); stdin.pause(); stdin.removeListener('data', onData);
144
- stdout.write('\n'); resolve(buf.trim()); return;
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
- if (s === '') { stdout.write('\n'); process.exit(1); } // Ctrl-C
147
- if (s === '' || s === '\b') { buf = buf.slice(0, -1); return; } // backspace
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
  }