@mutmutco/installer-face 0.1.0 → 0.2.1
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 +27 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +136 -5
- package/dist/rows.d.ts +22 -0
- package/dist/spinner.d.ts +26 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,6 +51,33 @@ UTF-8 console encoding set before the first glyph, glyphs by code point so Windo
|
|
|
51
51
|
cannot mojibake them, a rule built by repetition rather than `tr` (byte-oriented, emits mojibake),
|
|
52
52
|
and receipt widths measured in columns rather than `${#var}` bytes.
|
|
53
53
|
|
|
54
|
+
### Transient status
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import { createSpinner } from '@mutmutco/installer-face';
|
|
58
|
+
|
|
59
|
+
const spinner = createSpinner(face, { animate: Boolean(process.stderr.isTTY && !process.env.NO_COLOR) });
|
|
60
|
+
spinner.start('Downloading the launcher');
|
|
61
|
+
spinner.say('Downloading the launcher (12 MB)');
|
|
62
|
+
spinner.stop(); // ALWAYS before the durable line
|
|
63
|
+
console.log(face.step('Downloaded the launcher', 12));
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Status goes to stderr, results to stdout, so a relayed or redirected run never receives frames. The
|
|
67
|
+
frame occupies the step line's glyph column and is rendered through `face.step(text, null, 'note')`
|
|
68
|
+
rather than composed, so it inherits `TITLE_COLUMN` and the width cap, and the durable line replaces
|
|
69
|
+
it exactly. Off a TTY it writes nothing at all — no frames, no escape codes, no cleared lines. The
|
|
70
|
+
interval is `unref`'d, so a throw before `stop()` cannot hold a process open.
|
|
71
|
+
|
|
72
|
+
clack cannot provide this: its `spinner().stop()` always prints a line carrying its own glyph, which
|
|
73
|
+
is one of the three defects that made us draw the face ourselves.
|
|
74
|
+
|
|
75
|
+
Served scripts get the same lane from the generated fragments: `face_status` / `face_status_clear`
|
|
76
|
+
in sh (gated on `[ -t 2 ]`), and `Write-FaceStatus` / `Clear-FaceStatus` in PowerShell, with frames
|
|
77
|
+
emitted BY CODE POINT because `irm` hands the file to `iex` decoded by the console code page. Both
|
|
78
|
+
advance the frame per CALL rather than on a timer: drive them from the work — a byte count, a retry,
|
|
79
|
+
a phase change.
|
|
80
|
+
|
|
54
81
|
### The guard
|
|
55
82
|
|
|
56
83
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -3,5 +3,9 @@ export type { Face, FaceOptions, StepKind } from './face.js';
|
|
|
3
3
|
export { PRODUCTS, identityFor } from './products.js';
|
|
4
4
|
export type { ProductIdentity } from './products.js';
|
|
5
5
|
export { renderShellFace, renderPowerShellFace } from './shell.js';
|
|
6
|
+
export { renderRows } from './rows.js';
|
|
7
|
+
export type { Row } from './rows.js';
|
|
8
|
+
export { createSpinner, SPINNER_FRAMES } from './spinner.js';
|
|
9
|
+
export type { Spinner, SpinnerOptions } from './spinner.js';
|
|
6
10
|
export { assertFaceConformance, assertScriptConformance } from './conformance.js';
|
|
7
11
|
export type { ConformanceOptions, ScriptConformanceOptions } from './conformance.js';
|
package/dist/index.js
CHANGED
|
@@ -118,9 +118,10 @@ function createFace({ product, color = false, columns }) {
|
|
|
118
118
|
const content = Math.min(width - 6, Math.max(...body.map(visibleWidth)));
|
|
119
119
|
const rule = paint(identity.accent, GLYPH.rule.repeat(content + 4));
|
|
120
120
|
const frame = (left, right) => `${paint(identity.accent, left)}${rule}${paint(identity.accent, right)}`;
|
|
121
|
+
const bodyRow = (line) => line.startsWith(GLYPH.check) ? `${paint(PALETTE.green, GLYPH.check)}${line.slice(1)}` : line;
|
|
121
122
|
return [
|
|
122
123
|
frame(GLYPH.boxTop, GLYPH.boxTopEnd),
|
|
123
|
-
...body.map((line) => `${paint(identity.accent, GLYPH.bar)} ${line}${" ".repeat(Math.max(0, content - visibleWidth(line)))} ${paint(identity.accent, GLYPH.bar)}`),
|
|
124
|
+
...body.map((line) => `${paint(identity.accent, GLYPH.bar)} ${bodyRow(line)}${" ".repeat(Math.max(0, content - visibleWidth(line)))} ${paint(identity.accent, GLYPH.bar)}`),
|
|
124
125
|
frame(GLYPH.boxBottom, GLYPH.boxBottomEnd)
|
|
125
126
|
];
|
|
126
127
|
};
|
|
@@ -192,7 +193,7 @@ face_rule() {
|
|
|
192
193
|
face_r=''
|
|
193
194
|
face_i=0
|
|
194
195
|
while [ "$face_i" -lt "$1" ]; do
|
|
195
|
-
face_r="
|
|
196
|
+
face_r="\${face_r}\u2500"
|
|
196
197
|
face_i=$((face_i + 1))
|
|
197
198
|
done
|
|
198
199
|
printf '%s' "$face_r"
|
|
@@ -220,6 +221,29 @@ face_receipt() {
|
|
|
220
221
|
face_refusal() {
|
|
221
222
|
printf '%s %s %s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_ACCENT" '\u2716')" "$1" >&2
|
|
222
223
|
}
|
|
224
|
+
|
|
225
|
+
# face_status <text> \u2014 transient status, redrawn in place on STDERR (0.2.0). A served script has no
|
|
226
|
+
# timer and no background job worth the complexity, so the frame advances on each CALL: drive it from
|
|
227
|
+
# the work (a byte count, a retry, a phase change) rather than from a clock. Gated on stderr being a
|
|
228
|
+
# TTY, so a piped or logged run receives nothing and stays byte-stable.
|
|
229
|
+
FACE_FRAME=0
|
|
230
|
+
face_status() {
|
|
231
|
+
[ -t 2 ] || return 0
|
|
232
|
+
FACE_FRAME=$(( (FACE_FRAME + 1) % 4 ))
|
|
233
|
+
case "$FACE_FRAME" in
|
|
234
|
+
0) face_f='\u25D2' ;;
|
|
235
|
+
1) face_f='\u25D0' ;;
|
|
236
|
+
2) face_f='\u25D3' ;;
|
|
237
|
+
*) face_f='\u25D1' ;;
|
|
238
|
+
esac
|
|
239
|
+
printf '\\r\\033[2K%s %s %s' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_MUTED" "$face_f")" "$1" >&2
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# Always call before writing the durable line, or the finished step lands on a half-drawn frame.
|
|
243
|
+
face_status_clear() {
|
|
244
|
+
[ -t 2 ] || return 0
|
|
245
|
+
printf '\\r\\033[2K' >&2
|
|
246
|
+
}
|
|
223
247
|
# <<< installer-face:end
|
|
224
248
|
`;
|
|
225
249
|
}
|
|
@@ -315,10 +339,110 @@ function Write-FaceRefusal {
|
|
|
315
339
|
param([string] $Message)
|
|
316
340
|
[Console]::Error.WriteLine((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceAccent ([string] $FaceCross)) + ' ' + $Message)
|
|
317
341
|
}
|
|
342
|
+
|
|
343
|
+
# Transient status, redrawn in place on STDERR (0.2.0). Frames are emitted BY CODE POINT for the same
|
|
344
|
+
# reason the glyphs are: \`irm\` hands this file to \`iex\` decoded by the console code page, so a
|
|
345
|
+
# literal UTF-8 frame arrives as mojibake on Windows PowerShell 5.1. The frame advances per CALL \u2014
|
|
346
|
+
# drive it from the work, not from a timer.
|
|
347
|
+
$FaceFrames = @([char] 0x25D2, [char] 0x25D0, [char] 0x25D3, [char] 0x25D1)
|
|
348
|
+
$FaceFrame = 0
|
|
349
|
+
function Write-FaceStatus {
|
|
350
|
+
param([string] $Text)
|
|
351
|
+
if ([Console]::IsErrorRedirected) {
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
$script:FaceFrame = ($script:FaceFrame + 1) % $FaceFrames.Length
|
|
355
|
+
$line = (Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceMuted ([string] $FaceFrames[$script:FaceFrame])) + ' ' + $Text
|
|
356
|
+
[Console]::Error.Write([string] [char] 13 + ([char] 27) + '[2K' + $line)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Always call before writing the durable line.
|
|
360
|
+
function Clear-FaceStatus {
|
|
361
|
+
if ([Console]::IsErrorRedirected) {
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
[Console]::Error.Write([string] [char] 13 + ([char] 27) + '[2K')
|
|
365
|
+
}
|
|
318
366
|
# <<< installer-face:end
|
|
319
367
|
`;
|
|
320
368
|
}
|
|
321
369
|
|
|
370
|
+
// src/rows.ts
|
|
371
|
+
var SEPARATOR = "\xB7";
|
|
372
|
+
var subjectOf = (row) => row.aspect ? `${row.surface} ${row.aspect}` : row.surface;
|
|
373
|
+
var groupKey = (row) => `${subjectOf(row)}\0${row.state}\0${row.mark ?? ""}\0${row.note ?? ""}`;
|
|
374
|
+
function renderRows(face, rows) {
|
|
375
|
+
if (rows.length === 0) return [];
|
|
376
|
+
const groups = /* @__PURE__ */ new Map();
|
|
377
|
+
for (const row of rows) {
|
|
378
|
+
const key = groupKey(row);
|
|
379
|
+
const found = groups.get(key);
|
|
380
|
+
if (found) {
|
|
381
|
+
if (row.instance) found.instances.push(row.instance);
|
|
382
|
+
} else {
|
|
383
|
+
groups.set(key, { row, instances: row.instance ? [row.instance] : [] });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const subjects = [...groups.values()].map(({ row, instances }) => instances.length > 1 ? subjectOf(row) : [subjectOf(row), instances[0]].filter(Boolean).join(` ${SEPARATOR} `));
|
|
387
|
+
const column = Math.min(Math.max(...subjects.map(visibleWidth)), Math.max(12, face.width - 24));
|
|
388
|
+
const lines = [];
|
|
389
|
+
for (const [index, { row, instances }] of [...groups.values()].entries()) {
|
|
390
|
+
const subject = subjects[index];
|
|
391
|
+
const tail = instances.length > 1 ? `${row.state} ${SEPARATOR} ${instances.join(", ")}` : row.state;
|
|
392
|
+
const body = `${clip(subject, column).padEnd(column)} ${row.mark ? `${markGlyph(face, row.mark)} ` : ""}${tail}`;
|
|
393
|
+
lines.push(...face.relay(body).split("\n"));
|
|
394
|
+
if (row.note) {
|
|
395
|
+
for (const part of wrapWords(row.note, Math.max(16, face.width - TITLE_COLUMN))) {
|
|
396
|
+
lines.push(...face.relay(part).split("\n"));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return lines;
|
|
401
|
+
}
|
|
402
|
+
function markGlyph(face, kind) {
|
|
403
|
+
const glyph = kind === "fail" ? GLYPH.cross : kind === "note" ? GLYPH.hollow : GLYPH.check;
|
|
404
|
+
return face.paint(kind === "fail" ? face.identity.accent : kind === "note" ? "38;2;150;150;150" : "38;2;31;209;138", glyph);
|
|
405
|
+
}
|
|
406
|
+
function clip(text, width) {
|
|
407
|
+
if (visibleWidth(text) <= width) return text;
|
|
408
|
+
return `\u2026${[...text].slice(-(width - 1)).join("")}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/spinner.ts
|
|
412
|
+
var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
|
|
413
|
+
function createSpinner(face, { animate, stream = process.stderr, intervalMs = 90 }) {
|
|
414
|
+
let timer = null;
|
|
415
|
+
let text = "";
|
|
416
|
+
let frame = 0;
|
|
417
|
+
const clear = () => {
|
|
418
|
+
if (animate) stream.write("\r\x1B[2K");
|
|
419
|
+
};
|
|
420
|
+
const draw = () => {
|
|
421
|
+
if (!animate) return;
|
|
422
|
+
const line = face.step(text, null, "note").split("\n")[0].replace(GLYPH.hollow, FRAMES[frame % FRAMES.length]);
|
|
423
|
+
stream.write(`\r\x1B[2K${line}`);
|
|
424
|
+
frame += 1;
|
|
425
|
+
};
|
|
426
|
+
return {
|
|
427
|
+
start(next) {
|
|
428
|
+
text = next;
|
|
429
|
+
frame = 0;
|
|
430
|
+
draw();
|
|
431
|
+
if (animate && !timer) timer = setInterval(draw, intervalMs).unref();
|
|
432
|
+
},
|
|
433
|
+
say(next) {
|
|
434
|
+
text = next;
|
|
435
|
+
draw();
|
|
436
|
+
},
|
|
437
|
+
stop() {
|
|
438
|
+
if (timer) clearInterval(timer);
|
|
439
|
+
timer = null;
|
|
440
|
+
clear();
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
var SPINNER_FRAMES = Object.freeze([...FRAMES]);
|
|
445
|
+
|
|
322
446
|
// src/conformance.ts
|
|
323
447
|
var ALLOWED = new Set(Object.values(GLYPH));
|
|
324
448
|
var FORBIDDEN = /* @__PURE__ */ new Map([
|
|
@@ -338,6 +462,9 @@ function assertFaceConformance(lines, options) {
|
|
|
338
462
|
const width = options.columns ?? 100;
|
|
339
463
|
const rendered = lines.flatMap((line) => String(line).split("\n"));
|
|
340
464
|
const plain = rendered.map(stripColor);
|
|
465
|
+
const head = plain[0] ?? "";
|
|
466
|
+
const signOffLine = `${GLYPH.diamond} ${identity.name} \xB7 Mutatis Mutandis`;
|
|
467
|
+
const opensWithWelcome = head.startsWith(GLYPH.diamond) && head !== signOffLine;
|
|
341
468
|
if (rendered.length === 0) fail("R7 render before you ship", "no rendered lines were given to the guard");
|
|
342
469
|
if (options.tty === false) {
|
|
343
470
|
for (const line of plain) {
|
|
@@ -371,7 +498,7 @@ function assertFaceConformance(lines, options) {
|
|
|
371
498
|
if (widths.size !== 1) fail("R5 receipt frame", `the box is ragged: widths ${[...widths].join(", ")}`);
|
|
372
499
|
}
|
|
373
500
|
const inReceipt = (index) => top !== -1 && index >= top && index <= plain.findIndex((line, at) => at > top && line.startsWith(GLYPH.boxBottom));
|
|
374
|
-
const welcomeEnd =
|
|
501
|
+
const welcomeEnd = opensWithWelcome ? 4 : 0;
|
|
375
502
|
for (const [index, line] of plain.entries()) {
|
|
376
503
|
if (index < welcomeEnd || inReceipt(index)) continue;
|
|
377
504
|
if (!line.startsWith(GLYPH.bar) || line.trim() === GLYPH.bar) continue;
|
|
@@ -399,8 +526,7 @@ function assertFaceConformance(lines, options) {
|
|
|
399
526
|
fail("width cap", `a line is ${visibleWidth(line)} columns wide, cap ${width}: ${JSON.stringify(line)}`);
|
|
400
527
|
}
|
|
401
528
|
}
|
|
402
|
-
|
|
403
|
-
if (head.startsWith(GLYPH.diamond)) {
|
|
529
|
+
if (opensWithWelcome) {
|
|
404
530
|
const expected = `${GLYPH.diamond} ${identity.name} \u2014 Mutatis Mutandis`;
|
|
405
531
|
if (head !== expected) fail("welcome is the product table's", `expected ${JSON.stringify(expected)}, got ${JSON.stringify(head)}`);
|
|
406
532
|
const warm = plain.slice(1, 4).find((line) => line.includes(identity.warm));
|
|
@@ -433,6 +559,8 @@ function assertScriptConformance(script, options) {
|
|
|
433
559
|
if (/\btr\s+'\s'\s+'\u2500'/u.test(text)) {
|
|
434
560
|
fail("served script", "a rule cannot be built with tr: it is byte-oriented and substitutes one byte of the three-byte character");
|
|
435
561
|
}
|
|
562
|
+
const unbraced = text.match(/\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]/u);
|
|
563
|
+
if (unbraced) fail("served script", `an unbraced $variable abuts a non-ASCII byte and macOS sh reads it as one variable name: ${JSON.stringify(unbraced[0])} \u2014 brace the expansion`);
|
|
436
564
|
}
|
|
437
565
|
if (!/NO_COLOR/u.test(text)) fail("served script", "NO_COLOR must disable colour");
|
|
438
566
|
}
|
|
@@ -440,13 +568,16 @@ export {
|
|
|
440
568
|
GLYPH,
|
|
441
569
|
PALETTE,
|
|
442
570
|
PRODUCTS,
|
|
571
|
+
SPINNER_FRAMES,
|
|
443
572
|
TITLE_COLUMN,
|
|
444
573
|
assertFaceConformance,
|
|
445
574
|
assertScriptConformance,
|
|
446
575
|
createFace,
|
|
576
|
+
createSpinner,
|
|
447
577
|
faceWidth,
|
|
448
578
|
identityFor,
|
|
449
579
|
renderPowerShellFace,
|
|
580
|
+
renderRows,
|
|
450
581
|
renderShellFace,
|
|
451
582
|
stripColor,
|
|
452
583
|
visibleWidth,
|
package/dist/rows.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Face, StepKind } from './face.js';
|
|
2
|
+
export interface Row {
|
|
3
|
+
/** The thing being reported: `Hermes desktop`, `Claude Code`, `jerv-cli`. */
|
|
4
|
+
surface: string;
|
|
5
|
+
/** What about it: `skill`, `MCP`, `panel`. Omitted when the surface is the whole subject. */
|
|
6
|
+
aspect?: string;
|
|
7
|
+
/** Which copy: a Hermes profile, a host account. Omitted for machine-level work. */
|
|
8
|
+
instance?: string;
|
|
9
|
+
/** What happened: `registered`, `written`, `installed`, `2/3 profiles`. */
|
|
10
|
+
state: string;
|
|
11
|
+
/** Optional glyph for a health verdict; omit for a plain convergence row. */
|
|
12
|
+
mark?: StepKind;
|
|
13
|
+
/** A continuation hanging under this row, with no subject of its own. */
|
|
14
|
+
note?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Render a batch of rows as durable lines, aligned in one column the package computes.
|
|
18
|
+
*
|
|
19
|
+
* Batch in, lines out: the column cannot be right for a row rendered alone, which is exactly how
|
|
20
|
+
* every hand-built version drifted.
|
|
21
|
+
*/
|
|
22
|
+
export declare function renderRows(face: Face, rows: readonly Row[]): string[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Face } from './face.js';
|
|
2
|
+
export interface Spinner {
|
|
3
|
+
/** Begin animating against this title. Safe to call again to restart. */
|
|
4
|
+
start(text: string): void;
|
|
5
|
+
/** Change the title without restarting the animation. */
|
|
6
|
+
say(text: string): void;
|
|
7
|
+
/** Stop and CLEAR the line. Always call before writing the durable step line. */
|
|
8
|
+
stop(): void;
|
|
9
|
+
}
|
|
10
|
+
export interface SpinnerOptions {
|
|
11
|
+
/** False writes nothing at all: no frames, no escape codes, no cleared lines. */
|
|
12
|
+
animate: boolean;
|
|
13
|
+
/** Defaults to stderr. Results belong on stdout; status never does. */
|
|
14
|
+
stream?: NodeJS.WritableStream;
|
|
15
|
+
/** Frame interval in ms. */
|
|
16
|
+
intervalMs?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A spinner that occupies the STEP LINE's glyph column, so the durable line that replaces it lands
|
|
20
|
+
* at the same place — one left edge, not two. It renders through `face.step(text, null, 'note')`
|
|
21
|
+
* rather than composing a line of its own, which means it inherits TITLE_COLUMN and cannot drift
|
|
22
|
+
* from it when the face changes.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createSpinner(face: Face, { animate, stream, intervalMs }: SpinnerOptions): Spinner;
|
|
25
|
+
/** The frames, exported so a conformance check can recognise transient output. */
|
|
26
|
+
export declare const SPINNER_FRAMES: readonly string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mutmutco/installer-face",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The MM Terminal Line installer face: one renderer, the canonical product table, shell/PowerShell fragments for served one-liners, and the drift guard every surface runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|