@mnemahq/cli 0.3.0 → 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.0",
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",
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
@@ -10,14 +10,14 @@
10
10
  * mnema uninstall cleanly reverse everything
11
11
  */
12
12
 
13
- import { existsSync } from 'node:fs';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
14
  import { cmdLogin, cmdLogout, accessToken } from './login.mjs';
15
- import { makeClient, canAuthenticate, renderError } from './client.mjs';
15
+ import { makeClient, call, hasApiKey, canAuthenticate, renderError, mintHookToken } from './client.mjs';
16
16
  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';
@@ -27,17 +27,59 @@ import {
27
27
  import { applyContext, scaffold } from './artifacts.mjs';
28
28
  import { execFileSync } from 'node:child_process';
29
29
 
30
- const VERSION = '0.1.0';
30
+ /**
31
+ * ⚠️ READ FROM package.json, NEVER HAND-WRITTEN. This was `const VERSION =
32
+ * '0.1.0'` and stayed that way through 0.1.1 and 0.3.0 — so every release of
33
+ * this CLI reported the same wrong number, and `mnema --version` was useless for
34
+ * the one job it has.
35
+ *
36
+ * The cost was not cosmetic. After 0.3.0 shipped with eight new commands,
37
+ * `mnema --help` printed "mnema 0.1.0" and the old command list, because the
38
+ * global install was stale and npm does not upgrade an installed global when you
39
+ * publish. The right fix was one line — `npm i -g @mnemahq/cli@latest` — but a
40
+ * binary that misreports itself makes that undiagnosable, and the honest reading
41
+ * of the evidence was "you shipped nothing".
42
+ *
43
+ * Resolved relative to import.meta.url, not cwd: the CLI runs from whatever
44
+ * directory the user happens to be in. package.json is always in the tarball.
45
+ */
46
+ const VERSION = JSON.parse(
47
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
48
+ ).version;
49
+
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']);
31
66
 
32
- function parseFlags(argv) {
67
+ export function parseFlags(argv) {
33
68
  const flags = {}; const rest = [];
34
69
  for (let i = 0; i < argv.length; i++) {
35
70
  const a = argv[i];
36
- if (a.startsWith('--')) {
37
- const key = a.slice(2);
38
- if (argv[i + 1] && !argv[i + 1].startsWith('--')) { flags[key] = argv[++i]; }
39
- else flags[key] = true;
40
- } 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
+ }
41
83
  }
42
84
  return { flags, rest };
43
85
  }
@@ -105,6 +147,26 @@ async function cmdInit(flags) {
105
147
  if (!workspaceId) { console.error(c.red('A workspace id is required.')); process.exit(1); }
106
148
 
107
149
  let hookToken = process.env.MNEMA_HOOK_TOKEN || getSecret(workspaceId, 'hook-token');
150
+
151
+ // ⚠️ ONLY MINT WHEN THERE IS NOTHING STORED. Minting ROTATES — the server keeps
152
+ // a hash, so `regenerate` is the only way to obtain a token and it invalidates
153
+ // the previous one. Re-running `init` on a second machine must not silently
154
+ // kill capture on the first.
155
+ if (!hookToken) {
156
+ process.stdout.write('Getting a hook token… ');
157
+ const minted = await mintHookToken({ origin, workspaceId });
158
+ if (minted) {
159
+ hookToken = minted;
160
+ console.log(c.green('done'));
161
+ console.log(c.dim(' A new token was issued. Any OTHER machine set up for this workspace'));
162
+ console.log(c.dim(' must re-run `mnema init` — the previous token no longer works.'));
163
+ } else {
164
+ // Not logged in, not the owner (the endpoint is owner-only), or offline.
165
+ // Fall through to asking, with the right pointer.
166
+ console.log(c.dim('not available — enter one manually'));
167
+ }
168
+ }
169
+
108
170
  if (!hookToken) {
109
171
  // ⚠️ NOT "Settings → Developer". That page does not exist and never did in
110
172
  // this shape — the hook token moved to the Access page when tokens were
@@ -215,9 +277,8 @@ async function cmdSessions(flags) {
215
277
  const repo = canonicalRepo(git.remote);
216
278
  let rows = [];
217
279
  try {
218
- rows = await makeClient({ origin, workspaceId })
219
- .sessions.list({ limit, ...(repo ? { repo } : {}) })
220
- .all();
280
+ rows = await call({ origin, workspaceId }, (m) =>
281
+ m.sessions.list({ limit, ...(repo ? { repo } : {}) }).all());
221
282
  } catch (e) {
222
283
  // Non-fatal by design: the LOCAL list above is the useful half and has
223
284
  // already printed. Degrading here is deliberate, and it says which wall it
@@ -260,13 +321,21 @@ async function cmdSearch(flags, rest) {
260
321
 
261
322
  let results = [];
262
323
  try {
263
- results = await makeClient({ origin, workspaceId }).docs.search(query);
264
- } catch (e) { renderError(e, { context: 'search' }); }
324
+ results = await call({ origin, workspaceId }, (m) => m.docs.search(query));
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
+
265
332
  if (!results.length) { console.log(c.dim('No results.')); return; }
266
333
  console.log(c.bold(`${results.length} result(s) for "${query}"`));
267
334
  for (const d of results) {
268
335
  console.log(` ${c.bold(d.title || d.path || d.id)}`);
269
- 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))}`);
270
339
  }
271
340
  }
272
341
 
@@ -285,8 +354,8 @@ async function cmdPull(flags) {
285
354
  try {
286
355
  // `.context`, not the whole result — `.repo` is the URL the server matched.
287
356
  // Reading the wrong one of these two is the bug this rewire found in the SDK.
288
- docs = (await makeClient({ origin, workspaceId }).repos.context(repo)).context ?? [];
289
- } catch (e) { renderError(e, { context: 'pull' }); }
357
+ docs = (await call({ origin, workspaceId }, (m) => m.repos.context(repo))).context ?? [];
358
+ } catch (e) { renderError(e, { context: 'pull', usedApiKey: hasApiKey(workspaceId) }); }
290
359
 
291
360
  scaffold(root);
292
361
  const s = applyContext(root, docs);
@@ -329,6 +398,9 @@ async function cmdDoctor(flags) {
329
398
  const apiKey = workspaceId ? getSecret(workspaceId, 'api-key') : null;
330
399
  if (apiKey) {
331
400
  let keyOk = false;
401
+ // Probes the KEY specifically — no fallback — because that is the thing
402
+ // doctor is reporting on. Using call() here would mask a dead key behind a
403
+ // working login and print a green tick for a credential that does not work.
332
404
  try { await makeClient({ origin, workspaceId }).docs.list({ limit: 1 }).pages().next(); keyOk = true; } catch { /* */ }
333
405
  ok('API key valid', keyOk);
334
406
  } else {
@@ -400,6 +472,15 @@ Options:
400
472
  --yes Non-interactive; skip optional prompts
401
473
  --purge uninstall: also delete .mnema/config.json
402
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.
403
484
  `);
404
485
  }
405
486
 
package/src/client.mjs CHANGED
@@ -28,8 +28,8 @@ import { c, DEFAULT_APP_URL } from './util.mjs';
28
28
  * request — that is what makes a refresh mid-command work instead of failing on
29
29
  * a token that expired thirty seconds ago.
30
30
  */
31
- export function makeClient({ origin, workspaceId }) {
32
- const apiKey = (workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY || null;
31
+ export function makeClient({ origin, workspaceId, forceLogin = false }) {
32
+ const apiKey = forceLogin ? null : ((workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY || null);
33
33
  return new Mnema(
34
34
  apiKey
35
35
  ? { apiKey, baseUrl: origin }
@@ -37,6 +37,45 @@ export function makeClient({ origin, workspaceId }) {
37
37
  );
38
38
  }
39
39
 
40
+ /**
41
+ * Run an SDK call, and if a STORED API KEY is rejected, retry once with the login.
42
+ *
43
+ * ⭐ THE TRAP THIS REMOVES. makeClient prefers an explicit API key, which is right
44
+ * — a key is scoped and deliberate. But a DEAD key then shadows a perfectly good
45
+ * login forever, and the failure told the user to do the one thing that could not
46
+ * help:
47
+ *
48
+ * $ mnema docs -> not authenticated. Run `mnema login`.
49
+ * $ mnema login -> ✓ Logged in.
50
+ * $ mnema docs -> not authenticated. Run `mnema login`.
51
+ *
52
+ * The login was never the credential in play. An instruction that cannot work is
53
+ * worse than none: it spends the reader's remaining trust in the tool.
54
+ *
55
+ * ⚠️ THE SDK CANNOT DO THIS FOR US. packages/core resolves `Bearer apiKey`
56
+ * whenever apiKey is set and never consults getToken, so `onUnauthorized` can
57
+ * ask for a retry but cannot change which credential the retry uses. The swap has
58
+ * to happen out here, by building a second client.
59
+ *
60
+ * ⚠️ AND IT IS ANNOUNCED, not silent. Quietly using a different identity than the
61
+ * one configured is its own confusion — the next question would be "which
62
+ * credential ran this?", and the answer should not require reading source.
63
+ */
64
+ export async function call(ctx, fn) {
65
+ try {
66
+ return await fn(makeClient(ctx));
67
+ } catch (e) {
68
+ const isAuth = e?.constructor?.name === 'AuthError';
69
+ if (!isAuth || ctx.forceLogin || !hasApiKey(ctx.workspaceId)) throw e;
70
+ let loggedIn = false;
71
+ try { loggedIn = Boolean(await accessToken()); } catch { /* no login */ }
72
+ if (!loggedIn) throw e;
73
+ console.error(c.yellow(' stored API key was rejected — using your login instead.'));
74
+ console.error(c.dim(' Run `mnema init` to replace the key, or ignore this if you meant to use the login.'));
75
+ return await fn(makeClient({ ...ctx, forceLogin: true }));
76
+ }
77
+ }
78
+
40
79
  /** True when an explicit API key is configured. Cheap; no network. */
41
80
  export function hasApiKey(workspaceId) {
42
81
  return Boolean((workspaceId && getSecret(workspaceId, 'api-key')) || process.env.MNEMA_API_KEY);
@@ -73,14 +112,24 @@ export async function canAuthenticate(workspaceId) {
73
112
  * branch — turning a good message back into a bad one for the exact reason this
74
113
  * function exists.
75
114
  */
76
- export function renderError(e, { context = '' } = {}) {
115
+ export function renderError(e, { context = '', usedApiKey = false } = {}) {
116
+ const opts = { usedApiKey };
77
117
  const name = e?.constructor?.name ?? '';
78
118
  const where = context ? `${context}: ` : '';
79
119
 
80
120
  switch (name) {
81
121
  case 'AuthError':
82
122
  console.error(c.red(`${where}not authenticated.`));
83
- console.error(` ${e.fix ?? 'Run `mnema login`.'}`);
123
+ // ⚠️ NAME THE CREDENTIAL THAT FAILED. The SDK's generic fix is "Run `mnema
124
+ // login`", which is exactly wrong when the thing that was rejected is a
125
+ // stored API KEY and a valid login already exists — the user runs it,
126
+ // succeeds, and nothing changes.
127
+ if (opts.usedApiKey) {
128
+ console.error(' The stored API key was rejected.');
129
+ console.error(' Run `mnema init` to replace it, or `mnema login` to use a login instead.');
130
+ } else {
131
+ console.error(` ${e.fix ?? 'Run `mnema login`.'}`);
132
+ }
84
133
  break;
85
134
 
86
135
  case 'PlanRequiredError':
@@ -126,3 +175,43 @@ export function renderError(e, { context = '' } = {}) {
126
175
  }
127
176
  process.exit(1);
128
177
  }
178
+
179
+ /**
180
+ * Mint a hook token for this workspace, using the login (t-627).
181
+ *
182
+ * ⭐ WHY THIS EXISTS. Setting up a repo meant fetching three things from two
183
+ * settings pages. The workspace id now comes from /api/me and the API key is
184
+ * optional — this was the last piece of copy-paste, and it never needed to be one:
185
+ * the endpoint has existed all along and has accepted a bearer token since t-613.
186
+ *
187
+ * ⚠️ THE HOOK TOKEN CANNOT SIMPLY BE THE LOGIN, and that is deliberate rather than
188
+ * unfinished. installHook writes it in PLAINTEXT to ~/.claude/mnema-hook.json,
189
+ * where an unattended process reads it after every session. A narrow,
190
+ * single-purpose token belongs in a file like that; an OAuth refresh token — which
191
+ * can mint access tokens for the whole account — does not.
192
+ *
193
+ * ⚠️ AND MINTING ROTATES. The server keeps only a HASH, so there is no way to read
194
+ * an existing token back; `regenerate` is the only path and it invalidates the old
195
+ * one. Callers MUST check for a stored token first — minting unconditionally would
196
+ * silently break capture on every other machine the user has set up. That check
197
+ * lives in cmdInit, not here, because this function's job is to mint and it should
198
+ * not pretend otherwise.
199
+ *
200
+ * Returns null rather than throwing: not logged in, not the owner (the endpoint is
201
+ * owner-only), or offline are all ordinary, and the caller falls back to asking.
202
+ */
203
+ export async function mintHookToken({ origin, workspaceId }) {
204
+ try {
205
+ const token = await accessToken();
206
+ if (!token) return null;
207
+ const res = await fetch(`${origin}/api/workspaces/${workspaceId}/regenerate-hook-token`, {
208
+ method: 'POST',
209
+ headers: { Authorization: `Bearer ${token}` },
210
+ });
211
+ if (!res.ok) return null;
212
+ const body = await res.json();
213
+ return body?.hookToken ?? null;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
@@ -20,8 +20,8 @@
20
20
  * returns the shape worth emitting, so it costs one line each.
21
21
  */
22
22
 
23
- import { makeClient, canAuthenticate, renderError } from './client.mjs';
24
- import { c } from './util.mjs';
23
+ import { call, hasApiKey, canAuthenticate, renderError } from './client.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,18 +35,21 @@ 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) {
42
45
  await guard(ctx.workspaceId);
43
46
  const limit = Number(flags.limit) || 20;
44
47
  try {
45
- const rows = await makeClient(ctx).tasks.list({
48
+ const rows = await call(ctx, (m) => m.tasks.list({
46
49
  limit,
47
50
  ...(flags.status ? { status: flags.status } : {}),
48
51
  ...(flags.project ? { project: flags.project } : {}),
49
- }).all();
52
+ }).all());
50
53
  emit(flags, rows, () => {
51
54
  if (!rows.length) return console.log(c.dim('No tasks match.'));
52
55
  console.log(c.bold(`${rows.length} task(s)`));
@@ -55,13 +58,13 @@ export async function cmdTasks(flags, ctx) {
55
58
  console.log(` ${c.cyan(id)} ${String(t.status).padEnd(12)} ${c.dim(String(t.priority).padEnd(8))} ${trunc(t.title, 70)}`);
56
59
  }
57
60
  });
58
- } catch (e) { renderError(e, { context: 'tasks' }); }
61
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'tasks' }); }
59
62
  }
60
63
 
61
64
  export async function cmdNext(flags, ctx) {
62
65
  await guard(ctx.workspaceId);
63
66
  try {
64
- const t = await makeClient(ctx).tasks.next();
67
+ const t = await call(ctx, (m) => m.tasks.next());
65
68
  emit(flags, t, () => {
66
69
  if (!t) return console.log(c.dim('Nothing queued — the board is clear.'));
67
70
  console.log(`${c.bold(t.publicId || t.id)} ${t.title}`);
@@ -73,7 +76,7 @@ export async function cmdNext(flags, ctx) {
73
76
  console.log(`\n ${c.dim('branch:')} git checkout -b ${t.publicId}${slug ? `-${slug}` : ''}`);
74
77
  }
75
78
  });
76
- } catch (e) { renderError(e, { context: 'next' }); }
79
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'next' }); }
77
80
  }
78
81
 
79
82
  // ── docs ──────────────────────────────────────────────────────────────────────
@@ -81,7 +84,7 @@ export async function cmdDocs(flags, ctx) {
81
84
  await guard(ctx.workspaceId);
82
85
  const limit = Number(flags.limit) || 20;
83
86
  try {
84
- const rows = await makeClient(ctx).docs.list({ limit }).all();
87
+ const rows = await call(ctx, (m) => m.docs.list({ limit }).all());
85
88
  emit(flags, rows, () => {
86
89
  if (!rows.length) return console.log(c.dim('No docs.'));
87
90
  console.log(c.bold(`${rows.length} doc(s)`));
@@ -89,7 +92,7 @@ export async function cmdDocs(flags, ctx) {
89
92
  console.log(` ${c.dim(d.id.slice(0, 8))} ${trunc(d.title, 60).padEnd(60)} ${c.dim(String(d.updatedAt).slice(0, 10))}`);
90
93
  }
91
94
  });
92
- } catch (e) { renderError(e, { context: 'docs' }); }
95
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'docs' }); }
93
96
  }
94
97
 
95
98
  export async function cmdDoc(flags, ctx, rest) {
@@ -97,7 +100,7 @@ export async function cmdDoc(flags, ctx, rest) {
97
100
  const id = rest[0];
98
101
  if (!id) { console.error(c.red('Usage: mnema doc <id>')); process.exit(1); }
99
102
  try {
100
- const d = await makeClient(ctx).docs.get(id);
103
+ const d = await call(ctx, (m) => m.docs.get(id));
101
104
  // Markdown goes to stdout unadorned so `mnema doc <id> > file.md` works and
102
105
  // the heading is not silently baked into the file.
103
106
  emit(flags, d, () => {
@@ -105,20 +108,20 @@ export async function cmdDoc(flags, ctx, rest) {
105
108
  console.error(c.dim(`${d.path} · updated ${String(d.updatedAt).slice(0, 10)}`));
106
109
  if (d.markdown) console.log(d.markdown);
107
110
  });
108
- } catch (e) { renderError(e, { context: 'doc' }); }
111
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'doc' }); }
109
112
  }
110
113
 
111
114
  // ── projects ──────────────────────────────────────────────────────────────────
112
115
  export async function cmdProjects(flags, ctx) {
113
116
  await guard(ctx.workspaceId);
114
117
  try {
115
- const rows = await makeClient(ctx).projects.list().all();
118
+ const rows = await call(ctx, (m) => m.projects.list().all());
116
119
  emit(flags, rows, () => {
117
120
  if (!rows.length) return console.log(c.dim('No projects.'));
118
121
  console.log(c.bold(`${rows.length} project(s)`));
119
122
  for (const p of rows) console.log(` ${c.dim(p.id.slice(0, 8))} ${trunc(p.name, 50)}`);
120
123
  });
121
- } catch (e) { renderError(e, { context: 'projects' }); }
124
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'projects' }); }
122
125
  }
123
126
 
124
127
  // ── graph: ask + traverse ─────────────────────────────────────────────────────
@@ -127,7 +130,7 @@ export async function cmdAsk(flags, ctx, rest) {
127
130
  const q = rest.join(' ').trim();
128
131
  if (!q) { console.error(c.red('Usage: mnema ask "<question>"')); process.exit(1); }
129
132
  try {
130
- const a = await makeClient(ctx).graph.ask(q, { ...(flags.budget ? { budgetMs: Number(flags.budget) } : {}) });
133
+ const a = await call(ctx, (m) => m.graph.ask(q, { ...(flags.budget ? { budgetMs: Number(flags.budget) } : {}) }));
131
134
  emit(flags, a, () => {
132
135
  console.log(a.answer);
133
136
  // ⚠️ NEVER WITHOUT THE CONFIDENCE. The interpreter deliberately hedges
@@ -138,11 +141,19 @@ 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
- } catch (e) { renderError(e, { context: 'ask' }); }
156
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'ask' }); }
146
157
  }
147
158
 
148
159
  export async function cmdGraph(flags, ctx, rest) {
@@ -150,7 +161,7 @@ export async function cmdGraph(flags, ctx, rest) {
150
161
  const [from, to] = rest;
151
162
  if (!from) { console.error(c.red('Usage: mnema graph <from> [to]')); process.exit(1); }
152
163
  try {
153
- const g = await makeClient(ctx).graph.traverse(from, to);
164
+ const g = await call(ctx, (m) => m.graph.traverse(from, to));
154
165
  emit(flags, g, () => {
155
166
  if (to) {
156
167
  // connected:false is an ANSWER, not a failure — say so plainly rather
@@ -164,16 +175,20 @@ 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
- } catch (e) { renderError(e, { context: 'graph' }); }
184
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'graph' }); }
170
185
  }
171
186
 
172
187
  // ── briefing ──────────────────────────────────────────────────────────────────
173
188
  export async function cmdBriefing(flags, ctx) {
174
189
  await guard(ctx.workspaceId);
175
190
  try {
176
- const b = await makeClient(ctx).findings.briefing();
191
+ const b = await call(ctx, (m) => m.findings.briefing());
177
192
  emit(flags, b, () => {
178
193
  const p = b.pulse;
179
194
  console.log(c.bold('Pulse'));
@@ -192,5 +207,5 @@ export async function cmdBriefing(flags, ctx) {
192
207
  console.log(` ${c.dim(String(f.kind).padEnd(14))} ${trunc(f.headline, 90)}${count}`);
193
208
  }
194
209
  });
195
- } catch (e) { renderError(e, { context: 'briefing' }); }
210
+ } catch (e) { renderError(e, { usedApiKey: hasApiKey(ctx.workspaceId), context: 'briefing' }); }
196
211
  }
@@ -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
  }