@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 +21 -0
- package/package.json +3 -3
- package/src/cli.mjs +99 -18
- package/src/client.mjs +93 -4
- package/src/read-commands.mjs +37 -22
- package/src/render/theme.mjs +227 -0
- package/src/util.mjs +78 -19
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
|
+
"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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/read-commands.mjs
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
* returns the shape worth emitting, so it costs one line each.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|