@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 +21 -0
- package/package.json +3 -3
- package/src/cli.mjs +49 -8
- package/src/read-commands.mjs +19 -4
- 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 && node --check
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
package/src/read-commands.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|