@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 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="$face_r\u2500"
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 = (plain[0] ?? "").startsWith(GLYPH.diamond) ? 4 : 0;
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
- const head = plain[0] ?? "";
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.0",
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",