@mutmutco/installer-face 0.2.0 → 0.2.3

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
@@ -36,6 +36,23 @@ console.log(face.signOff());
36
36
  Colour is a DECISION the caller passes in, never sniffed here: the same face is used by processes
37
37
  whose stdout is a pipe, and a face drawn into a pipe is the bug this package exists to prevent.
38
38
 
39
+ ### Self re-exec continuation
40
+
41
+ A process that re-execs itself onto an inherited console sets `MM_FACE_CONTINUES` to the
42
+ comma-separated phase names already drawn. `createFace` reads it directly: `welcome()` returns no
43
+ lines, and each `face.continues(phase, kind)` consumes one inherited phase. A failure always returns
44
+ false and consumes the inherited phase, so neither it nor a later retry can be hidden.
45
+
46
+ ```ts
47
+ if (!face.continues('preflight', result.ok ? 'ok' : 'fail')) {
48
+ console.log(face.step('preflight', result.seconds, result.ok ? 'ok' : 'fail'));
49
+ }
50
+ ```
51
+
52
+ The optional `env` input exists for tests; normal callers use `process.env`. Missing or empty means a
53
+ fresh face. Only an inheriting spawn sets the variable — never a person starting a wrapped command
54
+ on a fresh console.
55
+
39
56
  ### Served one-liners
40
57
 
41
58
  A `curl … | sh` or `irm … | iex` script cannot import npm, so the package emits the block instead:
package/dist/face.d.ts CHANGED
@@ -41,8 +41,10 @@ export type StepKind = 'ok' | 'fail' | 'note';
41
41
  export interface Face {
42
42
  readonly identity: ProductIdentity;
43
43
  readonly width: number;
44
- /** `◆ <product> — Mutatis Mutandis` and the product's own warm line. */
44
+ /** `◆ <product> — Mutatis Mutandis` and the product's own warm line; empty on a continuation. */
45
45
  welcome(): string[];
46
+ /** Consume one phase inherited through MM_FACE_CONTINUES; failures are always new information. */
47
+ continues(phase: string, kind?: StepKind): boolean;
46
48
  /** One durable line for one finished step: glyph, title, and a measured time when truthful. */
47
49
  step(title: string, seconds?: number | null, kind?: StepKind): string;
48
50
  /** Another program's output, kept inside the face at the step-title column. */
@@ -60,5 +62,7 @@ export interface FaceOptions {
60
62
  /** False emits no escape codes at all, so a piped or logged run is byte-stable plain text. */
61
63
  color?: boolean;
62
64
  columns?: number;
65
+ /** Injectable only so tests do not mutate process.env; normal callers inherit process.env. */
66
+ env?: Readonly<NodeJS.ProcessEnv>;
63
67
  }
64
- export declare function createFace({ product, color, columns }: FaceOptions): Face;
68
+ export declare function createFace({ product, color, columns, env }: FaceOptions): Face;
package/dist/index.js CHANGED
@@ -85,20 +85,26 @@ function wrapWords(text, width) {
85
85
  if (line) lines.push(line);
86
86
  return lines.length ? lines : [""];
87
87
  }
88
- function createFace({ product, color = false, columns }) {
88
+ function createFace({ product, color = false, columns, env = process.env }) {
89
89
  const identity = identityFor(product);
90
90
  const width = faceWidth(columns);
91
91
  const paint = (sgr, text) => color ? `\x1B[${sgr}m${text}\x1B[0m` : String(text);
92
92
  const bar = () => paint(PALETTE.muted, GLYPH.bar);
93
93
  const indent = " ".repeat(TITLE_COLUMN - 1);
94
- const welcome = () => [
94
+ const continuedPhases = new Set((env.MM_FACE_CONTINUES ?? "").split(",").map((phase) => phase.trim()).filter(Boolean));
95
+ const continuesFace = continuedPhases.size > 0;
96
+ const welcome = () => continuesFace ? [] : [
95
97
  `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, identity.name)} \u2014 Mutatis Mutandis`,
96
98
  bar(),
97
99
  `${bar()} ${identity.warm}`,
98
100
  bar()
99
101
  ];
102
+ const continues = (phase, kind = "ok") => {
103
+ const inherited = continuedPhases.delete(String(phase).trim());
104
+ return kind === "fail" ? false : inherited;
105
+ };
100
106
  const step = (title, seconds = null, kind = "ok") => {
101
- const glyph = kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.hollow) : paint(PALETTE.green, GLYPH.check);
107
+ const glyph = kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.dot) : paint(PALETTE.green, GLYPH.check);
102
108
  const time = seconds === null || seconds === void 0 ? "" : paint(PALETTE.muted, `${Math.max(0, Math.round(seconds))}s`);
103
109
  const column = Math.min(44, Math.max(0, width - 8));
104
110
  const reserved = time ? 6 : 0;
@@ -112,21 +118,21 @@ function createFace({ product, color = false, columns }) {
112
118
  const body = (Array.isArray(lines) ? lines : String(lines).split("\n")).flatMap((raw) => {
113
119
  const line = String(raw);
114
120
  if (visibleWidth(line) <= width - 6) return [line];
115
- const lead = /^\s*/u.exec(line)?.[0] ?? "";
116
- return wrapWords(line, width - 6 - lead.length).map((part) => `${lead}${part}`);
121
+ return wrapWords(line, width - 6);
117
122
  });
118
123
  const content = Math.min(width - 6, Math.max(...body.map(visibleWidth)));
119
124
  const rule = paint(identity.accent, GLYPH.rule.repeat(content + 4));
120
125
  const frame = (left, right) => `${paint(identity.accent, left)}${rule}${paint(identity.accent, right)}`;
126
+ const bodyRow = (line) => line.startsWith(GLYPH.check) ? `${paint(PALETTE.green, GLYPH.check)}${line.slice(1)}` : line;
121
127
  return [
122
128
  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)}`),
129
+ ...body.map((line) => `${paint(identity.accent, GLYPH.bar)} ${bodyRow(line)}${" ".repeat(Math.max(0, content - visibleWidth(line)))} ${paint(identity.accent, GLYPH.bar)}`),
124
130
  frame(GLYPH.boxBottom, GLYPH.boxBottomEnd)
125
131
  ];
126
132
  };
127
133
  const signOff = () => `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, `${identity.name} \xB7 Mutatis Mutandis`)}`;
128
134
  const refusal = (message) => `${bar()} ${paint(identity.accent, GLYPH.cross)} ${message}`;
129
- return { identity, width, welcome, step, relay, receipt, signOff, refusal, paint };
135
+ return { identity, width, welcome, continues, step, relay, receipt, signOff, refusal, paint };
130
136
  }
131
137
 
132
138
  // src/shell.ts
@@ -199,10 +205,15 @@ face_rule() {
199
205
  }
200
206
 
201
207
  # face_receipt <headline> <next command> <location> \u2014 the closing box.
208
+ #
209
+ # The PACKAGE owns the box's internal left edge (#6859): body rows sit at the column right after the
210
+ # frame bar's two spaces \u2014 the SAME column the headline's check occupies \u2014 so the box reads as one
211
+ # block. No body string carries its own indent; the four spaces that used to sit here were the empty
212
+ # glyph column the owner read as stray indentation.
202
213
  face_receipt() {
203
214
  face_text1="$1"
204
- face_text2=" Installed into $3"
205
- face_text3=" Check health any time: $2"
215
+ face_text2="Installed into $3"
216
+ face_text3="Check health any time: $2"
206
217
  face_w1=$(( \${#face_text1} + 3 ))
207
218
  face_w=$face_w1
208
219
  [ \${#face_text2} -gt "$face_w" ] && face_w=\${#face_text2}
@@ -322,8 +333,8 @@ function Write-FaceRelay {
322
333
  function Write-FaceReceipt {
323
334
  param([string] $Location, [string] $Next)
324
335
  $first = [string] $FaceCheck + ' ' + $FaceName + ' is ready.'
325
- $second = ' Installed into ' + $Location
326
- $third = ' Check health any time: ' + $Next
336
+ $second = 'Installed into ' + $Location
337
+ $third = 'Check health any time: ' + $Next
327
338
  $inner = ($first.Length, $second.Length, $third.Length | Measure-Object -Maximum).Maximum
328
339
  $rule = [string]::new([char] 0x2500, $inner + 4)
329
340
  Write-Host (Write-Paint $FaceAccent ([string] [char] 0x256D + $rule + [char] 0x256E))
@@ -391,8 +402,8 @@ function renderRows(face, rows) {
391
402
  const body = `${clip(subject, column).padEnd(column)} ${row.mark ? `${markGlyph(face, row.mark)} ` : ""}${tail}`;
392
403
  lines.push(...face.relay(body).split("\n"));
393
404
  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"));
405
+ for (const part of wrapWords(row.note, Math.max(16, face.width - TITLE_COLUMN))) {
406
+ lines.push(...face.relay(part).split("\n"));
396
407
  }
397
408
  }
398
409
  }
@@ -418,7 +429,7 @@ function createSpinner(face, { animate, stream = process.stderr, intervalMs = 90
418
429
  };
419
430
  const draw = () => {
420
431
  if (!animate) return;
421
- const line = face.step(text, null, "note").split("\n")[0].replace(GLYPH.hollow, FRAMES[frame % FRAMES.length]);
432
+ const line = face.step(text, null, "note").split("\n")[0].replace(GLYPH.dot, FRAMES[frame % FRAMES.length]);
422
433
  stream.write(`\r\x1B[2K${line}`);
423
434
  frame += 1;
424
435
  };
@@ -461,6 +472,9 @@ function assertFaceConformance(lines, options) {
461
472
  const width = options.columns ?? 100;
462
473
  const rendered = lines.flatMap((line) => String(line).split("\n"));
463
474
  const plain = rendered.map(stripColor);
475
+ const head = plain[0] ?? "";
476
+ const signOffLine = `${GLYPH.diamond} ${identity.name} \xB7 Mutatis Mutandis`;
477
+ const opensWithWelcome = head.startsWith(GLYPH.diamond) && head !== signOffLine;
464
478
  if (rendered.length === 0) fail("R7 render before you ship", "no rendered lines were given to the guard");
465
479
  if (options.tty === false) {
466
480
  for (const line of plain) {
@@ -472,6 +486,11 @@ function assertFaceConformance(lines, options) {
472
486
  }
473
487
  return;
474
488
  }
489
+ for (const line of plain) {
490
+ if (line.startsWith(`${GLYPH.bar} ${GLYPH.hollow} `)) {
491
+ fail("note step uses U+25CF", `U+25C7 is the note-box opener in ${JSON.stringify(line)}`);
492
+ }
493
+ }
475
494
  for (const line of plain) {
476
495
  if (/[\u25c6\u25c7\u2714\u2716\u25cf] (?! )/u.test(line)) {
477
496
  fail("R4 two spaces after a glyph", `a single space follows the glyph in ${JSON.stringify(line)}`);
@@ -494,7 +513,7 @@ function assertFaceConformance(lines, options) {
494
513
  if (widths.size !== 1) fail("R5 receipt frame", `the box is ragged: widths ${[...widths].join(", ")}`);
495
514
  }
496
515
  const inReceipt = (index) => top !== -1 && index >= top && index <= plain.findIndex((line, at) => at > top && line.startsWith(GLYPH.boxBottom));
497
- const welcomeEnd = (plain[0] ?? "").startsWith(GLYPH.diamond) ? 4 : 0;
516
+ const welcomeEnd = opensWithWelcome ? 4 : 0;
498
517
  for (const [index, line] of plain.entries()) {
499
518
  if (index < welcomeEnd || inReceipt(index)) continue;
500
519
  if (!line.startsWith(GLYPH.bar) || line.trim() === GLYPH.bar) continue;
@@ -522,8 +541,7 @@ function assertFaceConformance(lines, options) {
522
541
  fail("width cap", `a line is ${visibleWidth(line)} columns wide, cap ${width}: ${JSON.stringify(line)}`);
523
542
  }
524
543
  }
525
- const head = plain[0] ?? "";
526
- if (head.startsWith(GLYPH.diamond)) {
544
+ if (opensWithWelcome) {
527
545
  const expected = `${GLYPH.diamond} ${identity.name} \u2014 Mutatis Mutandis`;
528
546
  if (head !== expected) fail("welcome is the product table's", `expected ${JSON.stringify(expected)}, got ${JSON.stringify(head)}`);
529
547
  const warm = plain.slice(1, 4).find((line) => line.includes(identity.warm));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/installer-face",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
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",