@moku-labs/common 0.1.1 → 0.2.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 +2 -0
- package/dist/browser.d.mts +6 -0
- package/dist/browser.mjs +13 -0
- package/dist/cli.cjs +669 -0
- package/dist/cli.d.cts +517 -0
- package/dist/cli.d.mts +517 -0
- package/dist/cli.mjs +652 -0
- package/dist/index.cjs +13 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +13 -0
- package/package.json +22 -4
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
//#region src/cli/ansi.ts
|
|
3
|
+
/**
|
|
4
|
+
* @file `@moku-labs/common/cli` — TTY/`NO_COLOR`-aware ANSI color + box-drawing
|
|
5
|
+
* primitives: the shared "brand DNA" every Moku CLI renders through (the brand pink,
|
|
6
|
+
* the palette, the box/spinner/progress glyphs). Color and Unicode glyphs are emitted
|
|
7
|
+
* only on a real TTY with `NO_COLOR` unset; otherwise plain ASCII so CI logs and pipes
|
|
8
|
+
* stay readable. Pure: depends on nothing but `process.stdout`/`process.env` defaults,
|
|
9
|
+
* so a consuming framework can build its own panels on top without pulling in any UI lib.
|
|
10
|
+
*/
|
|
11
|
+
/** The ANSI escape byte (ESC, `0x1b`), built so no literal control char is in source. */
|
|
12
|
+
const ESC = String.fromCodePoint(27);
|
|
13
|
+
/** ANSI SGR codes used by the brand renderer (each prefixed with the ESC byte). */
|
|
14
|
+
const ANSI = {
|
|
15
|
+
reset: `${ESC}[0m`,
|
|
16
|
+
bold: `${ESC}[1m`,
|
|
17
|
+
dim: `${ESC}[2m`,
|
|
18
|
+
red: `${ESC}[31m`,
|
|
19
|
+
green: `${ESC}[32m`,
|
|
20
|
+
yellow: `${ESC}[33m`,
|
|
21
|
+
blue: `${ESC}[34m`,
|
|
22
|
+
magenta: `${ESC}[35m`,
|
|
23
|
+
cyan: `${ESC}[36m`,
|
|
24
|
+
gray: `${ESC}[90m`
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The Moku brand pink (`#FF1E6F`) as an RGB triple, used for 24-bit truecolor output.
|
|
28
|
+
* Degrades to {@link ANSI.magenta} on a 16-color TTY and to plain text off a TTY.
|
|
29
|
+
*/
|
|
30
|
+
const BRAND_PINK = {
|
|
31
|
+
r: 255,
|
|
32
|
+
g: 30,
|
|
33
|
+
b: 111
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Build a 24-bit (truecolor) SGR foreground escape for the given RGB triple.
|
|
37
|
+
*
|
|
38
|
+
* @param r - Red channel (0–255).
|
|
39
|
+
* @param g - Green channel (0–255).
|
|
40
|
+
* @param b - Blue channel (0–255).
|
|
41
|
+
* @returns The `ESC[38;2;r;g;bm` foreground sequence.
|
|
42
|
+
* @example
|
|
43
|
+
* fg24(255, 30, 111); // "\x1b[38;2;255;30;111m"
|
|
44
|
+
*/
|
|
45
|
+
function fg24(r, g, b) {
|
|
46
|
+
return `${ESC}[38;2;${r};${g};${b}m`;
|
|
47
|
+
}
|
|
48
|
+
/** ANSI: erase the entire current line, leaving the cursor where it is. */
|
|
49
|
+
const CLEAR_LINE = `${ESC}[2K`;
|
|
50
|
+
/** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
|
|
51
|
+
const CLEAR_BELOW = `${ESC}[0J`;
|
|
52
|
+
/**
|
|
53
|
+
* Braille spinner frames for live "working…" indicators on a TTY (advance one per tick).
|
|
54
|
+
* Off a TTY a renderer never animates, so this is unused in plain/CI output.
|
|
55
|
+
*/
|
|
56
|
+
const SPINNER_FRAMES = [
|
|
57
|
+
"⠋",
|
|
58
|
+
"⠙",
|
|
59
|
+
"⠹",
|
|
60
|
+
"⠸",
|
|
61
|
+
"⠼",
|
|
62
|
+
"⠴",
|
|
63
|
+
"⠦",
|
|
64
|
+
"⠧",
|
|
65
|
+
"⠇",
|
|
66
|
+
"⠏"
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* The ANSI sequence to move the cursor up `n` lines (empty string for `n <= 0`). A
|
|
70
|
+
* renderer uses it to repaint a live block in place — move up over the previous draw,
|
|
71
|
+
* then rewrite each row — so progress updates a fixed region instead of scrolling lines.
|
|
72
|
+
*
|
|
73
|
+
* @param n - Number of lines to move the cursor up.
|
|
74
|
+
* @returns The cursor-up escape sequence, or `""` when `n <= 0`.
|
|
75
|
+
* @example
|
|
76
|
+
* cursorUp(3); // "\x1b[3A"
|
|
77
|
+
*/
|
|
78
|
+
function cursorUp(n) {
|
|
79
|
+
return n > 0 ? `${ESC}[${n}A` : "";
|
|
80
|
+
}
|
|
81
|
+
/** Unicode rounded box glyphs used when output is a color-capable TTY. */
|
|
82
|
+
const UNICODE_BOX = {
|
|
83
|
+
topLeft: "╭",
|
|
84
|
+
topRight: "╮",
|
|
85
|
+
bottomLeft: "╰",
|
|
86
|
+
bottomRight: "╯",
|
|
87
|
+
horizontal: "─",
|
|
88
|
+
vertical: "│"
|
|
89
|
+
};
|
|
90
|
+
/** ASCII box glyphs used when output is piped/CI (plain mode). */
|
|
91
|
+
const ASCII_BOX = {
|
|
92
|
+
topLeft: "+",
|
|
93
|
+
topRight: "+",
|
|
94
|
+
bottomLeft: "+",
|
|
95
|
+
bottomRight: "+",
|
|
96
|
+
horizontal: "-",
|
|
97
|
+
vertical: "|"
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Matches every ANSI SGR escape sequence (used to measure visible width). Built from
|
|
101
|
+
* the {@link ESC} byte so no literal control character appears in the source regex.
|
|
102
|
+
*/
|
|
103
|
+
const ANSI_PATTERN = new RegExp(String.raw`${ESC}\[[0-9;]*m`, "g");
|
|
104
|
+
/**
|
|
105
|
+
* Whether ANSI color/box glyphs should be emitted: a TTY stream with `NO_COLOR`
|
|
106
|
+
* unset. Reads `process.stdout.isTTY` and `process.env.NO_COLOR` by default so the
|
|
107
|
+
* renderer auto-degrades in CI and pipes.
|
|
108
|
+
*
|
|
109
|
+
* @param stream - Stream to probe for `isTTY` (defaults to `process.stdout`).
|
|
110
|
+
* @param noColor - The `NO_COLOR` value (defaults to `process.env.NO_COLOR`).
|
|
111
|
+
* @returns `true` when color should be used.
|
|
112
|
+
* @example
|
|
113
|
+
* supportsColor(); // true in an interactive terminal
|
|
114
|
+
*/
|
|
115
|
+
function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR) {
|
|
116
|
+
return stream.isTTY === true && noColor === void 0;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether the terminal advertises 24-bit (truecolor) support via `COLORTERM`, so the
|
|
120
|
+
* renderer may emit the exact brand pink ({@link BRAND_PINK}) instead of the 16-color
|
|
121
|
+
* `magenta` approximation. Always layered on top of {@link supportsColor} — truecolor
|
|
122
|
+
* is never used when color itself is disabled.
|
|
123
|
+
*
|
|
124
|
+
* @param colorTerm - The `COLORTERM` value (defaults to `process.env.COLORTERM`).
|
|
125
|
+
* @returns `true` when `COLORTERM` is `truecolor` or `24bit`.
|
|
126
|
+
* @example
|
|
127
|
+
* supportsTruecolor("truecolor"); // true
|
|
128
|
+
*/
|
|
129
|
+
function supportsTruecolor(colorTerm = process.env.COLORTERM) {
|
|
130
|
+
return colorTerm === "truecolor" || colorTerm === "24bit";
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The braille spinner glyph for a given elapsed time, advancing one frame per
|
|
134
|
+
* `frameMs`. Deriving the frame from wall-clock elapsed (rather than a tick counter)
|
|
135
|
+
* keeps the spinner correct even when the animation ticker is briefly starved by a
|
|
136
|
+
* synchronous build phase and several ticks coalesce — the glyph still reflects real
|
|
137
|
+
* elapsed time instead of freezing on a stale frame.
|
|
138
|
+
*
|
|
139
|
+
* @param elapsedMs - Milliseconds since the live region opened.
|
|
140
|
+
* @param frameMs - Milliseconds per frame (defaults to `80`).
|
|
141
|
+
* @returns The active spinner glyph.
|
|
142
|
+
* @example
|
|
143
|
+
* spinnerFrameAt(240); // "⠹" (the 4th frame at 80ms/frame)
|
|
144
|
+
*/
|
|
145
|
+
function spinnerFrameAt(elapsedMs, frameMs = 80) {
|
|
146
|
+
return SPINNER_FRAMES[Math.floor(Math.max(0, elapsedMs) / frameMs) % SPINNER_FRAMES.length] ?? "⠋";
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
|
|
150
|
+
*
|
|
151
|
+
* @param color - Whether color/Unicode output is enabled.
|
|
152
|
+
* @returns The matching {@link BoxGlyphs} set.
|
|
153
|
+
* @example
|
|
154
|
+
* const glyphs = boxGlyphs(supportsColor());
|
|
155
|
+
*/
|
|
156
|
+
function boxGlyphs(color) {
|
|
157
|
+
return color ? UNICODE_BOX : ASCII_BOX;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The visible width of a string, ignoring any ANSI escape sequences it contains.
|
|
161
|
+
*
|
|
162
|
+
* @param text - The (possibly colorized) text to measure.
|
|
163
|
+
* @returns The number of visible characters.
|
|
164
|
+
* @example
|
|
165
|
+
* visibleWidth(`${ANSI.red}hi${ANSI.reset}`); // 2
|
|
166
|
+
*/
|
|
167
|
+
function visibleWidth(text) {
|
|
168
|
+
return text.replaceAll(ANSI_PATTERN, "").length;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Build a {@link Palette} bound to a fixed color mode. When `color` is `false` every
|
|
172
|
+
* helper returns its input unchanged, so the same render code path produces plain
|
|
173
|
+
* output in CI/pipes.
|
|
174
|
+
*
|
|
175
|
+
* @param color - Whether color is enabled (typically `supportsColor()`).
|
|
176
|
+
* @param truecolor - Whether 24-bit output is enabled (typically `supportsTruecolor()`);
|
|
177
|
+
* only consulted by {@link Palette.pink}. Defaults to `false` (16-color magenta).
|
|
178
|
+
* @returns The bound color palette.
|
|
179
|
+
* @example
|
|
180
|
+
* const palette = makePalette(supportsColor(), supportsTruecolor());
|
|
181
|
+
* const line = palette.green("done");
|
|
182
|
+
*/
|
|
183
|
+
function makePalette(color, truecolor = false) {
|
|
184
|
+
return {
|
|
185
|
+
enabled: color,
|
|
186
|
+
/**
|
|
187
|
+
* Wrap text in the given ANSI code (returns it unchanged when color is off).
|
|
188
|
+
*
|
|
189
|
+
* @param code - The ANSI SGR code to apply.
|
|
190
|
+
* @param text - The text to colorize.
|
|
191
|
+
* @returns The colorized (or unchanged) text.
|
|
192
|
+
* @example
|
|
193
|
+
* palette.paint(ANSI.green, "ok");
|
|
194
|
+
*/
|
|
195
|
+
paint(code, text) {
|
|
196
|
+
return color ? `${code}${text}${ANSI.reset}` : text;
|
|
197
|
+
},
|
|
198
|
+
/**
|
|
199
|
+
* Bold the given text (no-op in plain mode).
|
|
200
|
+
*
|
|
201
|
+
* @param text - The text to embolden.
|
|
202
|
+
* @returns The bold (or unchanged) text.
|
|
203
|
+
* @example
|
|
204
|
+
* palette.bold("title");
|
|
205
|
+
*/
|
|
206
|
+
bold(text) {
|
|
207
|
+
return this.paint(ANSI.bold, text);
|
|
208
|
+
},
|
|
209
|
+
/**
|
|
210
|
+
* Dim the given text (no-op in plain mode).
|
|
211
|
+
*
|
|
212
|
+
* @param text - The text to dim.
|
|
213
|
+
* @returns The dim (or unchanged) text.
|
|
214
|
+
* @example
|
|
215
|
+
* palette.dim("· 84ms");
|
|
216
|
+
*/
|
|
217
|
+
dim(text) {
|
|
218
|
+
return this.paint(ANSI.dim, text);
|
|
219
|
+
},
|
|
220
|
+
/**
|
|
221
|
+
* Color the given text green (no-op in plain mode).
|
|
222
|
+
*
|
|
223
|
+
* @param text - The text to colorize.
|
|
224
|
+
* @returns The green (or unchanged) text.
|
|
225
|
+
* @example
|
|
226
|
+
* palette.green("✓");
|
|
227
|
+
*/
|
|
228
|
+
green(text) {
|
|
229
|
+
return this.paint(ANSI.green, text);
|
|
230
|
+
},
|
|
231
|
+
/**
|
|
232
|
+
* Color the given text yellow (no-op in plain mode).
|
|
233
|
+
*
|
|
234
|
+
* @param text - The text to colorize.
|
|
235
|
+
* @returns The yellow (or unchanged) text.
|
|
236
|
+
* @example
|
|
237
|
+
* palette.yellow("~");
|
|
238
|
+
*/
|
|
239
|
+
yellow(text) {
|
|
240
|
+
return this.paint(ANSI.yellow, text);
|
|
241
|
+
},
|
|
242
|
+
/**
|
|
243
|
+
* Color the given text red (no-op in plain mode).
|
|
244
|
+
*
|
|
245
|
+
* @param text - The text to colorize.
|
|
246
|
+
* @returns The red (or unchanged) text.
|
|
247
|
+
* @example
|
|
248
|
+
* palette.red("✗");
|
|
249
|
+
*/
|
|
250
|
+
red(text) {
|
|
251
|
+
return this.paint(ANSI.red, text);
|
|
252
|
+
},
|
|
253
|
+
/**
|
|
254
|
+
* Color the given text cyan (no-op in plain mode).
|
|
255
|
+
*
|
|
256
|
+
* @param text - The text to colorize.
|
|
257
|
+
* @returns The cyan (or unchanged) text.
|
|
258
|
+
* @example
|
|
259
|
+
* palette.cyan("http://localhost:4173");
|
|
260
|
+
*/
|
|
261
|
+
cyan(text) {
|
|
262
|
+
return this.paint(ANSI.cyan, text);
|
|
263
|
+
},
|
|
264
|
+
/**
|
|
265
|
+
* Color the given text the Moku brand pink: exact `#FF1E6F` (24-bit) when truecolor
|
|
266
|
+
* is enabled, the 16-color `magenta` approximation otherwise, unchanged in plain mode.
|
|
267
|
+
*
|
|
268
|
+
* @param text - The text to colorize.
|
|
269
|
+
* @returns The pink (or unchanged) text.
|
|
270
|
+
* @example
|
|
271
|
+
* palette.pink("▟▙ moku web");
|
|
272
|
+
*/
|
|
273
|
+
pink(text) {
|
|
274
|
+
if (!color) return text;
|
|
275
|
+
if (truecolor) return `${fg24(BRAND_PINK.r, BRAND_PINK.g, BRAND_PINK.b)}${text}${ANSI.reset}`;
|
|
276
|
+
return this.paint(ANSI.magenta, text);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Frame a list of already-rendered content lines in a box, padding each line to the
|
|
282
|
+
* widest visible line (or `minInnerWidth`, whichever is larger — so several boxes can be
|
|
283
|
+
* forced to a shared width). Uses Unicode borders when `color` is enabled and ASCII
|
|
284
|
+
* otherwise. Visible width ignores embedded ANSI so colored lines align.
|
|
285
|
+
*
|
|
286
|
+
* @param lines - The content lines (may contain ANSI color codes).
|
|
287
|
+
* @param color - Whether to use Unicode borders (and assume color-capable output).
|
|
288
|
+
* @param minInnerWidth - Minimum inner (content) width to pad every row to. Defaults to `0`.
|
|
289
|
+
* @returns The boxed lines (top border, content rows, bottom border).
|
|
290
|
+
* @example
|
|
291
|
+
* box(["Local: http://localhost:4173"], true, 62);
|
|
292
|
+
*/
|
|
293
|
+
function box(lines, color, minInnerWidth = 0) {
|
|
294
|
+
const glyphs = boxGlyphs(color);
|
|
295
|
+
const inner = Math.max(0, minInnerWidth, ...lines.map((line) => visibleWidth(line)));
|
|
296
|
+
const horizontal = glyphs.horizontal.repeat(inner + 2);
|
|
297
|
+
const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
|
|
298
|
+
const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
|
|
299
|
+
return [
|
|
300
|
+
top,
|
|
301
|
+
...lines.map((line) => {
|
|
302
|
+
const pad = " ".repeat(inner - visibleWidth(line));
|
|
303
|
+
return `${glyphs.vertical} ${line}${pad} ${glyphs.vertical}`;
|
|
304
|
+
}),
|
|
305
|
+
bottom
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
//#endregion
|
|
309
|
+
//#region src/cli/console.ts
|
|
310
|
+
/**
|
|
311
|
+
* @file `@moku-labs/common/cli` — the branded console: the shared, **stateless** line
|
|
312
|
+
* vocabulary every Moku CLI prints through so the look never drifts between projects.
|
|
313
|
+
* It is the generic counterpart to a framework's own (stateful) panels: the `▟▙` lockup
|
|
314
|
+
* banner, section `heading`s, `info`/`warn`/`error` lines, `✓/✗ check` rows, plus the
|
|
315
|
+
* `railLine`/`box` builders a project composes its own panels from. Built entirely on the
|
|
316
|
+
* {@link makePalette} primitives, TTY/`NO_COLOR`-aware, every line routed through an
|
|
317
|
+
* injectable sink so tests capture output (and a non-CLI consumer can redirect it).
|
|
318
|
+
*/
|
|
319
|
+
/** Default total visible width the lockup rule spans and `railLine` right-aligns to. */
|
|
320
|
+
const DEFAULT_WIDTH$1 = 66;
|
|
321
|
+
/**
|
|
322
|
+
* Create a {@link BrandConsole}. Output flows through the injected sink (default
|
|
323
|
+
* `console.log`/`console.error`) and is colorized only when color is enabled, so the
|
|
324
|
+
* identical render path yields branded color/Unicode on a TTY and plain ASCII in CI/pipes.
|
|
325
|
+
*
|
|
326
|
+
* @param options - Optional sinks, color/truecolor overrides, and width (see
|
|
327
|
+
* {@link BrandConsoleOptions}).
|
|
328
|
+
* @returns The branded console.
|
|
329
|
+
* @example
|
|
330
|
+
* const ui = createBrandConsole();
|
|
331
|
+
* ui.lockup({ wordmark: "moku tool", version: "v1.0.0" });
|
|
332
|
+
* ui.check(true, "config loaded");
|
|
333
|
+
*/
|
|
334
|
+
function createBrandConsole(options = {}) {
|
|
335
|
+
const write = options.write ?? ((line) => console.log(line));
|
|
336
|
+
const writeError = options.writeError ?? ((line) => console.error(line));
|
|
337
|
+
const color = options.color ?? supportsColor();
|
|
338
|
+
const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
|
|
339
|
+
const width = options.width ?? DEFAULT_WIDTH$1;
|
|
340
|
+
const cube = color ? "▟▙" : "*";
|
|
341
|
+
const rule = color ? "─" : "-";
|
|
342
|
+
/**
|
|
343
|
+
* Right-align `right` against `left` within `lineWidth`, measuring visible width so
|
|
344
|
+
* embedded ANSI never throws the alignment off.
|
|
345
|
+
*
|
|
346
|
+
* @param left - The left segment (may contain ANSI).
|
|
347
|
+
* @param right - The right segment (may contain ANSI).
|
|
348
|
+
* @param lineWidth - Total visible width to fill (defaults to the console width).
|
|
349
|
+
* @returns The padded line.
|
|
350
|
+
* @example
|
|
351
|
+
* railLine("left", "right", 20);
|
|
352
|
+
*/
|
|
353
|
+
const railLine = (left, right, lineWidth = width) => {
|
|
354
|
+
const gap = Math.max(1, lineWidth - visibleWidth(left) - visibleWidth(right));
|
|
355
|
+
return `${left}${" ".repeat(gap)}${right}`;
|
|
356
|
+
};
|
|
357
|
+
return {
|
|
358
|
+
palette,
|
|
359
|
+
color,
|
|
360
|
+
width,
|
|
361
|
+
/**
|
|
362
|
+
* Write a pre-rendered line verbatim through the stdout sink.
|
|
363
|
+
*
|
|
364
|
+
* @param text - The line to write (defaults to an empty line).
|
|
365
|
+
* @example
|
|
366
|
+
* ui.line(" custom row");
|
|
367
|
+
*/
|
|
368
|
+
line(text = "") {
|
|
369
|
+
write(text);
|
|
370
|
+
},
|
|
371
|
+
/**
|
|
372
|
+
* Render the `▟▙ <wordmark>` lockup (cube + bold-pink wordmark + optional label,
|
|
373
|
+
* version right-aligned), a dim hairline rule, and an optional dim facts line.
|
|
374
|
+
*
|
|
375
|
+
* @param opts - The lockup fields (see {@link LockupOptions}).
|
|
376
|
+
* @example
|
|
377
|
+
* ui.lockup({ wordmark: "moku web", label: "build", version: "v1.2.0" });
|
|
378
|
+
*/
|
|
379
|
+
lockup(opts) {
|
|
380
|
+
const wordmark = palette.pink(palette.bold(opts.wordmark));
|
|
381
|
+
const label = opts.label ? ` ${palette.dim(opts.label)}` : "";
|
|
382
|
+
write(railLine(` ${palette.pink(cube)} ${wordmark}${label}`, opts.version ? palette.dim(opts.version) : ""));
|
|
383
|
+
write(` ${palette.dim(rule.repeat(width - 1))}`);
|
|
384
|
+
if (opts.facts !== void 0) write(` ${palette.dim(opts.facts)}`);
|
|
385
|
+
},
|
|
386
|
+
/**
|
|
387
|
+
* Render a section heading: a blank line followed by a bold brand-pink label.
|
|
388
|
+
*
|
|
389
|
+
* @param text - The heading label.
|
|
390
|
+
* @example
|
|
391
|
+
* ui.heading("Diagnostics");
|
|
392
|
+
*/
|
|
393
|
+
heading(text) {
|
|
394
|
+
write("");
|
|
395
|
+
write(` ${palette.bold(palette.pink(text))}`);
|
|
396
|
+
},
|
|
397
|
+
/**
|
|
398
|
+
* Render a neutral informational line (`› message`), indenting continuation lines.
|
|
399
|
+
*
|
|
400
|
+
* @param message - The line to print.
|
|
401
|
+
* @example
|
|
402
|
+
* ui.info("watching for changes…");
|
|
403
|
+
*/
|
|
404
|
+
info(message) {
|
|
405
|
+
const [first = "", ...rest] = message.split("\n");
|
|
406
|
+
write(` ${palette.cyan("›")} ${first}`);
|
|
407
|
+
for (const lineText of rest) write(` ${lineText}`);
|
|
408
|
+
},
|
|
409
|
+
/**
|
|
410
|
+
* Render a warning line (`⚠ message`, to stderr).
|
|
411
|
+
*
|
|
412
|
+
* @param message - The warning to print.
|
|
413
|
+
* @example
|
|
414
|
+
* ui.warn("deploy skipped");
|
|
415
|
+
*/
|
|
416
|
+
warn(message) {
|
|
417
|
+
writeError(` ${palette.yellow("⚠")} ${message}`);
|
|
418
|
+
},
|
|
419
|
+
/**
|
|
420
|
+
* Render an error line (`✗ message`, to stderr), optionally with a cause beneath.
|
|
421
|
+
*
|
|
422
|
+
* @param message - The error summary to print.
|
|
423
|
+
* @param cause - Optional underlying error/value printed beneath the summary.
|
|
424
|
+
* @example
|
|
425
|
+
* ui.error("build failed", err);
|
|
426
|
+
*/
|
|
427
|
+
error(message, cause) {
|
|
428
|
+
writeError(` ${palette.red("✗")} ${message}`);
|
|
429
|
+
if (cause !== void 0) writeError(String(cause));
|
|
430
|
+
},
|
|
431
|
+
/**
|
|
432
|
+
* Render a diagnostic line — green `✓` / red `✗` + label, with optional dim,
|
|
433
|
+
* indented detail beneath.
|
|
434
|
+
*
|
|
435
|
+
* @param ok - Whether the check passed.
|
|
436
|
+
* @param label - The check label.
|
|
437
|
+
* @param detail - Optional multi-line guidance shown indented under the line.
|
|
438
|
+
* @example
|
|
439
|
+
* ui.check(true, "config loaded");
|
|
440
|
+
*/
|
|
441
|
+
check(ok, label, detail) {
|
|
442
|
+
write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
|
|
443
|
+
if (detail !== void 0) for (const lineText of detail.split("\n")) write(` ${palette.dim(lineText)}`);
|
|
444
|
+
},
|
|
445
|
+
railLine,
|
|
446
|
+
/**
|
|
447
|
+
* Frame the given content lines in a brand box and write the result.
|
|
448
|
+
*
|
|
449
|
+
* @param lines - The content lines (may contain ANSI).
|
|
450
|
+
* @param minInnerWidth - Minimum inner width to pad every row to. Defaults to `0`.
|
|
451
|
+
* @example
|
|
452
|
+
* ui.box(["Local http://localhost:4173"]);
|
|
453
|
+
*/
|
|
454
|
+
box(lines, minInnerWidth = 0) {
|
|
455
|
+
for (const lineText of box(lines, color, minInnerWidth)) write(lineText);
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region src/cli/log-sink.ts
|
|
461
|
+
/** Severity rank for threshold comparison (higher = more severe). Mirrors the log plugin. */
|
|
462
|
+
const LEVEL_RANK = {
|
|
463
|
+
debug: 10,
|
|
464
|
+
info: 20,
|
|
465
|
+
warn: 30,
|
|
466
|
+
error: 40
|
|
467
|
+
};
|
|
468
|
+
/**
|
|
469
|
+
* Render an entry's optional structured `data` as a compact, dim suffix. Falls back to
|
|
470
|
+
* `String(data)` when it is not JSON-serializable (e.g. a circular object).
|
|
471
|
+
*
|
|
472
|
+
* @param ui - The brand console (for its palette).
|
|
473
|
+
* @param data - The entry's structured payload.
|
|
474
|
+
* @returns A leading-space dim suffix, or `""` when there is no data.
|
|
475
|
+
* @example
|
|
476
|
+
* formatData(ui, { count: 12 }); // ' {"count":12}' (dim)
|
|
477
|
+
*/
|
|
478
|
+
function formatData(ui, data) {
|
|
479
|
+
if (data === void 0) return "";
|
|
480
|
+
let text;
|
|
481
|
+
try {
|
|
482
|
+
text = JSON.stringify(data);
|
|
483
|
+
} catch {
|
|
484
|
+
text = String(data);
|
|
485
|
+
}
|
|
486
|
+
return text === void 0 ? "" : ` ${ui.palette.dim(text)}`;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Build a branded log {@link LogSink}: routes each entry to the matching brand line —
|
|
490
|
+
* `error` → `✗` (stderr), `warn` → `⚠` (stderr), `info` → `›`, `debug` → dim — with any
|
|
491
|
+
* structured `data` appended dim. Entries below `minLevel` are dropped (the in-memory
|
|
492
|
+
* trace still records everything). TTY/`NO_COLOR`-aware via the brand console.
|
|
493
|
+
*
|
|
494
|
+
* @param minLevel - Lowest severity to print. Defaults to `"debug"` (print all).
|
|
495
|
+
* @returns A {@link LogSink} that writes branded lines to stdout/stderr.
|
|
496
|
+
* @example
|
|
497
|
+
* ctx.log.clearSinks();
|
|
498
|
+
* ctx.log.addSink(brandedSink("info")); // suppress debug spam, branded output
|
|
499
|
+
*/
|
|
500
|
+
function brandedSink(minLevel = "debug") {
|
|
501
|
+
const threshold = LEVEL_RANK[minLevel];
|
|
502
|
+
const ui = createBrandConsole();
|
|
503
|
+
return {
|
|
504
|
+
/**
|
|
505
|
+
* Render one entry as a branded line matching its level (dropping entries below
|
|
506
|
+
* the threshold).
|
|
507
|
+
*
|
|
508
|
+
* @param entry - The entry to emit.
|
|
509
|
+
* @example
|
|
510
|
+
* sink.write({ level: "info", event: "deploy:done", ts: 0 });
|
|
511
|
+
*/
|
|
512
|
+
write(entry) {
|
|
513
|
+
if (LEVEL_RANK[entry.level] < threshold) return;
|
|
514
|
+
const message = `${entry.event}${formatData(ui, entry.data)}`;
|
|
515
|
+
switch (entry.level) {
|
|
516
|
+
case "error":
|
|
517
|
+
ui.error(message);
|
|
518
|
+
break;
|
|
519
|
+
case "warn":
|
|
520
|
+
ui.warn(message);
|
|
521
|
+
break;
|
|
522
|
+
case "debug":
|
|
523
|
+
ui.line(` ${ui.palette.dim(message)}`);
|
|
524
|
+
break;
|
|
525
|
+
default: ui.info(message);
|
|
526
|
+
}
|
|
527
|
+
} };
|
|
528
|
+
}
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/cli/prompts.ts
|
|
531
|
+
/**
|
|
532
|
+
* @file `@moku-labs/common/cli` — branded interactive prompts (`confirm` y/N and
|
|
533
|
+
* `select` one-of-N) styled with the brand `◆` marker, the dim hint, and the cyan `›`
|
|
534
|
+
* caret, so a guided flow in any Moku CLI looks the same. Built on `node:readline`; the
|
|
535
|
+
* input/output streams and the choices-block sink are injectable so tests drive prompts
|
|
536
|
+
* without a real TTY. Off a color TTY (CI/pipes) every prompt degrades to a plain form.
|
|
537
|
+
*/
|
|
538
|
+
/** Default prompt rail width — matches the brand console so hints align with other rows. */
|
|
539
|
+
const DEFAULT_WIDTH = 66;
|
|
540
|
+
/** Matches an explicit affirmative answer (`y`/`yes`, case-insensitive). */
|
|
541
|
+
const YES_PATTERN = /^y(es)?$/i;
|
|
542
|
+
/**
|
|
543
|
+
* Create {@link BrandPrompts} bound to a color mode + streams. Styling matches the brand
|
|
544
|
+
* console (the `◆` marker, dim hints, cyan caret); off a color TTY every prompt uses the
|
|
545
|
+
* plain `question [y/N]` / `question [1-N]` form.
|
|
546
|
+
*
|
|
547
|
+
* @param options - Optional color/width overrides and injectable streams/sink (see
|
|
548
|
+
* {@link BrandPromptsOptions}).
|
|
549
|
+
* @returns The branded prompts.
|
|
550
|
+
* @example
|
|
551
|
+
* const prompts = createBrandPrompts();
|
|
552
|
+
* const i = await prompts.select("Workflow?", ["Auto", "Manual"]);
|
|
553
|
+
*/
|
|
554
|
+
function createBrandPrompts(options = {}) {
|
|
555
|
+
const color = options.color ?? supportsColor();
|
|
556
|
+
const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
|
|
557
|
+
const width = options.width ?? DEFAULT_WIDTH;
|
|
558
|
+
const input = options.input ?? process.stdin;
|
|
559
|
+
const output = options.output ?? process.stdout;
|
|
560
|
+
const write = options.write ?? ((block) => console.log(block));
|
|
561
|
+
/**
|
|
562
|
+
* Build the y/N prompt string: the styled `◆ question … y / N ›` rail on a color TTY,
|
|
563
|
+
* else the plain `question [y/N] ` form.
|
|
564
|
+
*
|
|
565
|
+
* @param question - The yes/no question to display.
|
|
566
|
+
* @returns The readline prompt string.
|
|
567
|
+
* @example
|
|
568
|
+
* confirmPrompt("Deploy?");
|
|
569
|
+
*/
|
|
570
|
+
const confirmPrompt = (question) => {
|
|
571
|
+
if (!color) return `${question} [y/N] `;
|
|
572
|
+
const left = ` ${palette.pink("◆")} ${question}`;
|
|
573
|
+
const right = `${palette.dim("y / N")} ${palette.cyan("›")} `;
|
|
574
|
+
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
|
575
|
+
return `${left}${" ".repeat(gap)}${right}`;
|
|
576
|
+
};
|
|
577
|
+
/**
|
|
578
|
+
* Build the select choices block: the styled `◆ question` head + dim-numbered rows on a
|
|
579
|
+
* color TTY, else the plain ` N) label` list.
|
|
580
|
+
*
|
|
581
|
+
* @param question - The prompt shown above the choices (styled mode only).
|
|
582
|
+
* @param choices - The selectable option labels.
|
|
583
|
+
* @returns The multi-line choices block.
|
|
584
|
+
* @example
|
|
585
|
+
* choicesBlock("Pick", ["a", "b"]);
|
|
586
|
+
*/
|
|
587
|
+
const choicesBlock = (question, choices) => {
|
|
588
|
+
if (!color) return choices.map((choice, index) => ` ${index + 1}) ${choice}`).join("\n");
|
|
589
|
+
return [` ${palette.pink("◆")} ${question}`, ...choices.map((choice, index) => ` ${palette.dim(String(index + 1))} ${choice}`)].join("\n");
|
|
590
|
+
};
|
|
591
|
+
/**
|
|
592
|
+
* Build the select input prompt: the dim `pick 1–N ›` hint on a color TTY, else the
|
|
593
|
+
* plain `question [1-N] ` form.
|
|
594
|
+
*
|
|
595
|
+
* @param question - The prompt (used only by the plain fallback).
|
|
596
|
+
* @param count - The number of choices.
|
|
597
|
+
* @returns The readline prompt string.
|
|
598
|
+
* @example
|
|
599
|
+
* selectPrompt("Pick", 3);
|
|
600
|
+
*/
|
|
601
|
+
const selectPrompt = (question, count) => {
|
|
602
|
+
if (!color) return `${question} [1-${count}] `;
|
|
603
|
+
return ` ${palette.dim(`pick 1–${count}`)} ${palette.cyan("›")} `;
|
|
604
|
+
};
|
|
605
|
+
return {
|
|
606
|
+
/**
|
|
607
|
+
* Ask a yes/no question; resolves `true` only on an explicit `y`/`yes`.
|
|
608
|
+
*
|
|
609
|
+
* @param question - The yes/no question to display.
|
|
610
|
+
* @returns Resolves `true` when the user answered yes.
|
|
611
|
+
* @example
|
|
612
|
+
* await prompts.confirm("Deploy?");
|
|
613
|
+
*/
|
|
614
|
+
confirm(question) {
|
|
615
|
+
return new Promise((resolve) => {
|
|
616
|
+
const readline = createInterface({
|
|
617
|
+
input,
|
|
618
|
+
output
|
|
619
|
+
});
|
|
620
|
+
readline.question(confirmPrompt(question), (answer) => {
|
|
621
|
+
readline.close();
|
|
622
|
+
resolve(YES_PATTERN.test(answer.trim()));
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
},
|
|
626
|
+
/**
|
|
627
|
+
* Present `choices` numbered from 1 and resolve the chosen zero-based index.
|
|
628
|
+
*
|
|
629
|
+
* @param question - The prompt to display.
|
|
630
|
+
* @param choices - The selectable option labels.
|
|
631
|
+
* @returns Resolves the chosen zero-based index (`0` for empty/out-of-range).
|
|
632
|
+
* @example
|
|
633
|
+
* await prompts.select("Pick", ["a", "b"]);
|
|
634
|
+
*/
|
|
635
|
+
select(question, choices) {
|
|
636
|
+
return new Promise((resolve) => {
|
|
637
|
+
const readline = createInterface({
|
|
638
|
+
input,
|
|
639
|
+
output
|
|
640
|
+
});
|
|
641
|
+
write(choicesBlock(question, choices));
|
|
642
|
+
readline.question(selectPrompt(question, choices.length), (answer) => {
|
|
643
|
+
readline.close();
|
|
644
|
+
const picked = Number.parseInt(answer.trim(), 10);
|
|
645
|
+
resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
//#endregion
|
|
652
|
+
export { ANSI, BRAND_PINK, CLEAR_BELOW, CLEAR_LINE, SPINNER_FRAMES, box, boxGlyphs, brandedSink, createBrandConsole, createBrandPrompts, cursorUp, fg24, makePalette, spinnerFrameAt, supportsColor, supportsTruecolor, visibleWidth };
|
package/dist/index.cjs
CHANGED
|
@@ -406,6 +406,19 @@ function createLogApi(ctx) {
|
|
|
406
406
|
*/
|
|
407
407
|
reset() {
|
|
408
408
|
state.entries.length = 0;
|
|
409
|
+
},
|
|
410
|
+
/**
|
|
411
|
+
* Remove all registered output sinks; the in-memory trace (`entries`) is
|
|
412
|
+
* unaffected, so `trace()`/`expect()` keep working.
|
|
413
|
+
*
|
|
414
|
+
* @example
|
|
415
|
+
* ```ts
|
|
416
|
+
* log.clearSinks();
|
|
417
|
+
* log.addSink(brandedSink()); // from @moku-labs/common/cli
|
|
418
|
+
* ```
|
|
419
|
+
*/
|
|
420
|
+
clearSinks() {
|
|
421
|
+
state.sinks.length = 0;
|
|
409
422
|
}
|
|
410
423
|
};
|
|
411
424
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -137,6 +137,12 @@ type LogApi = {
|
|
|
137
137
|
*/
|
|
138
138
|
addSink(sink: LogSink): void; /** Clear all recorded entries while keeping registered sinks. */
|
|
139
139
|
reset(): void;
|
|
140
|
+
/**
|
|
141
|
+
* Remove all registered output sinks. The in-memory trace (`entries`) is
|
|
142
|
+
* unaffected, so `trace()`/`expect()` keep working — used to replace the default
|
|
143
|
+
* console sink (e.g. a CLI plugin swapping in a branded sink from `@moku-labs/common/cli`).
|
|
144
|
+
*/
|
|
145
|
+
clearSinks(): void;
|
|
140
146
|
};
|
|
141
147
|
//#endregion
|
|
142
148
|
//#region src/plugins/log/index.d.ts
|
package/dist/index.d.mts
CHANGED
|
@@ -137,6 +137,12 @@ type LogApi = {
|
|
|
137
137
|
*/
|
|
138
138
|
addSink(sink: LogSink): void; /** Clear all recorded entries while keeping registered sinks. */
|
|
139
139
|
reset(): void;
|
|
140
|
+
/**
|
|
141
|
+
* Remove all registered output sinks. The in-memory trace (`entries`) is
|
|
142
|
+
* unaffected, so `trace()`/`expect()` keep working — used to replace the default
|
|
143
|
+
* console sink (e.g. a CLI plugin swapping in a branded sink from `@moku-labs/common/cli`).
|
|
144
|
+
*/
|
|
145
|
+
clearSinks(): void;
|
|
140
146
|
};
|
|
141
147
|
//#endregion
|
|
142
148
|
//#region src/plugins/log/index.d.ts
|