@mnemahq/cli 0.3.1 → 0.4.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 CHANGED
@@ -52,6 +52,27 @@ contents. See the [privacy details](https://mnema.theboringpeople.in/docs/connec
52
52
 
53
53
  Requires Node 18+ and `git`.
54
54
 
55
+ ## Output, piping and colour
56
+
57
+ Every read command takes `--json`, so the CLI composes:
58
+
59
+ ```bash
60
+ mnema tasks --json | jq '.[] | select(.priority == "high") | .title'
61
+ ```
62
+
63
+ **Colour is emitted only when output is a terminal.** `mnema docs > out.txt` and
64
+ `mnema tasks | grep …` produce plain text with no escape sequences in it.
65
+
66
+ | variable | effect |
67
+ | --- | --- |
68
+ | `NO_COLOR` | disable colour, any value ([no-color.org](https://no-color.org)) |
69
+ | `FORCE_COLOR=1` | keep colour through a pipe, e.g. `mnema tasks \| less -R` |
70
+ | `COLUMNS` | override the width used for column layout |
71
+ | `MNEMA_WORKSPACE_ID` | default workspace, instead of `--workspace` |
72
+
73
+ `mnema doc <id>` is built for redirection: the markdown goes to stdout and the title
74
+ and path go to stderr, so `mnema doc <id> > note.md` gets the document alone.
75
+
55
76
  ## `.mnema/` repo artifacts
56
77
 
57
78
  `mnema init` scaffolds a committed `.mnema/` directory and `mnema pull` fills it:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemahq/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Mnema CLI — connect a repo to your Mnema workspace: install session capture, sweep past sessions, and search from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "claude-code",
23
23
  "sessions"
24
24
  ],
25
- "license": "SEE LICENSE IN LICENSE",
25
+ "license": "SEE LICENSE IN LICENSE.md",
26
26
  "publishConfig": {
27
27
  "access": "public"
28
28
  },
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "scripts": {
36
36
  "build": "node -e \"process.exit(0)\"",
37
- "typecheck": "node --check bin/mnema.mjs && node --check src/cli.mjs && node --check src/util.mjs && node --check src/secrets.mjs && node --check src/client.mjs && node --check src/read-commands.mjs && node --check test/version.test.mjs",
37
+ "typecheck": "node --check bin/mnema.mjs && node --check src/cli.mjs && node --check src/util.mjs && node --check src/secrets.mjs && node --check src/client.mjs && node --check src/read-commands.mjs && node --check src/render/theme.mjs",
38
38
  "test": "vitest run"
39
39
  }
40
40
  }
package/src/cli.mjs CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  cmdTasks, cmdNext, cmdDocs, cmdDoc, cmdProjects, cmdAsk, cmdGraph, cmdBriefing,
18
18
  } from './read-commands.mjs';
19
19
  import {
20
- DEFAULT_ORIGIN, DEFAULT_APP_URL, c, gitInfo, canonicalRepo, readConfig, writeConfig, removeConfigDir,
20
+ DEFAULT_ORIGIN, DEFAULT_APP_URL, c, truncate, gitInfo, canonicalRepo, readConfig, writeConfig, removeConfigDir,
21
21
  apiFetch, prompt, promptHidden, localSessionsForRepo,
22
22
  } from './util.mjs';
23
23
  import { getSecret, setSecret, deleteSecrets, backendName, usingFallback } from './secrets.mjs';
@@ -47,15 +47,39 @@ const VERSION = JSON.parse(
47
47
  readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
48
48
  ).version;
49
49
 
50
- function parseFlags(argv) {
50
+ /**
51
+ * Flags that take a value. Everything else is boolean.
52
+ *
53
+ * ⭐ THIS LIST IS THE FIX. The parser used to treat ANY flag as value-taking if
54
+ * the next argv entry did not start with `--`, so a boolean flag silently ate the
55
+ * following positional:
56
+ *
57
+ * mnema doc --json <id> -> flags.json = '<id>', rest = []
58
+ * -> "Usage: mnema doc <id>"
59
+ *
60
+ * `--json` only worked in trailing position, and the same trap hit `ask`,
61
+ * `search` and `graph`. Guessing from argv shape cannot distinguish "a boolean
62
+ * flag followed by an argument" from "a flag and its value" — the parser has to
63
+ * be told which is which, so it is.
64
+ */
65
+ const VALUE_FLAGS = new Set(['workspace', 'origin', 'limit', 'status', 'project', 'repo', 'budget']);
66
+
67
+ export function parseFlags(argv) {
51
68
  const flags = {}; const rest = [];
52
69
  for (let i = 0; i < argv.length; i++) {
53
70
  const a = argv[i];
54
- if (a.startsWith('--')) {
55
- const key = a.slice(2);
56
- if (argv[i + 1] && !argv[i + 1].startsWith('--')) { flags[key] = argv[++i]; }
57
- else flags[key] = true;
58
- } else rest.push(a);
71
+ if (!a.startsWith('--')) { rest.push(a); continue; }
72
+
73
+ // `--limit=5` as well as `--limit 5`; the former is unambiguous and common.
74
+ const eq = a.indexOf('=');
75
+ if (eq > 2) { flags[a.slice(2, eq)] = a.slice(eq + 1); continue; }
76
+
77
+ const key = a.slice(2);
78
+ if (VALUE_FLAGS.has(key) && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) {
79
+ flags[key] = argv[++i];
80
+ } else {
81
+ flags[key] = true;
82
+ }
59
83
  }
60
84
  return { flags, rest };
61
85
  }
@@ -299,11 +323,19 @@ async function cmdSearch(flags, rest) {
299
323
  try {
300
324
  results = await call({ origin, workspaceId }, (m) => m.docs.search(query));
301
325
  } catch (e) { renderError(e, { context: 'search', usedApiKey: hasApiKey(workspaceId) }); }
326
+
327
+ // ⚠️ `search` DID NOT HONOUR --json, though help advertised it for "every read
328
+ // command". It sits in cli.mjs rather than read-commands.mjs, so it never got
329
+ // the shared emit() — a filing accident, not a decision.
330
+ if (flags.json) { console.log(JSON.stringify(results, null, 2)); return; }
331
+
302
332
  if (!results.length) { console.log(c.dim('No results.')); return; }
303
333
  console.log(c.bold(`${results.length} result(s) for "${query}"`));
304
334
  for (const d of results) {
305
335
  console.log(` ${c.bold(d.title || d.path || d.id)}`);
306
- if (d.preview) console.log(` ${c.dim(String(d.preview).replace(/\s+/g, ' ').slice(0, 140))}`);
336
+ // truncate() adds an ellipsis; the old slice(0,140) cut mid-sentence with no
337
+ // marker, so a clipped preview looked like a document that simply ended.
338
+ if (d.preview) console.log(` ${c.dim(truncate(String(d.preview).replace(/\s+/g, ' '), 140))}`);
307
339
  }
308
340
  }
309
341
 
@@ -440,6 +472,15 @@ Options:
440
472
  --yes Non-interactive; skip optional prompts
441
473
  --purge uninstall: also delete .mnema/config.json
442
474
  --version, --help
475
+
476
+ Environment:
477
+ NO_COLOR Disable colour (any value)
478
+ FORCE_COLOR=1 Keep colour when piping, e.g. into \`less -R\`
479
+ COLUMNS Override the terminal width used for layout
480
+ MNEMA_WORKSPACE_ID Default workspace, instead of --workspace
481
+
482
+ Colour is off automatically when output is not a terminal, so \`mnema tasks > f.txt\`
483
+ writes plain text.
443
484
  `);
444
485
  }
445
486
 
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { call, hasApiKey, canAuthenticate, renderError } from './client.mjs';
24
- import { c } from './util.mjs';
24
+ import { c, truncate } from './util.mjs';
25
25
 
26
26
  /** Machine output is the whole object; humans get the formatted view. */
27
27
  function emit(flags, value, render) {
@@ -35,7 +35,10 @@ async function guard(workspaceId) {
35
35
  process.exit(1);
36
36
  }
37
37
 
38
- const trunc = (s, n) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : (s ?? ''));
38
+ // ⚠️ The old local helper measured `.length` UTF-16 code units so a CJK title
39
+ // or an emoji made the column visibly ragged. truncate() from the theme measures
40
+ // terminal columns instead.
41
+ const trunc = (s, n) => truncate(s ?? '', n);
39
42
 
40
43
  // ── tasks ─────────────────────────────────────────────────────────────────────
41
44
  export async function cmdTasks(flags, ctx) {
@@ -138,8 +141,16 @@ export async function cmdAsk(flags, ctx, rest) {
138
141
  const line = `confidence ${pct}%${why} · tier ${a.tier}${a.usedFallback ? ' · fallback' : ''}`;
139
142
  console.log(pct >= 60 ? c.dim(`\n${line}`) : c.yellow(`\n${line} — treat as a lead, not a fact`));
140
143
  if (a.sources?.length) {
144
+ // ⚠️ THE HEADER USED TO LIE. It printed "sources (7):" and then listed
145
+ // five, silently. Either show them all or say how many are hidden —
146
+ // a count that does not match the rows under it teaches the reader to
147
+ // distrust every other number in the output.
148
+ const SHOWN = 5;
149
+ const shown = a.sources.slice(0, SHOWN);
150
+ const hidden = a.sources.length - shown.length;
141
151
  console.log(c.dim(`\nsources (${a.sources.length}):`));
142
- for (const s of a.sources.slice(0, 5)) console.log(c.dim(` ${trunc(s.title, 70)}`));
152
+ for (const s of shown) console.log(c.dim(` ${trunc(s.title, 70)}`));
153
+ if (hidden > 0) console.log(c.dim(` … ${hidden} more — --json for all`));
143
154
  }
144
155
  });
145
156
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'ask' }); }
@@ -164,7 +175,11 @@ export async function cmdGraph(flags, ctx, rest) {
164
175
  return;
165
176
  }
166
177
  console.log(c.bold(`${g.nodes.length} node(s), ${g.edges.length} edge(s) around "${from}"`));
167
- for (const n of g.nodes.slice(0, 25)) console.log(` ${trunc(n.label, 60).padEnd(60)} ${c.dim(n.type ?? '')}`);
178
+ // ⚠️ SAME LIE AS `ask`: the header reported the true count and the body
179
+ // stopped at 25 without a word.
180
+ const CAP = 25;
181
+ for (const n of g.nodes.slice(0, CAP)) console.log(` ${trunc(n.label, 60).padEnd(60)} ${c.dim(n.type ?? '')}`);
182
+ if (g.nodes.length > CAP) console.log(c.dim(` … ${g.nodes.length - CAP} more — --json for all`));
168
183
  });
169
184
  } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'graph' }); }
170
185
  }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The CLI's presentation primitives (t-628).
3
+ *
4
+ * ⭐ COLOUR WAS UNCONDITIONAL, WHICH IS THE BUG UNDER THE BUG. util.mjs's `c`
5
+ * helper emitted `\x1b[Nm…\x1b[0m` with no isTTY check, no NO_COLOR, no
6
+ * FORCE_COLOR anywhere in the package — so `mnema tasks | less` and
7
+ * `mnema docs > out.txt` both contained raw escape bytes, and nothing in the CLI
8
+ * had any idea how wide the terminal was. Eleven separate hard-coded `padEnd`
9
+ * widths disagreed with each other because there was nothing to agree with.
10
+ *
11
+ * This is the one module that knows about the terminal. Everything above it asks
12
+ * questions ("how wide?", "truncate this") instead of writing escapes.
13
+ *
14
+ * ⚠️ THE ORDERING RULE THAT MUST NOT BREAK: pad and truncate the PLAIN string,
15
+ * then colour it. Alignment is correct in the current CLI only because every call
16
+ * site happens to do this by hand — `id.padEnd(8)` before `c.cyan(...)`. An
17
+ * escape sequence is zero columns wide but many characters long, so padding a
18
+ * coloured string pads by the wrong amount and the column silently drifts. Every
19
+ * helper here takes plain text and returns coloured text; none of them accepts
20
+ * pre-coloured input.
21
+ */
22
+
23
+ /**
24
+ * ⚠️ THREE SIGNALS, IN THIS ORDER, and the order is the convention every other
25
+ * CLI follows: an explicit FORCE_COLOR wins, then NO_COLOR (https://no-color.org
26
+ * — presence alone counts, whatever the value), then whether stdout is a real
27
+ * terminal. Reading isTTY first would make FORCE_COLOR useless in exactly the
28
+ * case people set it: piping into something that renders colour itself.
29
+ */
30
+ function detectColour() {
31
+ const env = process.env;
32
+ // ⚠️ AN EMPTY STRING IS "UNSET", NOT "ON". `FORCE_COLOR=` in a shell sets the
33
+ // variable to '' — and reading that as force-on made `NO_COLOR=1 FORCE_COLOR= …`
34
+ // emit colour anyway, which is precisely the combination someone types when
35
+ // they are trying hard to turn it off. Caught by the verification run, not by
36
+ // reading the code.
37
+ const force = env.FORCE_COLOR;
38
+ if (force !== undefined && force !== '') {
39
+ return force !== '0' && force !== 'false';
40
+ }
41
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false;
42
+ if (env.TERM === 'dumb') return false;
43
+ return Boolean(process.stdout.isTTY);
44
+ }
45
+
46
+ let colourEnabled = detectColour();
47
+
48
+ /** Test seam. Production code never calls this. */
49
+ export function setColour(on) { colourEnabled = on; }
50
+ export function colourOn() { return colourEnabled; }
51
+
52
+ const wrap = (open) => (s) => (colourEnabled ? `\x1b[${open}m${s}\x1b[0m` : String(s));
53
+
54
+ /**
55
+ * Same six names the old `c` helper had, so the migration is a rename rather
56
+ * than a rewrite, plus the two the redesign needs.
57
+ */
58
+ export const c = {
59
+ dim: wrap(2),
60
+ bold: wrap(1),
61
+ red: wrap(31),
62
+ green: wrap(32),
63
+ yellow: wrap(33),
64
+ cyan: wrap(36),
65
+ blue: wrap(34),
66
+ magenta: wrap(35),
67
+ };
68
+
69
+ /** Strip escapes — needed to measure anything that may already be coloured. */
70
+ export function strip(s) {
71
+ // eslint-disable-next-line no-control-regex
72
+ return String(s).replace(/\x1b\[[0-9;]*m/g, '');
73
+ }
74
+
75
+ /**
76
+ * How many terminal columns a string occupies.
77
+ *
78
+ * ⚠️ NOT `.length`. The old `trunc()` measured UTF-16 code units, which is wrong
79
+ * three separate ways: a CJK ideograph is one unit but two columns, an emoji is
80
+ * two units and two columns, and a combining accent is one unit and zero columns.
81
+ * A list of Japanese doc titles came out visibly ragged.
82
+ *
83
+ * Uses Intl.Segmenter to walk graphemes so a family emoji or a flag counts once.
84
+ * It has been in Node since 16, and this package requires 18.
85
+ */
86
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
87
+
88
+ export function displayWidth(s) {
89
+ const plain = strip(s);
90
+ let w = 0;
91
+ for (const { segment } of segmenter.segment(plain)) {
92
+ const cp = segment.codePointAt(0);
93
+ if (cp === undefined) continue;
94
+ // Zero-width: combining marks, ZWJ, variation selectors, control chars.
95
+ if (cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x0300 && cp <= 0x036f)) continue;
96
+ if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) continue;
97
+ w += isWide(cp) ? 2 : 1;
98
+ }
99
+ return w;
100
+ }
101
+
102
+ /** East Asian Wide + Fullwidth ranges, plus the emoji blocks that render double. */
103
+ function isWide(cp) {
104
+ return (
105
+ (cp >= 0x1100 && cp <= 0x115f) // Hangul Jamo
106
+ || (cp >= 0x2e80 && cp <= 0xa4cf) // CJK radicals … Yi
107
+ || (cp >= 0xac00 && cp <= 0xd7a3) // Hangul syllables
108
+ || (cp >= 0xf900 && cp <= 0xfaff) // CJK compatibility ideographs
109
+ || (cp >= 0xfe30 && cp <= 0xfe6f) // CJK compatibility forms
110
+ || (cp >= 0xff00 && cp <= 0xff60) // Fullwidth forms
111
+ || (cp >= 0xffe0 && cp <= 0xffe6)
112
+ || (cp >= 0x1f300 && cp <= 0x1f64f) // emoji
113
+ || (cp >= 0x1f900 && cp <= 0x1f9ff)
114
+ || (cp >= 0x20000 && cp <= 0x3fffd) // CJK ext B+
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Truncate to `n` COLUMNS, with an ellipsis when it actually cut something.
120
+ *
121
+ * ⚠️ THE ELLIPSIS IS NOT DECORATION. `search` used to `slice(0, 140)` with no
122
+ * marker, so a preview that stopped mid-sentence was indistinguishable from a
123
+ * document that ended there. Everywhere that shortens text must say it did.
124
+ */
125
+ export function truncate(s, n) {
126
+ const plain = strip(s ?? '');
127
+ if (n <= 0) return '';
128
+ if (displayWidth(plain) <= n) return plain;
129
+ let out = '';
130
+ let w = 0;
131
+ for (const { segment } of segmenter.segment(plain)) {
132
+ const sw = displayWidth(segment);
133
+ if (w + sw > n - 1) break;
134
+ out += segment;
135
+ w += sw;
136
+ }
137
+ return `${out}…`;
138
+ }
139
+
140
+ /** Pad to `n` columns. Plain text in, plain text out — colour afterwards. */
141
+ export function pad(s, n, align = 'left') {
142
+ const plain = strip(s ?? '');
143
+ const gap = n - displayWidth(plain);
144
+ if (gap <= 0) return plain;
145
+ return align === 'right' ? ' '.repeat(gap) + plain : plain + ' '.repeat(gap);
146
+ }
147
+
148
+ /**
149
+ * Usable terminal width.
150
+ *
151
+ * COLUMNS is honoured because that is how a user (and every test) says "pretend
152
+ * the terminal is this wide" for something that is not a TTY. Clamped low so a
153
+ * 20-column terminal degrades rather than producing negative padding, and high so
154
+ * a maximised 4K window does not stretch a two-column list across 400 characters.
155
+ */
156
+ export function width() {
157
+ const raw = Number(process.env.COLUMNS) || process.stdout.columns || 80;
158
+ return Math.max(40, Math.min(raw, 120));
159
+ }
160
+
161
+ /**
162
+ * Render aligned rows from `[{ cells: [...], style?: [...] }]`.
163
+ *
164
+ * ⭐ REPLACES ELEVEN HAND-PICKED padEnd WIDTHS. Column widths are measured from
165
+ * the content, then the LAST column absorbs whatever is left so a long title
166
+ * truncates instead of wrapping and destroying the alignment of every row under
167
+ * it. `style` is applied per cell AFTER padding, preserving the ordering rule at
168
+ * the top of this file.
169
+ */
170
+ export function table(rows, { indent = 2, gap = 2, max = width() } = {}) {
171
+ if (!rows.length) return [];
172
+ const cols = Math.max(...rows.map((r) => r.cells.length));
173
+ const widths = [];
174
+ for (let i = 0; i < cols; i += 1) {
175
+ widths[i] = Math.max(...rows.map((r) => displayWidth(r.cells[i] ?? '')));
176
+ }
177
+ // Everything except the final column is fixed; the final column gets the rest.
178
+ const fixed = indent + widths.slice(0, -1).reduce((a, b) => a + b + gap, 0);
179
+ widths[cols - 1] = Math.max(8, max - fixed);
180
+
181
+ return rows.map((r) => {
182
+ const parts = r.cells.map((cell, i) => {
183
+ const isLast = i === cols - 1;
184
+ const text = isLast ? truncate(cell ?? '', widths[i]) : pad(cell ?? '', widths[i]);
185
+ const style = r.style?.[i];
186
+ // Trailing cell is not padded — a padded last column leaves invisible
187
+ // whitespace that shows up when someone copies a row out of the terminal.
188
+ const sized = isLast ? text : text;
189
+ return style ? style(sized) : sized;
190
+ });
191
+ return ' '.repeat(indent) + parts.join(' '.repeat(gap)).replace(/\s+$/, '');
192
+ });
193
+ }
194
+
195
+ /**
196
+ * One heading grammar.
197
+ *
198
+ * ⚠️ THE OLD CLI HAD FOUR: product-prefixed ("Mnema status"), noun + count
199
+ * ("Server sessions (7)"), count-first ("12 task(s)"), and bare noun ("Pulse").
200
+ * Nothing was wrong with any one of them; having four made the output read as
201
+ * four different programs.
202
+ */
203
+ export function section(title, meta) {
204
+ const left = c.bold(title);
205
+ if (!meta) return left;
206
+ const room = width() - displayWidth(title) - displayWidth(meta) - 2;
207
+ return room > 0 ? `${left}${' '.repeat(room)}${c.dim(meta)}` : `${left} ${c.dim(meta)}`;
208
+ }
209
+
210
+ /**
211
+ * A terminal hyperlink (OSC 8), degrading to plain text where unsupported.
212
+ *
213
+ * ⚠️ NOT EMITTED WHEN COLOUR IS OFF. The same detection covers both: if stdout is
214
+ * a pipe, an OSC 8 sequence is corruption in the consumer's data, not a link.
215
+ */
216
+ export function link(label, url) {
217
+ if (!colourEnabled) return `${label} (${url})`;
218
+ return `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\`;
219
+ }
220
+
221
+ /** Status glyphs, used consistently rather than ad hoc per command. */
222
+ export const mark = {
223
+ ok: () => c.green('✓'),
224
+ bad: () => c.red('✗'),
225
+ warn: () => c.yellow('!'),
226
+ dot: () => c.dim('·'),
227
+ };
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
  }