@mutmutco/installer-face 0.2.1 → 0.4.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/dist/index.js CHANGED
@@ -1,24 +1,35 @@
1
+ // src/face.ts
2
+ import { writeSync } from "node:fs";
3
+
1
4
  // src/products.ts
2
5
  var PRODUCTS = Object.freeze({
3
6
  "mm-strategy": Object.freeze({
4
7
  name: "MM Strategy",
8
+ installWarm: "Welcome. Let's set up MM Strategy \u2014 about a minute.",
5
9
  accent: "38;2;249;115;22",
6
- warm: "Welcome. Let's set up MM Strategy \u2014 about a minute."
10
+ warm: "Welcome. Let's set up MM Strategy \u2014 about a minute.",
11
+ doctor: "mm-strategy doctor"
7
12
  }),
8
13
  "mmi-hub": Object.freeze({
9
14
  name: "mmi-hub",
15
+ installWarm: "Welcome. Setting up mmi-hub \u2014 about a minute.",
10
16
  accent: "38;2;125;211;252",
11
- warm: "Welcome back. Checking your surfaces\u2026"
17
+ warm: "Welcome back. Checking your surfaces\u2026",
18
+ doctor: "mmi doctor"
12
19
  }),
13
20
  "jerv-hub": Object.freeze({
14
21
  name: "jerv-hub",
22
+ installWarm: "Welcome. Setting up jerv-hub \u2014 about a minute.",
15
23
  accent: "38;2;248;113;113",
16
- warm: "Welcome back. Checking your surfaces\u2026"
24
+ warm: "Welcome back. Checking your surfaces\u2026",
25
+ doctor: "jerv doctor"
17
26
  }),
18
27
  jervcode: Object.freeze({
19
28
  name: "JervCode",
29
+ installWarm: "Welcome. Setting up JervCode \u2014 about a minute.",
20
30
  accent: "38;2;192;132;252",
21
- warm: "Welcome back. Keeping JervCode current\u2026"
31
+ warm: "Welcome back. Keeping JervCode current\u2026",
32
+ doctor: "jervcode doctor"
22
33
  })
23
34
  });
24
35
  function identityFor(product) {
@@ -85,35 +96,64 @@ function wrapWords(text, width) {
85
96
  if (line) lines.push(line);
86
97
  return lines.length ? lines : [""];
87
98
  }
88
- function createFace({ product, color = false, columns }) {
99
+ var PROGRESS_PROTOCOL = 1;
100
+ function readProgressFd(env) {
101
+ const fd = Number.parseInt(String(env.MM_PROGRESS_FD ?? ""), 10);
102
+ if (!Number.isInteger(fd) || fd <= 0) return null;
103
+ const protocol = Number.parseInt(String(env.MM_PROGRESS_PROTOCOL ?? PROGRESS_PROTOCOL), 10);
104
+ return protocol === PROGRESS_PROTOCOL ? fd : null;
105
+ }
106
+ function createFace({ product, color = false, columns, env = process.env, operation }) {
89
107
  const identity = identityFor(product);
90
108
  const width = faceWidth(columns);
91
109
  const paint = (sgr, text) => color ? `\x1B[${sgr}m${text}\x1B[0m` : String(text);
92
110
  const bar = () => paint(PALETTE.muted, GLYPH.bar);
93
111
  const indent = " ".repeat(TITLE_COLUMN - 1);
94
- const welcome = () => [
112
+ const continuedPhases = new Set((env.MM_FACE_CONTINUES ?? "").split(",").map((phase) => phase.trim()).filter(Boolean));
113
+ const continuesFace = continuedPhases.size > 0;
114
+ const nested = env.MM_OUTER_CONSOLE === "1";
115
+ const progressFd = readProgressFd(env);
116
+ const emitMilestone = (title, measure, kind) => {
117
+ if (progressFd === null) return false;
118
+ const record = { v: PROGRESS_PROTOCOL, step: stripColor(title), state: kind };
119
+ if (typeof measure === "number") record.ms = Math.max(0, Math.round(measure * 1e3));
120
+ try {
121
+ writeSync(progressFd, `${JSON.stringify(record)}
122
+ `);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ };
128
+ const welcome = () => continuesFace || nested ? [] : [
95
129
  `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, identity.name)} \u2014 Mutatis Mutandis`,
96
130
  bar(),
97
- `${bar()} ${identity.warm}`,
131
+ `${bar()} ${operation === "install" ? identity.installWarm : identity.warm}`,
98
132
  bar()
99
133
  ];
100
- 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);
102
- const time = seconds === null || seconds === void 0 ? "" : paint(PALETTE.muted, `${Math.max(0, Math.round(seconds))}s`);
134
+ const continues = (phase, kind = "ok") => {
135
+ const inherited = continuedPhases.delete(String(phase).trim());
136
+ return kind === "fail" ? false : inherited;
137
+ };
138
+ const step = (title, measure = null, kind = "ok") => {
139
+ if (emitMilestone(title, measure, kind)) return "";
140
+ const glyph = kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.dot) : paint(PALETTE.green, GLYPH.check);
141
+ const measured = measure === null || measure === void 0 ? "" : paint(PALETTE.muted, typeof measure === "number" ? `${Math.max(0, Math.round(measure))}s` : String(measure).trim());
142
+ const time = measured;
103
143
  const column = Math.min(44, Math.max(0, width - 8));
104
- const reserved = time ? 6 : 0;
144
+ const reserved = time ? Math.max(6, visibleWidth(time) + 2) : 0;
105
145
  const [first, ...rest] = wrapWords(title, width - TITLE_COLUMN - reserved);
106
146
  const head = `${bar()} ${glyph} ${first}`;
107
147
  const pad = Math.max(1, column - visibleWidth(head));
108
148
  return [time ? `${head}${" ".repeat(pad)}${time}` : head, ...rest.map((line) => `${bar()}${indent}${line}`)].join("\n");
109
149
  };
110
- const relay = (text) => String(text).split("\n").map((line) => line.trim() === "" ? bar() : `${bar()}${indent}${line}`).join("\n");
111
- const receipt = (lines) => {
150
+ const relay = (text) => String(text).split("\n").flatMap((line) => line.trim() === "" ? [bar()] : (visibleWidth(line) <= width - TITLE_COLUMN ? [line] : wrapWords(line, width - TITLE_COLUMN)).map((part) => `${bar()}${indent}${part}`)).join("\n");
151
+ const receipt = (lines, { ready = true } = {}) => {
152
+ if (ready && nested) return [];
112
153
  const body = (Array.isArray(lines) ? lines : String(lines).split("\n")).flatMap((raw) => {
113
154
  const line = String(raw);
114
155
  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}`);
156
+ return wrapWords(line, width - 6);
117
157
  });
118
158
  const content = Math.min(width - 6, Math.max(...body.map(visibleWidth)));
119
159
  const rule = paint(identity.accent, GLYPH.rule.repeat(content + 4));
@@ -125,9 +165,20 @@ function createFace({ product, color = false, columns }) {
125
165
  frame(GLYPH.boxBottom, GLYPH.boxBottomEnd)
126
166
  ];
127
167
  };
128
- const signOff = () => `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, `${identity.name} \xB7 Mutatis Mutandis`)}`;
168
+ const outcome = (changed, options = {}) => {
169
+ const fact = String(changed ?? "").trim();
170
+ if (!fact) {
171
+ throw new Error(`installer face: the receipt must say what changed \u2014 pass the one measured fact this run produced, such as "Updated 2 of 7 surfaces to 4.4.10." (${identity.name})`);
172
+ }
173
+ return receipt([
174
+ `${GLYPH.check} ${identity.name} is ready.`,
175
+ fact,
176
+ `Check health any time: ${identity.doctor}`
177
+ ], options);
178
+ };
179
+ const signOff = () => nested ? "" : `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, `${identity.name} \xB7 Mutatis Mutandis`)}`;
129
180
  const refusal = (message) => `${bar()} ${paint(identity.accent, GLYPH.cross)} ${message}`;
130
- return { identity, width, welcome, step, relay, receipt, signOff, refusal, paint };
181
+ return { identity, width, nested, emitsProgress: progressFd !== null, welcome, continues, step, relay, receipt, outcome, signOff, refusal, paint };
131
182
  }
132
183
 
133
184
  // src/shell.ts
@@ -143,7 +194,7 @@ if [ -t 1 ] && [ -z "\${NO_COLOR:-}" ] && [ "\${TERM:-}" != 'dumb' ]; then
143
194
  fi
144
195
  FACE_NAME='${identity.name}'
145
196
  FACE_ACCENT='${identity.accent}'
146
- FACE_WARM='${identity.warm.replace(/'/gu, "'\\''")}'
197
+ FACE_WARM='${identity.installWarm.replace(/'/gu, "'\\''")}'
147
198
  FACE_MUTED='${PALETTE.muted}'
148
199
  FACE_GREEN='${PALETTE.green}'
149
200
 
@@ -156,11 +207,34 @@ paint() {
156
207
  fi
157
208
  }
158
209
 
210
+ # JSONL capture uses only POSIX awk; a bootstrap cannot require an installed Node runtime.
211
+ face_print() {
212
+ face_channel="$1"
213
+ shift
214
+ face_output=$(printf "$@"; printf '.')
215
+ face_output=\${face_output%.}
216
+ if [ "$face_channel" = stdout ]; then printf '%s' "$face_output"; else printf '%s' "$face_output" >&2; fi
217
+ if [ -n "\${MM_FACE_TRANSCRIPT:-}" ]; then
218
+ MM_FACE_TEXT="$face_output" MM_FACE_CHANNEL="$face_channel" awk '
219
+ BEGIN {
220
+ for (n = 1; n < 32; n++) escapes[sprintf("%c", n)] = sprintf("\\\\u%04x", n)
221
+ escapes["\\\\"] = "\\\\\\\\"; escapes["\\""] = "\\\\\\""
222
+ printf "{\\"channel\\":\\"%s\\",\\"text\\":\\"", ENVIRON["MM_FACE_CHANNEL"]
223
+ text = ENVIRON["MM_FACE_TEXT"]
224
+ for (i = 1; i <= length(text); i++) {
225
+ c = substr(text, i, 1); printf "%s", (c in escapes ? escapes[c] : c)
226
+ }
227
+ print "\\"}"
228
+ }
229
+ ' >> "$MM_FACE_TRANSCRIPT"
230
+ fi
231
+ }
232
+
159
233
  face_welcome() {
160
- printf '%s %s %s\\n' "$(paint "$FACE_ACCENT" '\u25C6')" "$(paint "$FACE_ACCENT" "$FACE_NAME")" '\u2014 Mutatis Mutandis'
161
- printf '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
162
- printf '%s %s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$FACE_WARM"
163
- printf '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
234
+ face_print stdout '%s %s %s\\n' "$(paint "$FACE_ACCENT" '\u25C6')" "$(paint "$FACE_ACCENT" "$FACE_NAME")" '\u2014 Mutatis Mutandis'
235
+ face_print stdout '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
236
+ face_print stdout '%s %s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$FACE_WARM"
237
+ face_print stdout '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
164
238
  }
165
239
 
166
240
  # face_step <title> <start epoch seconds> \u2014 one durable line for a finished step, measured.
@@ -174,7 +248,7 @@ face_step() {
174
248
  else
175
249
  face_pad=$(( 38 - \${#face_title} ))
176
250
  fi
177
- printf '%s %s %s%*s%s\\n' \\
251
+ face_print stdout '%s %s %s%*s%s\\n' \\
178
252
  "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_GREEN" '\u2714')" "$face_title" "$face_pad" '' "$(paint "$FACE_MUTED" "\${face_secs}s")"
179
253
  }
180
254
 
@@ -182,9 +256,9 @@ face_step() {
182
256
  face_relay() {
183
257
  while IFS= read -r face_l; do
184
258
  if [ -n "$face_l" ]; then
185
- printf '%s${RELAY_INDENT}%s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$face_l"
259
+ face_print stdout '%s${RELAY_INDENT}%s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$face_l"
186
260
  else
187
- printf '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
261
+ face_print stdout '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
188
262
  fi
189
263
  done
190
264
  }
@@ -200,26 +274,31 @@ face_rule() {
200
274
  }
201
275
 
202
276
  # face_receipt <headline> <next command> <location> \u2014 the closing box.
277
+ #
278
+ # The PACKAGE owns the box's internal left edge (#6859): body rows sit at the column right after the
279
+ # frame bar's two spaces \u2014 the SAME column the headline's check occupies \u2014 so the box reads as one
280
+ # block. No body string carries its own indent; the four spaces that used to sit here were the empty
281
+ # glyph column the owner read as stray indentation.
203
282
  face_receipt() {
204
283
  face_text1="$1"
205
- face_text2=" Installed into $3"
206
- face_text3=" Check health any time: $2"
284
+ face_text2="Installed into $3"
285
+ face_text3="Check health any time: $2"
207
286
  face_w1=$(( \${#face_text1} + 3 ))
208
287
  face_w=$face_w1
209
288
  [ \${#face_text2} -gt "$face_w" ] && face_w=\${#face_text2}
210
289
  [ \${#face_text3} -gt "$face_w" ] && face_w=\${#face_text3}
211
290
  face_bar="$(paint "$FACE_ACCENT" '\u2502')"
212
291
  face_line="$(face_rule $((face_w + 4)))"
213
- printf '%s\\n' "$(paint "$FACE_ACCENT" "\u256D\${face_line}\u256E")"
214
- printf '%s %s %*s%s\\n' "$face_bar" "$(paint "$FACE_GREEN" '\u2714') $face_text1" "$((face_w - face_w1))" '' "$face_bar"
215
- printf '%s %s%*s %s\\n' "$face_bar" "$face_text2" "$((face_w - \${#face_text2}))" '' "$face_bar"
216
- printf '%s %s%*s %s\\n' "$face_bar" "$face_text3" "$((face_w - \${#face_text3}))" '' "$face_bar"
217
- printf '%s\\n' "$(paint "$FACE_ACCENT" "\u2570\${face_line}\u256F")"
218
- printf '%s %s\\n' "$(paint "$FACE_ACCENT" '\u25C6')" "$(paint "$FACE_ACCENT" "$FACE_NAME \xB7 Mutatis Mutandis")"
292
+ face_print stdout '%s\\n' "$(paint "$FACE_ACCENT" "\u256D\${face_line}\u256E")"
293
+ face_print stdout '%s %s %*s%s\\n' "$face_bar" "$(paint "$FACE_GREEN" '\u2714') $face_text1" "$((face_w - face_w1))" '' "$face_bar"
294
+ face_print stdout '%s %s%*s %s\\n' "$face_bar" "$face_text2" "$((face_w - \${#face_text2}))" '' "$face_bar"
295
+ face_print stdout '%s %s%*s %s\\n' "$face_bar" "$face_text3" "$((face_w - \${#face_text3}))" '' "$face_bar"
296
+ face_print stdout '%s\\n' "$(paint "$FACE_ACCENT" "\u2570\${face_line}\u256F")"
297
+ face_print stdout '%s %s\\n' "$(paint "$FACE_ACCENT" '\u25C6')" "$(paint "$FACE_ACCENT" "$FACE_NAME \xB7 Mutatis Mutandis")"
219
298
  }
220
299
 
221
300
  face_refusal() {
222
- printf '%s %s %s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_ACCENT" '\u2716')" "$1" >&2
301
+ face_print stderr '%s %s %s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_ACCENT" '\u2716')" "$1"
223
302
  }
224
303
 
225
304
  # face_status <text> \u2014 transient status, redrawn in place on STDERR (0.2.0). A served script has no
@@ -236,13 +315,13 @@ face_status() {
236
315
  2) face_f='\u25D3' ;;
237
316
  *) face_f='\u25D1' ;;
238
317
  esac
239
- printf '\\r\\033[2K%s %s %s' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_MUTED" "$face_f")" "$1" >&2
318
+ face_print spinner '\\r\\033[2K%s %s %s' "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_MUTED" "$face_f")" "$1"
240
319
  }
241
320
 
242
321
  # Always call before writing the durable line, or the finished step lands on a half-drawn frame.
243
322
  face_status_clear() {
244
323
  [ -t 2 ] || return 0
245
- printf '\\r\\033[2K' >&2
324
+ face_print spinner '\\r\\033[2K'
246
325
  }
247
326
  # <<< installer-face:end
248
327
  `;
@@ -261,7 +340,7 @@ $FaceDash = [char] 0x2014
261
340
 
262
341
  $FaceName = '${identity.name}'
263
342
  $FaceAccent = '${identity.accent}'
264
- $FaceWarm = '${identity.warm.replace(/'/gu, "''").replace(/\u2026/gu, "' + [char] 0x2026 + '")}'
343
+ $FaceWarm = '${identity.installWarm.replace(/'/gu, "''").replace(/\u2026/gu, "' + [char] 0x2026 + '").replace(/\u2014/gu, "' + [char] 0x2014 + '")}'
265
344
  $FaceMuted = '${PALETTE.muted}'
266
345
  $FaceGreen = '${PALETTE.green}'
267
346
 
@@ -278,6 +357,28 @@ catch {
278
357
  # A console that refuses UTF-8 still gets the glyphs its code page can draw.
279
358
  }
280
359
 
360
+ # Use .NET JSON serialization: no Utility-module autoload, Node, or handwritten JSON escaping.
361
+ function Write-FaceOutput {
362
+ param([string] $Text, [string] $Channel = 'stdout', [switch] $NoNewline)
363
+ if (-not $NoNewline) { $Text += [Environment]::NewLine }
364
+ if ($Channel -eq 'stdout') { [Console]::Out.Write($Text) } else { [Console]::Error.Write($Text) }
365
+ if (-not [string]::IsNullOrEmpty($env:MM_FACE_TRANSCRIPT)) {
366
+ [void] [Reflection.Assembly]::Load('System.Runtime.Serialization, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089')
367
+ $record = [Collections.Generic.Dictionary[string,string]]::new()
368
+ $record.Add('channel', $Channel)
369
+ $record.Add('text', $Text)
370
+ $settings = [Runtime.Serialization.Json.DataContractJsonSerializerSettings]::new()
371
+ $settings.UseSimpleDictionaryFormat = $true
372
+ $serializer = [Runtime.Serialization.Json.DataContractJsonSerializer]::new($record.GetType(), $settings)
373
+ $buffer = [IO.MemoryStream]::new()
374
+ try {
375
+ $serializer.WriteObject($buffer, $record)
376
+ $json = [Text.Encoding]::UTF8.GetString($buffer.ToArray())
377
+ [IO.File]::AppendAllText($env:MM_FACE_TRANSCRIPT, $json + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
378
+ } finally { $buffer.Dispose() }
379
+ }
380
+ }
381
+
281
382
  function Write-Paint {
282
383
  param([string] $Sgr, [string] $Text)
283
384
  if ($FaceColor) {
@@ -287,10 +388,10 @@ function Write-Paint {
287
388
  }
288
389
 
289
390
  function Write-FaceWelcome {
290
- Write-Host ((Write-Paint $FaceAccent ([string] $FaceDiamond)) + ' ' + (Write-Paint $FaceAccent $FaceName) + ' ' + $FaceDash + ' Mutatis Mutandis')
291
- Write-Host (Write-Paint $FaceMuted ([string] $FaceBar))
292
- Write-Host ((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + $FaceWarm)
293
- Write-Host (Write-Paint $FaceMuted ([string] $FaceBar))
391
+ Write-FaceOutput ((Write-Paint $FaceAccent ([string] $FaceDiamond)) + ' ' + (Write-Paint $FaceAccent $FaceName) + ' ' + $FaceDash + ' Mutatis Mutandis')
392
+ Write-FaceOutput (Write-Paint $FaceMuted ([string] $FaceBar))
393
+ Write-FaceOutput ((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + $FaceWarm)
394
+ Write-FaceOutput (Write-Paint $FaceMuted ([string] $FaceBar))
294
395
  }
295
396
 
296
397
  # A title longer than the step column is shortened in the middle: a path's start and tail identify it.
@@ -307,37 +408,37 @@ function Get-FaceTitle {
307
408
  function Write-FaceStep {
308
409
  param([string] $Title, [DateTime] $Started)
309
410
  $seconds = [Math]::Max(0, [int] ([DateTime]::UtcNow - $Started).TotalSeconds)
310
- Write-Host ((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceGreen ([string] $FaceCheck)) + ' ' + (Get-FaceTitle $Title).PadRight(38) + ' ' + (Write-Paint $FaceMuted ($seconds.ToString() + 's')))
411
+ Write-FaceOutput ((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceGreen ([string] $FaceCheck)) + ' ' + (Get-FaceTitle $Title).PadRight(38) + ' ' + (Write-Paint $FaceMuted ($seconds.ToString() + 's')))
311
412
  }
312
413
 
313
414
  # Another program's output, kept inside the face at the step-title column.
314
415
  function Write-FaceRelay {
315
416
  param([string] $Line)
316
417
  if ([string]::IsNullOrWhiteSpace($Line)) {
317
- Write-Host (Write-Paint $FaceMuted ([string] $FaceBar))
418
+ Write-FaceOutput (Write-Paint $FaceMuted ([string] $FaceBar))
318
419
  return
319
420
  }
320
- Write-Host ((Write-Paint $FaceMuted ([string] $FaceBar)) + '${RELAY_INDENT}' + $Line)
421
+ Write-FaceOutput ((Write-Paint $FaceMuted ([string] $FaceBar)) + '${RELAY_INDENT}' + $Line)
321
422
  }
322
423
 
323
424
  function Write-FaceReceipt {
324
425
  param([string] $Location, [string] $Next)
325
426
  $first = [string] $FaceCheck + ' ' + $FaceName + ' is ready.'
326
- $second = ' Installed into ' + $Location
327
- $third = ' Check health any time: ' + $Next
427
+ $second = 'Installed into ' + $Location
428
+ $third = 'Check health any time: ' + $Next
328
429
  $inner = ($first.Length, $second.Length, $third.Length | Measure-Object -Maximum).Maximum
329
430
  $rule = [string]::new([char] 0x2500, $inner + 4)
330
- Write-Host (Write-Paint $FaceAccent ([string] [char] 0x256D + $rule + [char] 0x256E))
431
+ Write-FaceOutput (Write-Paint $FaceAccent ([string] [char] 0x256D + $rule + [char] 0x256E))
331
432
  foreach ($text in @($first, $second, $third)) {
332
- Write-Host ((Write-Paint $FaceAccent ([string] $FaceBar)) + ' ' + $text.PadRight($inner) + ' ' + (Write-Paint $FaceAccent ([string] $FaceBar)))
433
+ Write-FaceOutput ((Write-Paint $FaceAccent ([string] $FaceBar)) + ' ' + $text.PadRight($inner) + ' ' + (Write-Paint $FaceAccent ([string] $FaceBar)))
333
434
  }
334
- Write-Host (Write-Paint $FaceAccent ([string] [char] 0x2570 + $rule + [char] 0x256F))
335
- Write-Host ((Write-Paint $FaceAccent ([string] $FaceDiamond)) + ' ' + (Write-Paint $FaceAccent ($FaceName + ' ' + [char] 0x00B7 + ' Mutatis Mutandis')))
435
+ Write-FaceOutput (Write-Paint $FaceAccent ([string] [char] 0x2570 + $rule + [char] 0x256F))
436
+ Write-FaceOutput ((Write-Paint $FaceAccent ([string] $FaceDiamond)) + ' ' + (Write-Paint $FaceAccent ($FaceName + ' ' + [char] 0x00B7 + ' Mutatis Mutandis')))
336
437
  }
337
438
 
338
439
  function Write-FaceRefusal {
339
440
  param([string] $Message)
340
- [Console]::Error.WriteLine((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceAccent ([string] $FaceCross)) + ' ' + $Message)
441
+ Write-FaceOutput -Channel stderr ((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceAccent ([string] $FaceCross)) + ' ' + $Message)
341
442
  }
342
443
 
343
444
  # Transient status, redrawn in place on STDERR (0.2.0). Frames are emitted BY CODE POINT for the same
@@ -353,7 +454,7 @@ function Write-FaceStatus {
353
454
  }
354
455
  $script:FaceFrame = ($script:FaceFrame + 1) % $FaceFrames.Length
355
456
  $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)
457
+ Write-FaceOutput -Channel spinner -NoNewline ([string] [char] 13 + ([char] 27) + '[2K' + $line)
357
458
  }
358
459
 
359
460
  # Always call before writing the durable line.
@@ -361,7 +462,7 @@ function Clear-FaceStatus {
361
462
  if ([Console]::IsErrorRedirected) {
362
463
  return
363
464
  }
364
- [Console]::Error.Write([string] [char] 13 + ([char] 27) + '[2K')
465
+ Write-FaceOutput -Channel spinner -NoNewline ([string] [char] 13 + ([char] 27) + '[2K')
365
466
  }
366
467
  # <<< installer-face:end
367
468
  `;
@@ -409,35 +510,109 @@ function clip(text, width) {
409
510
  }
410
511
 
411
512
  // src/spinner.ts
513
+ import { appendFileSync, writeSync as writeSync2 } from "node:fs";
514
+ import { Worker } from "node:worker_threads";
412
515
  var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
413
- function createSpinner(face, { animate, stream = process.stderr, intervalMs = 90 }) {
516
+ var WORKER_SOURCE = `
517
+ const { parentPort, workerData } = require('node:worker_threads');
518
+ const { writeSync, appendFileSync } = require('node:fs');
519
+ const control = new Int32Array(workerData.control);
520
+ let frames = workerData.frames, frame = workerData.frame;
521
+ function draw() {
522
+ if (Atomics.compareExchange(control, 1, 0, 1) !== 0) return;
523
+ try {
524
+ if (Atomics.load(control, 0) || Atomics.load(control, 2) || !frames.length) return;
525
+ const text = frames[frame++ % frames.length];
526
+ writeSync(2, text);
527
+ if (workerData.transcriptPath) appendFileSync(workerData.transcriptPath, JSON.stringify({ channel: 'spinner', text }) + '\\n');
528
+ } finally {
529
+ Atomics.store(control, 1, 0);
530
+ Atomics.notify(control, 1);
531
+ }
532
+ }
533
+ parentPort.on('message', (next) => {
534
+ if (Atomics.load(control, 2)) return;
535
+ frames = next.frames;
536
+ frame = next.frame;
537
+ Atomics.store(control, 0, 0);
538
+ });
539
+ setInterval(draw, workerData.intervalMs);
540
+ `;
541
+ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath }) {
542
+ animate = animate && !face.nested && !face.emitsProgress;
414
543
  let timer = null;
415
- let text = "";
544
+ let worker = null;
545
+ let control = null;
546
+ let frames = [];
416
547
  let frame = 0;
417
- const clear = () => {
418
- if (animate) stream.write("\r\x1B[2K");
548
+ const write = (text) => {
549
+ if (stream) stream.write(text);
550
+ else {
551
+ writeSync2(2, text);
552
+ if (transcriptPath) appendFileSync(transcriptPath, `${JSON.stringify({ channel: "spinner", text })}
553
+ `);
554
+ }
555
+ };
556
+ const render = (text, measure) => {
557
+ const line = face.step(text, measure, "note").split("\n")[0];
558
+ frames = line ? FRAMES.map((glyph) => `\r\x1B[2K${line.replace(GLYPH.dot, glyph)}`) : [];
419
559
  };
420
560
  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;
561
+ if (animate && frames.length) write(frames[frame++ % frames.length]);
562
+ };
563
+ const pause = () => {
564
+ if (!control) return;
565
+ Atomics.store(control, 0, 1);
566
+ while (Atomics.load(control, 1)) Atomics.wait(control, 1, 1);
567
+ };
568
+ const halt = () => {
569
+ if (timer) clearInterval(timer);
570
+ timer = null;
571
+ if (control) Atomics.store(control, 2, 1);
572
+ pause();
573
+ if (worker) void worker.terminate();
574
+ worker = null;
575
+ control = null;
425
576
  };
426
577
  return {
427
- start(next) {
428
- text = next;
578
+ start(text, measure = null) {
579
+ if (!animate) return;
580
+ halt();
581
+ render(text, measure);
429
582
  frame = 0;
430
583
  draw();
431
- if (animate && !timer) timer = setInterval(draw, intervalMs).unref();
584
+ if (!frames.length) return;
585
+ if (stream) timer = setInterval(draw, intervalMs).unref();
586
+ else {
587
+ control = new Int32Array(new SharedArrayBuffer(12));
588
+ try {
589
+ worker = new Worker(WORKER_SOURCE, { eval: true, workerData: {
590
+ control: control.buffer,
591
+ frames,
592
+ frame,
593
+ intervalMs,
594
+ transcriptPath
595
+ } });
596
+ const active = worker;
597
+ worker.on("error", () => {
598
+ if (worker === active) halt();
599
+ });
600
+ worker.unref();
601
+ } catch {
602
+ halt();
603
+ }
604
+ }
432
605
  },
433
- say(next) {
434
- text = next;
606
+ say(text, measure = null) {
607
+ if (!animate) return;
608
+ pause();
609
+ render(text, measure);
435
610
  draw();
611
+ worker?.postMessage({ frames, frame });
436
612
  },
437
613
  stop() {
438
- if (timer) clearInterval(timer);
439
- timer = null;
440
- clear();
614
+ halt();
615
+ if (animate) write("\r\x1B[2K");
441
616
  }
442
617
  };
443
618
  }
@@ -465,7 +640,42 @@ function assertFaceConformance(lines, options) {
465
640
  const head = plain[0] ?? "";
466
641
  const signOffLine = `${GLYPH.diamond} ${identity.name} \xB7 Mutatis Mutandis`;
467
642
  const opensWithWelcome = head.startsWith(GLYPH.diamond) && head !== signOffLine;
643
+ if (options.complete && options.tty !== false) {
644
+ const welcome = `${GLYPH.diamond} ${identity.name} \u2014 Mutatis Mutandis`;
645
+ const welcomes = plain.filter((line) => line === welcome).length;
646
+ const receipts = plain.filter((line) => line.startsWith(GLYPH.boxTop)).length;
647
+ const signOffs = plain.filter((line) => line === signOffLine).length;
648
+ if (welcomes !== (options.nested ? 0 : 1) || signOffs !== (options.nested ? 0 : 1) || !options.nested && receipts !== 1) {
649
+ fail("complete run", "expected one console owner, one welcome, one receipt and one sign-off");
650
+ }
651
+ for (const line of plain) {
652
+ if (line && ![GLYPH.bar, GLYPH.diamond, GLYPH.hollow, GLYPH.boxTop, GLYPH.boxBottom].some((glyph) => line.startsWith(glyph))) {
653
+ fail("complete run", `unframed output: ${JSON.stringify(line)}`);
654
+ }
655
+ }
656
+ }
468
657
  if (rendered.length === 0) fail("R7 render before you ship", "no rendered lines were given to the guard");
658
+ const greetings = plain.filter((line) => line.includes(`${identity.name} \u2014 Mutatis Mutandis`));
659
+ if (greetings.length > 1) fail("R8 one welcome per run", "duplicate product greeting");
660
+ if (plain.filter((line) => line.startsWith(GLYPH.boxTop)).length > 1) {
661
+ fail("R8 one receipt per run", "duplicate receipt");
662
+ }
663
+ if (options.nested === true) {
664
+ if (opensWithWelcome) {
665
+ fail("R8 a nested run draws no welcome", `an outer console already greeted the user; this run opened with ${JSON.stringify(head)}`);
666
+ }
667
+ const signOffAt = plain.findIndex((line) => line === signOffLine);
668
+ if (signOffAt !== -1) {
669
+ fail("R8 a nested run draws no sign-off", `the outer console closes the run; line ${signOffAt + 1} signs off`);
670
+ }
671
+ const boxAt = plain.findIndex((line) => line.startsWith(GLYPH.boxTop));
672
+ if (boxAt !== -1) {
673
+ const headline = plain.slice(boxAt + 1).find((line) => line.startsWith(GLYPH.bar) && line.trim() !== GLYPH.bar) ?? "";
674
+ if (headline.includes(GLYPH.check)) {
675
+ fail("R8 a nested run draws no success receipt", `the outer console owns the outcome; this run printed ${JSON.stringify(headline.trim())}`);
676
+ }
677
+ }
678
+ }
469
679
  if (options.tty === false) {
470
680
  for (const line of plain) {
471
681
  const glyph = [...line].find((char) => ALLOWED.has(char));
@@ -476,6 +686,11 @@ function assertFaceConformance(lines, options) {
476
686
  }
477
687
  return;
478
688
  }
689
+ for (const line of plain) {
690
+ if (line.startsWith(`${GLYPH.bar} ${GLYPH.hollow} `)) {
691
+ fail("note step uses U+25CF", `U+25C7 is the note-box opener in ${JSON.stringify(line)}`);
692
+ }
693
+ }
479
694
  for (const line of plain) {
480
695
  if (/[\u25c6\u25c7\u2714\u2716\u25cf] (?! )/u.test(line)) {
481
696
  fail("R4 two spaces after a glyph", `a single space follows the glyph in ${JSON.stringify(line)}`);
@@ -496,6 +711,14 @@ function assertFaceConformance(lines, options) {
496
711
  if (!plain[bottom].endsWith(GLYPH.boxBottomEnd)) fail("R5 receipt frame", `the bottom row does not pair its corners: ${JSON.stringify(plain[bottom])}`);
497
712
  const widths = new Set(plain.slice(top, bottom + 1).map(visibleWidth));
498
713
  if (widths.size !== 1) fail("R5 receipt frame", `the box is ragged: widths ${[...widths].join(", ")}`);
714
+ const rows = plain.slice(top + 1, bottom);
715
+ if (rows.length > 0 && rows[0].includes(GLYPH.check)) {
716
+ if (rows.length < 3) {
717
+ fail("R9 the receipt says what changed", `a success receipt has three lines \u2014 ready, what changed, the health command \u2014 and this one has ${rows.length}: ${JSON.stringify(rows.map((row) => row.trim()))}`);
718
+ }
719
+ const changed = rows[1].replace(GLYPH.bar, "").trim();
720
+ if (changed === "") fail("R9 the receipt says what changed", "the middle line of the success receipt is blank");
721
+ }
499
722
  }
500
723
  const inReceipt = (index) => top !== -1 && index >= top && index <= plain.findIndex((line, at) => at > top && line.startsWith(GLYPH.boxBottom));
501
724
  const welcomeEnd = opensWithWelcome ? 4 : 0;
@@ -529,7 +752,7 @@ function assertFaceConformance(lines, options) {
529
752
  if (opensWithWelcome) {
530
753
  const expected = `${GLYPH.diamond} ${identity.name} \u2014 Mutatis Mutandis`;
531
754
  if (head !== expected) fail("welcome is the product table's", `expected ${JSON.stringify(expected)}, got ${JSON.stringify(head)}`);
532
- const warm = plain.slice(1, 4).find((line) => line.includes(identity.warm));
755
+ const warm = plain.slice(1, 4).find((line) => line.includes(identity.warm) || line.includes(identity.installWarm));
533
756
  if (!warm) fail("welcome is the product table's", `the warm line for ${options.product} is missing or reworded`);
534
757
  }
535
758
  const painted = rendered.join("\n");
@@ -564,6 +787,391 @@ function assertScriptConformance(script, options) {
564
787
  }
565
788
  if (!/NO_COLOR/u.test(text)) fail("served script", "NO_COLOR must disable colour");
566
789
  }
790
+
791
+ // src/run.ts
792
+ import { appendFileSync as appendFileSync2 } from "node:fs";
793
+
794
+ // src/outcome.ts
795
+ import { readFileSync, writeFileSync } from "node:fs";
796
+ var counts = ["total", "updated", "failed"];
797
+ var strings = ["version", "retry", "detail"];
798
+ var flags = ["dryRun", "installed", "deferred", "operationFailed"];
799
+ function validateInstallerOutcome(value) {
800
+ const invalid = () => {
801
+ throw new Error("installer outcome: invalid child result");
802
+ };
803
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid();
804
+ const facts = value;
805
+ const allowed = [...counts, ...strings, ...flags];
806
+ if (Object.keys(facts).some((key) => !allowed.includes(key))) return invalid();
807
+ for (const key of counts) if (!Number.isSafeInteger(facts[key]) || facts[key] < 0) return invalid();
808
+ if (facts.updated + facts.failed > facts.total) return invalid();
809
+ for (const key of strings) if (facts[key] !== void 0 && (typeof facts[key] !== "string" || facts[key].length > 4096)) return invalid();
810
+ for (const key of flags) if (facts[key] !== void 0 && typeof facts[key] !== "boolean") return invalid();
811
+ return { ...facts };
812
+ }
813
+ function writeInstallerOutcome(path, value) {
814
+ writeFileSync(path, JSON.stringify(validateInstallerOutcome(value)), { encoding: "utf8", mode: 384 });
815
+ }
816
+ function readInstallerOutcome(path) {
817
+ let text;
818
+ try {
819
+ text = readFileSync(path, "utf8");
820
+ } catch (error) {
821
+ if (error.code === "ENOENT") return void 0;
822
+ throw error;
823
+ }
824
+ if (text.length > 16384) throw new Error("installer outcome: child result is too large");
825
+ try {
826
+ return validateInstallerOutcome(JSON.parse(text));
827
+ } catch {
828
+ throw new Error("installer outcome: invalid child result");
829
+ }
830
+ }
831
+
832
+ // src/run.ts
833
+ function validateInstallerProduct(value) {
834
+ const fail2 = (field) => {
835
+ throw new Error(`installer product: invalid ${field}`);
836
+ };
837
+ if (!value || typeof value !== "object" || Array.isArray(value)) return fail2("declaration");
838
+ const input = value;
839
+ const text = (value2, field) => typeof value2 === "string" && value2.trim() && !/[\r\n\x00-\x1f]/u.test(value2) ? value2 : fail2(field);
840
+ const key = text(input.product, "product");
841
+ const product = { mmi: "mmi-hub", jerv: "jerv-hub" }[key] ?? key;
842
+ const identity = identityFor(product);
843
+ const gate = text(input.gate, "gate");
844
+ let gateUrl;
845
+ try {
846
+ gateUrl = new URL(gate);
847
+ } catch {
848
+ return fail2("gate");
849
+ }
850
+ if (!["https:", "http:"].includes(gateUrl.protocol) || gateUrl.username || gateUrl.password) return fail2("gate");
851
+ const doctor = text(input.doctor, "doctor");
852
+ if (doctor !== identity.doctor) return fail2("doctor");
853
+ if (!Array.isArray(input.surfaces) || input.surfaces.length === 0) return fail2("surfaces");
854
+ const ids = /* @__PURE__ */ new Set();
855
+ const surfaces = input.surfaces.map((raw) => {
856
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return fail2("surface");
857
+ const source = raw;
858
+ const id = text(source.id, "surface.id");
859
+ if (ids.has(id)) return fail2("duplicate surface.id");
860
+ ids.add(id);
861
+ const surface = { id };
862
+ for (const field of ["npm", "bin", "kind", "activation"]) {
863
+ if (source[field] !== void 0) surface[field] = text(source[field], `surface.${field}`);
864
+ }
865
+ if (Boolean(surface.npm) !== Boolean(surface.bin)) return fail2("surface npm/bin pair");
866
+ if (surface.kind !== void 0 && !["agent-home", "payload"].includes(surface.kind)) return fail2("surface.kind");
867
+ if (!surface.npm && !surface.kind) return fail2("surface implementation");
868
+ return surface;
869
+ });
870
+ return { product, gate, doctor, surfaces };
871
+ }
872
+ var PHASES = {
873
+ preflight: ["Checking prerequisites", "Checked prerequisites"],
874
+ resolve: ["Resolving the release", "Resolved the release"],
875
+ download: ["Downloading the payload", "Downloaded the payload"],
876
+ "sign-in": ["Signing in", "Signed in"],
877
+ check: ["Checking surfaces", "Checked surfaces"],
878
+ arm: ["Scheduling updates", "Armed hourly updates"],
879
+ "verify-release": ["Checking the release version", "Verified the release version"],
880
+ verify: ["Verifying the payload", "Verified the payload"],
881
+ install: ["Installing the product", "Installed the product"],
882
+ activate: ["Activating surfaces", "Activated surfaces"],
883
+ doctor: ["Checking health", "Checked health"]
884
+ };
885
+ function createInstallerRun(value, options = {}) {
886
+ const declaration = validateInstallerProduct(value);
887
+ const env = options.env ?? process.env;
888
+ const tty = options.tty ?? Boolean(process.stdout.isTTY);
889
+ const face = createFace({
890
+ operation: options.operation,
891
+ product: declaration.product,
892
+ columns: options.columns,
893
+ env,
894
+ color: tty && options.color !== false && env.NO_COLOR === void 0
895
+ });
896
+ const write = options.write ?? ((text, channel) => {
897
+ (channel === "stdout" ? process.stdout : process.stderr).write(text);
898
+ });
899
+ const emit = (text, channel = "stdout", recorded = text) => {
900
+ if (!text) return;
901
+ write(text, channel);
902
+ if (env.MM_FACE_TRANSCRIPT) {
903
+ appendFileSync2(env.MM_FACE_TRANSCRIPT, `${JSON.stringify({ channel, text: recorded })}
904
+ `, "utf8");
905
+ }
906
+ };
907
+ const lines = (rows, channel = "stdout") => {
908
+ for (const row of rows.flatMap((row2) => row2.split("\n"))) if (row) emit(`${row}
909
+ `, channel);
910
+ };
911
+ const spinner = createSpinner(face, {
912
+ animate: tty && env.TERM !== "dumb" && (options.animate ?? Boolean(process.stderr.isTTY)),
913
+ ...options.write ? { stream: { write: (text) => {
914
+ emit(String(text), "spinner");
915
+ return true;
916
+ } } } : { transcriptPath: env.MM_FACE_TRANSCRIPT }
917
+ });
918
+ let started = false;
919
+ let finished = false;
920
+ const start = () => {
921
+ if (started || finished) return;
922
+ started = true;
923
+ const welcome = face.welcome();
924
+ if (tty) lines(welcome);
925
+ else if (welcome.length) lines([`${face.identity.name} \u2014 Mutatis Mutandis`, options.operation === "install" ? face.identity.installWarm : face.identity.warm]);
926
+ };
927
+ const durable = (title, measure, kind) => {
928
+ spinner.stop();
929
+ const rendered = face.step(title, measure, kind);
930
+ if (rendered) lines([tty ? rendered : `${kind === "fail" ? "Failed: " : ""}${title}${measure === null ? "" : ` (${typeof measure === "number" ? `${Math.max(0, Math.round(measure))}s` : measure})`}`]);
931
+ };
932
+ const run = {
933
+ start,
934
+ phase(id, facts = {}) {
935
+ if (finished) throw new Error("installer run already finished");
936
+ if (!Object.hasOwn(PHASES, id)) throw new Error("installer run: unknown phase");
937
+ start();
938
+ const state = facts.state ?? "ok";
939
+ const title = PHASES[id][state === "ok" ? 1 : 0];
940
+ if (state === "running") {
941
+ spinner.start(title, facts.measure ?? null);
942
+ return;
943
+ }
944
+ if (!face.continues(id, state)) durable(title, facts.seconds ?? facts.measure ?? null, state);
945
+ if (facts.detail) run.relay(facts.detail);
946
+ },
947
+ surface(facts) {
948
+ if (finished) throw new Error("installer run already finished");
949
+ const surface = declaration.surfaces.find((surface2) => surface2.id === facts.id);
950
+ if (!surface) throw new Error("installer run: undeclared surface");
951
+ start();
952
+ const versions = facts.to ? facts.from && facts.from !== facts.to ? ` ${facts.from} \u2192 ${facts.to}` : ` ${facts.to}` : "";
953
+ const status = { updated: options.dryRun ? "would update" : "updated", current: "already current", failed: "failed", skipped: "skipped", retry: "retrying", pending: "pending", kept: "kept" }[facts.state];
954
+ if (!status) throw new Error("installer run: unknown surface state");
955
+ const activation = facts.state === "updated" && surface.activation ? ` \xB7 ${surface.activation}` : "";
956
+ const completed = ["updated", "current", "kept", "skipped"].includes(facts.state);
957
+ durable(`${facts.id}${versions} \xB7 ${status}${activation}`, completed ? facts.seconds ?? null : null, facts.state === "failed" ? "fail" : facts.state === "updated" ? "ok" : "note");
958
+ if (facts.detail) run.relay(facts.detail);
959
+ },
960
+ milestone({ step, state, ms }) {
961
+ start();
962
+ durable(step, ms === void 0 ? null : ms / 1e3, state);
963
+ },
964
+ signIn({ url, code }) {
965
+ start();
966
+ spinner.stop();
967
+ for (const text of [`Open ${url}`, `Enter code: ${code}`]) {
968
+ const rendered = `${tty ? face.relay(text) : text}
969
+ `;
970
+ emit(rendered, "stdout", rendered.replace(code, "[redacted]"));
971
+ }
972
+ },
973
+ // Only pass safe diagnostic text, never authentication output or credentials.
974
+ relay(text, channel = "stdout", record = true) {
975
+ spinner.stop();
976
+ const rendered = tty ? face.relay(text) : stripColor(text);
977
+ for (const row of rendered.split("\n")) if (row) {
978
+ emit(`${row}
979
+ `, channel, record ? `${row}
980
+ ` : `${tty ? face.relay("[external output omitted]") : "[external output omitted]"}
981
+ `);
982
+ }
983
+ },
984
+ finish(facts) {
985
+ if (finished) return;
986
+ validateInstallerOutcome(facts);
987
+ if (env.MM_INSTALLER_OUTCOME_FILE) writeInstallerOutcome(env.MM_INSTALLER_OUTCOME_FILE, facts);
988
+ start();
989
+ spinner.stop();
990
+ finished = true;
991
+ const changed = !facts.version ? "No release target is available." : facts.dryRun ? `Would update ${facts.updated} of ${facts.total} surfaces to ${facts.version}.` : facts.installed ? `Installed ${facts.version} across ${facts.total} surfaces.` : `Updated ${facts.updated} of ${facts.total} surfaces to ${facts.version}.`;
992
+ const ready = facts.failed === 0 && !facts.dryRun && !facts.deferred && !facts.operationFailed && Boolean(facts.version);
993
+ const body = [
994
+ `${ready ? GLYPH.check : facts.dryRun ? GLYPH.dot : GLYPH.cross} ${face.identity.name}${ready ? " is ready." : facts.dryRun ? " preview complete." : facts.deferred ? " update deferred." : " is not ready yet."}`,
995
+ changed,
996
+ ...facts.failed ? [`Failed ${facts.failed} of ${facts.total} surfaces.`] : [],
997
+ ...facts.detail ? [facts.detail] : [],
998
+ facts.retry && facts.failed ? `Retry: ${facts.retry}` : `Check health any time: ${declaration.doctor}`
999
+ ];
1000
+ if (!face.nested || !env.MM_INSTALLER_OUTCOME_FILE && (facts.failed > 0 || facts.operationFailed)) {
1001
+ lines(tty ? face.receipt(body, { ready }) : body.map((row) => row.replace(/^[✔✖●] /u, "")));
1002
+ }
1003
+ if (tty) lines([face.signOff()]);
1004
+ },
1005
+ stop() {
1006
+ spinner.stop();
1007
+ }
1008
+ };
1009
+ return run;
1010
+ }
1011
+ async function runInstaller(value, operation, options) {
1012
+ const run = createInstallerRun(value, options);
1013
+ run.start();
1014
+ try {
1015
+ const result = await operation(run);
1016
+ run.finish(result);
1017
+ return result;
1018
+ } finally {
1019
+ run.stop();
1020
+ }
1021
+ }
1022
+
1023
+ // src/payload.ts
1024
+ function renderPayloadEntry({ tarballs, converge, shippedFlag }) {
1025
+ if (!Array.isArray(tarballs) || tarballs.length === 0 || tarballs.some((name) => typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(name))) {
1026
+ throw new Error("installer payload: tarballs must be relative .tgz basenames");
1027
+ }
1028
+ if (!Array.isArray(converge) || converge.length === 0 || converge.some((arg) => typeof arg !== "string" || !arg.trim() || /[\x00-\x1f"&|<>^%!`()]/u.test(arg))) {
1029
+ throw new Error("installer payload: invalid converge argv");
1030
+ }
1031
+ if (shippedFlag !== void 0 && !/^--[a-z][a-z0-9-]*$/u.test(shippedFlag)) {
1032
+ throw new Error("installer payload: invalid shipped flag");
1033
+ }
1034
+ return String.raw`// Generated by @mutmutco/installer-face. Product code declares operations, never this program.
1035
+ import { spawnSync } from 'node:child_process';
1036
+ import { readFileSync } from 'node:fs';
1037
+ import { dirname, join } from 'node:path';
1038
+
1039
+ const payloadDir = dirname(process.argv[1]);
1040
+ const tarballs = ${JSON.stringify(tarballs.map((name) => `./${name}`))};
1041
+ const converge = ${JSON.stringify(converge)};
1042
+ const shippedFlag = ${JSON.stringify(shippedFlag ?? null)};
1043
+
1044
+ function step(command, args) {
1045
+ const options = { cwd: payloadDir, stdio: 'inherit', windowsHide: true };
1046
+ // One prequoted command and no argv avoids Node's args+shell warning for Windows .cmd shims.
1047
+ const result = process.platform === 'win32'
1048
+ ? spawnSync([command, ...args].map((arg) => /\s/.test(arg) ? '"' + arg + '"' : arg).join(' '), [], { ...options, shell: true })
1049
+ : spawnSync(command, args, options);
1050
+ if (result.error || result.status !== 0) {
1051
+ process.stderr.write('Installer operation failed.\n');
1052
+ process.exit(result.status === null || result.status === undefined ? 1 : result.status);
1053
+ }
1054
+ }
1055
+
1056
+ step('npm', ['install', '--global', '--no-fund', '--no-audit', ...tarballs]);
1057
+ let shipped = null;
1058
+ if (shippedFlag) {
1059
+ try {
1060
+ const manifest = JSON.parse(readFileSync(join(payloadDir, 'payload.json'), 'utf8'));
1061
+ shipped = typeof manifest.version === 'string' ? manifest.version.trim() || null : null;
1062
+ } catch {
1063
+ // Older/manual payloads have no manifest version and retain the plain converge operation.
1064
+ }
1065
+ if (shipped && !/^[A-Za-z0-9][A-Za-z0-9.+-]*$/.test(shipped)) {
1066
+ process.stderr.write('Installer payload has an invalid shipped version.\n');
1067
+ process.exit(1);
1068
+ }
1069
+ if (!shipped) process.stdout.write('Payload carries no shipped version; running the declared operation.\n');
1070
+ }
1071
+ step(converge[0], [...converge.slice(1), ...(shipped ? [shippedFlag, shipped] : [])]);
1072
+ `;
1073
+ }
1074
+
1075
+ // src/maintenance.ts
1076
+ async function runMaintenance(product, args, dependencies) {
1077
+ const json = args.includes("--json");
1078
+ const dryRun = args.includes("--dry-run");
1079
+ const installed = args.includes("install");
1080
+ const run = json ? null : dependencies.run ?? createInstallerRun(product, {
1081
+ tty: Boolean(process.stdout.isTTY),
1082
+ color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR,
1083
+ columns: process.stdout.columns,
1084
+ dryRun,
1085
+ operation: installed ? "install" : "update"
1086
+ });
1087
+ run?.start();
1088
+ let mark = Date.now();
1089
+ const pending = [];
1090
+ let streaming = false;
1091
+ const emitArm = ({ arm, seconds }) => {
1092
+ run?.surface({
1093
+ id: arm.surface,
1094
+ from: arm.from ?? void 0,
1095
+ to: arm.to ?? void 0,
1096
+ state: arm.verdict === "fail" ? "failed" : arm.verdict === "defer" ? "retry" : arm.verdict === "skip" ? "kept" : arm.from === arm.to && !arm.launchStaged ? "current" : "updated",
1097
+ seconds,
1098
+ detail: dependencies.diagnose(arm)
1099
+ });
1100
+ };
1101
+ try {
1102
+ const summary = await dependencies.engine({
1103
+ dryRun,
1104
+ shippedTarget: installed ? dependencies.shippedTarget : void 0,
1105
+ narrate: args.includes("--verbose") ? (text) => run?.relay(text, "stderr") : void 0,
1106
+ onTargetResolved: (target) => run?.phase("verify-release", { detail: target, state: "ok" }),
1107
+ onPhase: () => run?.phase("check", { state: "running" }),
1108
+ onArm: (arm) => {
1109
+ const seconds = (Date.now() - mark) / 1e3;
1110
+ mark = Date.now();
1111
+ pending.push({ arm, seconds });
1112
+ if (pending.length > 1) streaming = true;
1113
+ if (streaming) for (const result of pending.splice(0)) emitArm(result);
1114
+ }
1115
+ });
1116
+ if (summary.reexec) {
1117
+ run?.stop();
1118
+ return dependencies.handOff(summary.reexec, args, { ...process.env, MM_FACE_CONTINUES: "welcome" });
1119
+ }
1120
+ for (const arm of pending) emitArm(arm);
1121
+ const waiting = summary.surfaces.filter((surface) => surface.present && surface.action === "arm-pending");
1122
+ for (const surface of waiting) run?.surface({ id: surface.id, state: "pending" });
1123
+ let schedule = null;
1124
+ if (installed && !dryRun) {
1125
+ const started = Date.now();
1126
+ schedule = dependencies.schedule();
1127
+ run?.phase("arm", {
1128
+ state: schedule.ok ? "ok" : "fail",
1129
+ seconds: (Date.now() - started) / 1e3,
1130
+ detail: schedule.ok ? void 0 : schedule.detail
1131
+ });
1132
+ }
1133
+ const counts2 = dependencies.counts(summary);
1134
+ if (json) {
1135
+ process.stdout.write(JSON.stringify(installed ? { convergence: summary, autoupdate: schedule } : summary, null, 2) + "\n");
1136
+ } else {
1137
+ run.finish({
1138
+ version: summary.target ?? void 0,
1139
+ total: counts2.total,
1140
+ updated: counts2.updated,
1141
+ failed: counts2.failed,
1142
+ operationFailed: summary.exit !== 0 || Boolean(schedule && !schedule.ok),
1143
+ deferred: Boolean(counts2.retry || waiting.length || summary.deferred || !summary.target),
1144
+ dryRun,
1145
+ installed,
1146
+ detail: schedule && !schedule.ok ? schedule.detail : summary.detail
1147
+ });
1148
+ }
1149
+ return Math.max(summary.exit, schedule && !schedule.ok ? 1 : 0);
1150
+ } catch (error) {
1151
+ run?.finish({ total: 0, updated: 0, failed: 0, operationFailed: true, detail: error.message });
1152
+ if (json) throw error;
1153
+ return 1;
1154
+ } finally {
1155
+ run?.stop();
1156
+ }
1157
+ }
1158
+
1159
+ // src/transcript.ts
1160
+ function assertInstallerTranscript(jsonl, options, reference) {
1161
+ const parse = (text) => text.trim().split(/\r?\n/u).filter(Boolean).map((line) => {
1162
+ const record = JSON.parse(line);
1163
+ if (!record || typeof record !== "object" || Array.isArray(record) || Object.keys(record).some((key) => key !== "channel" && key !== "text") || !["stdout", "stderr", "spinner"].includes(String(record.channel)) || typeof record.text !== "string") {
1164
+ throw new Error("installer transcript: invalid record");
1165
+ }
1166
+ return { channel: String(record.channel), text: record.text };
1167
+ });
1168
+ const records = parse(jsonl);
1169
+ const lines = records.filter((record) => record.channel !== "spinner").map((record) => record.text).join("").split(/\r?\n/u).filter(Boolean);
1170
+ assertFaceConformance(lines, { ...options, complete: true });
1171
+ if (reference !== void 0 && JSON.stringify(records) !== JSON.stringify(parse(reference))) {
1172
+ throw new Error("installer transcript: output differs from the reviewed reference");
1173
+ }
1174
+ }
567
1175
  export {
568
1176
  GLYPH,
569
1177
  PALETTE,
@@ -571,15 +1179,24 @@ export {
571
1179
  SPINNER_FRAMES,
572
1180
  TITLE_COLUMN,
573
1181
  assertFaceConformance,
1182
+ assertInstallerTranscript,
574
1183
  assertScriptConformance,
575
1184
  createFace,
1185
+ createInstallerRun,
576
1186
  createSpinner,
577
1187
  faceWidth,
578
1188
  identityFor,
1189
+ readInstallerOutcome,
1190
+ renderPayloadEntry,
579
1191
  renderPowerShellFace,
580
1192
  renderRows,
581
1193
  renderShellFace,
1194
+ runInstaller,
1195
+ runMaintenance,
582
1196
  stripColor,
1197
+ validateInstallerOutcome,
1198
+ validateInstallerProduct,
583
1199
  visibleWidth,
584
- wrapWords
1200
+ wrapWords,
1201
+ writeInstallerOutcome
585
1202
  };