@mutmutco/installer-face 0.1.0 → 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 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
@@ -192,7 +192,7 @@ face_rule() {
192
192
  face_r=''
193
193
  face_i=0
194
194
  while [ "$face_i" -lt "$1" ]; do
195
- face_r="$face_r\u2500"
195
+ face_r="\${face_r}\u2500"
196
196
  face_i=$((face_i + 1))
197
197
  done
198
198
  printf '%s' "$face_r"
@@ -220,6 +220,29 @@ face_receipt() {
220
220
  face_refusal() {
221
221
  printf '%s %s %s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_ACCENT" '\u2716')" "$1" >&2
222
222
  }
223
+
224
+ # face_status <text> \u2014 transient status, redrawn in place on STDERR (0.2.0). A served script has no
225
+ # timer and no background job worth the complexity, so the frame advances on each CALL: drive it from
226
+ # the work (a byte count, a retry, a phase change) rather than from a clock. Gated on stderr being a
227
+ # TTY, so a piped or logged run receives nothing and stays byte-stable.
228
+ FACE_FRAME=0
229
+ face_status() {
230
+ [ -t 2 ] || return 0
231
+ FACE_FRAME=$(( (FACE_FRAME + 1) % 4 ))
232
+ case "$FACE_FRAME" in
233
+ 0) face_f='\u25D2' ;;
234
+ 1) face_f='\u25D0' ;;
235
+ 2) face_f='\u25D3' ;;
236
+ *) face_f='\u25D1' ;;
237
+ esac
238
+ printf '\\r\\033[2K%s %s %s' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_MUTED" "$face_f")" "$1" >&2
239
+ }
240
+
241
+ # Always call before writing the durable line, or the finished step lands on a half-drawn frame.
242
+ face_status_clear() {
243
+ [ -t 2 ] || return 0
244
+ printf '\\r\\033[2K' >&2
245
+ }
223
246
  # <<< installer-face:end
224
247
  `;
225
248
  }
@@ -315,10 +338,110 @@ function Write-FaceRefusal {
315
338
  param([string] $Message)
316
339
  [Console]::Error.WriteLine((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceAccent ([string] $FaceCross)) + ' ' + $Message)
317
340
  }
341
+
342
+ # Transient status, redrawn in place on STDERR (0.2.0). Frames are emitted BY CODE POINT for the same
343
+ # reason the glyphs are: \`irm\` hands this file to \`iex\` decoded by the console code page, so a
344
+ # literal UTF-8 frame arrives as mojibake on Windows PowerShell 5.1. The frame advances per CALL \u2014
345
+ # drive it from the work, not from a timer.
346
+ $FaceFrames = @([char] 0x25D2, [char] 0x25D0, [char] 0x25D3, [char] 0x25D1)
347
+ $FaceFrame = 0
348
+ function Write-FaceStatus {
349
+ param([string] $Text)
350
+ if ([Console]::IsErrorRedirected) {
351
+ return
352
+ }
353
+ $script:FaceFrame = ($script:FaceFrame + 1) % $FaceFrames.Length
354
+ $line = (Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceMuted ([string] $FaceFrames[$script:FaceFrame])) + ' ' + $Text
355
+ [Console]::Error.Write([string] [char] 13 + ([char] 27) + '[2K' + $line)
356
+ }
357
+
358
+ # Always call before writing the durable line.
359
+ function Clear-FaceStatus {
360
+ if ([Console]::IsErrorRedirected) {
361
+ return
362
+ }
363
+ [Console]::Error.Write([string] [char] 13 + ([char] 27) + '[2K')
364
+ }
318
365
  # <<< installer-face:end
319
366
  `;
320
367
  }
321
368
 
369
+ // src/rows.ts
370
+ var SEPARATOR = "\xB7";
371
+ var subjectOf = (row) => row.aspect ? `${row.surface} ${row.aspect}` : row.surface;
372
+ var groupKey = (row) => `${subjectOf(row)}\0${row.state}\0${row.mark ?? ""}\0${row.note ?? ""}`;
373
+ function renderRows(face, rows) {
374
+ if (rows.length === 0) return [];
375
+ const groups = /* @__PURE__ */ new Map();
376
+ for (const row of rows) {
377
+ const key = groupKey(row);
378
+ const found = groups.get(key);
379
+ if (found) {
380
+ if (row.instance) found.instances.push(row.instance);
381
+ } else {
382
+ groups.set(key, { row, instances: row.instance ? [row.instance] : [] });
383
+ }
384
+ }
385
+ const subjects = [...groups.values()].map(({ row, instances }) => instances.length > 1 ? subjectOf(row) : [subjectOf(row), instances[0]].filter(Boolean).join(` ${SEPARATOR} `));
386
+ const column = Math.min(Math.max(...subjects.map(visibleWidth)), Math.max(12, face.width - 24));
387
+ const lines = [];
388
+ for (const [index, { row, instances }] of [...groups.values()].entries()) {
389
+ const subject = subjects[index];
390
+ const tail = instances.length > 1 ? `${row.state} ${SEPARATOR} ${instances.join(", ")}` : row.state;
391
+ const body = `${clip(subject, column).padEnd(column)} ${row.mark ? `${markGlyph(face, row.mark)} ` : ""}${tail}`;
392
+ lines.push(...face.relay(body).split("\n"));
393
+ if (row.note) {
394
+ for (const part of wrapWords(row.note, Math.max(16, face.width - column - 10))) {
395
+ lines.push(...face.relay(`${" ".repeat(column)} ${part}`).split("\n"));
396
+ }
397
+ }
398
+ }
399
+ return lines;
400
+ }
401
+ function markGlyph(face, kind) {
402
+ const glyph = kind === "fail" ? GLYPH.cross : kind === "note" ? GLYPH.hollow : GLYPH.check;
403
+ return face.paint(kind === "fail" ? face.identity.accent : kind === "note" ? "38;2;150;150;150" : "38;2;31;209;138", glyph);
404
+ }
405
+ function clip(text, width) {
406
+ if (visibleWidth(text) <= width) return text;
407
+ return `\u2026${[...text].slice(-(width - 1)).join("")}`;
408
+ }
409
+
410
+ // src/spinner.ts
411
+ var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
412
+ function createSpinner(face, { animate, stream = process.stderr, intervalMs = 90 }) {
413
+ let timer = null;
414
+ let text = "";
415
+ let frame = 0;
416
+ const clear = () => {
417
+ if (animate) stream.write("\r\x1B[2K");
418
+ };
419
+ const draw = () => {
420
+ if (!animate) return;
421
+ const line = face.step(text, null, "note").split("\n")[0].replace(GLYPH.hollow, FRAMES[frame % FRAMES.length]);
422
+ stream.write(`\r\x1B[2K${line}`);
423
+ frame += 1;
424
+ };
425
+ return {
426
+ start(next) {
427
+ text = next;
428
+ frame = 0;
429
+ draw();
430
+ if (animate && !timer) timer = setInterval(draw, intervalMs).unref();
431
+ },
432
+ say(next) {
433
+ text = next;
434
+ draw();
435
+ },
436
+ stop() {
437
+ if (timer) clearInterval(timer);
438
+ timer = null;
439
+ clear();
440
+ }
441
+ };
442
+ }
443
+ var SPINNER_FRAMES = Object.freeze([...FRAMES]);
444
+
322
445
  // src/conformance.ts
323
446
  var ALLOWED = new Set(Object.values(GLYPH));
324
447
  var FORBIDDEN = /* @__PURE__ */ new Map([
@@ -433,6 +556,8 @@ function assertScriptConformance(script, options) {
433
556
  if (/\btr\s+'\s'\s+'\u2500'/u.test(text)) {
434
557
  fail("served script", "a rule cannot be built with tr: it is byte-oriented and substitutes one byte of the three-byte character");
435
558
  }
559
+ const unbraced = text.match(/\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]/u);
560
+ 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
561
  }
437
562
  if (!/NO_COLOR/u.test(text)) fail("served script", "NO_COLOR must disable colour");
438
563
  }
@@ -440,13 +565,16 @@ export {
440
565
  GLYPH,
441
566
  PALETTE,
442
567
  PRODUCTS,
568
+ SPINNER_FRAMES,
443
569
  TITLE_COLUMN,
444
570
  assertFaceConformance,
445
571
  assertScriptConformance,
446
572
  createFace,
573
+ createSpinner,
447
574
  faceWidth,
448
575
  identityFor,
449
576
  renderPowerShellFace,
577
+ renderRows,
450
578
  renderShellFace,
451
579
  stripColor,
452
580
  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.0",
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",