@mutmutco/installer-face 0.2.3 → 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,7 +96,14 @@ 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, env = process.env }) {
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);
@@ -93,28 +111,45 @@ function createFace({ product, color = false, columns, env = process.env }) {
93
111
  const indent = " ".repeat(TITLE_COLUMN - 1);
94
112
  const continuedPhases = new Set((env.MM_FACE_CONTINUES ?? "").split(",").map((phase) => phase.trim()).filter(Boolean));
95
113
  const continuesFace = continuedPhases.size > 0;
96
- const welcome = () => continuesFace ? [] : [
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 ? [] : [
97
129
  `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, identity.name)} \u2014 Mutatis Mutandis`,
98
130
  bar(),
99
- `${bar()} ${identity.warm}`,
131
+ `${bar()} ${operation === "install" ? identity.installWarm : identity.warm}`,
100
132
  bar()
101
133
  ];
102
134
  const continues = (phase, kind = "ok") => {
103
135
  const inherited = continuedPhases.delete(String(phase).trim());
104
136
  return kind === "fail" ? false : inherited;
105
137
  };
106
- const step = (title, seconds = null, kind = "ok") => {
138
+ const step = (title, measure = null, kind = "ok") => {
139
+ if (emitMilestone(title, measure, kind)) return "";
107
140
  const glyph = kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.dot) : paint(PALETTE.green, GLYPH.check);
108
- const time = seconds === null || seconds === void 0 ? "" : paint(PALETTE.muted, `${Math.max(0, Math.round(seconds))}s`);
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;
109
143
  const column = Math.min(44, Math.max(0, width - 8));
110
- const reserved = time ? 6 : 0;
144
+ const reserved = time ? Math.max(6, visibleWidth(time) + 2) : 0;
111
145
  const [first, ...rest] = wrapWords(title, width - TITLE_COLUMN - reserved);
112
146
  const head = `${bar()} ${glyph} ${first}`;
113
147
  const pad = Math.max(1, column - visibleWidth(head));
114
148
  return [time ? `${head}${" ".repeat(pad)}${time}` : head, ...rest.map((line) => `${bar()}${indent}${line}`)].join("\n");
115
149
  };
116
- const relay = (text) => String(text).split("\n").map((line) => line.trim() === "" ? bar() : `${bar()}${indent}${line}`).join("\n");
117
- 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 [];
118
153
  const body = (Array.isArray(lines) ? lines : String(lines).split("\n")).flatMap((raw) => {
119
154
  const line = String(raw);
120
155
  if (visibleWidth(line) <= width - 6) return [line];
@@ -130,9 +165,20 @@ function createFace({ product, color = false, columns, env = process.env }) {
130
165
  frame(GLYPH.boxBottom, GLYPH.boxBottomEnd)
131
166
  ];
132
167
  };
133
- 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`)}`;
134
180
  const refusal = (message) => `${bar()} ${paint(identity.accent, GLYPH.cross)} ${message}`;
135
- return { identity, width, welcome, continues, step, relay, receipt, signOff, refusal, paint };
181
+ return { identity, width, nested, emitsProgress: progressFd !== null, welcome, continues, step, relay, receipt, outcome, signOff, refusal, paint };
136
182
  }
137
183
 
138
184
  // src/shell.ts
@@ -148,7 +194,7 @@ if [ -t 1 ] && [ -z "\${NO_COLOR:-}" ] && [ "\${TERM:-}" != 'dumb' ]; then
148
194
  fi
149
195
  FACE_NAME='${identity.name}'
150
196
  FACE_ACCENT='${identity.accent}'
151
- FACE_WARM='${identity.warm.replace(/'/gu, "'\\''")}'
197
+ FACE_WARM='${identity.installWarm.replace(/'/gu, "'\\''")}'
152
198
  FACE_MUTED='${PALETTE.muted}'
153
199
  FACE_GREEN='${PALETTE.green}'
154
200
 
@@ -161,11 +207,34 @@ paint() {
161
207
  fi
162
208
  }
163
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
+
164
233
  face_welcome() {
165
- printf '%s %s %s\\n' "$(paint "$FACE_ACCENT" '\u25C6')" "$(paint "$FACE_ACCENT" "$FACE_NAME")" '\u2014 Mutatis Mutandis'
166
- printf '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
167
- printf '%s %s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$FACE_WARM"
168
- 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')"
169
238
  }
170
239
 
171
240
  # face_step <title> <start epoch seconds> \u2014 one durable line for a finished step, measured.
@@ -179,7 +248,7 @@ face_step() {
179
248
  else
180
249
  face_pad=$(( 38 - \${#face_title} ))
181
250
  fi
182
- printf '%s %s %s%*s%s\\n' \\
251
+ face_print stdout '%s %s %s%*s%s\\n' \\
183
252
  "$(paint "$FACE_MUTED" '\u2502')" "$(paint "$FACE_GREEN" '\u2714')" "$face_title" "$face_pad" '' "$(paint "$FACE_MUTED" "\${face_secs}s")"
184
253
  }
185
254
 
@@ -187,9 +256,9 @@ face_step() {
187
256
  face_relay() {
188
257
  while IFS= read -r face_l; do
189
258
  if [ -n "$face_l" ]; then
190
- 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"
191
260
  else
192
- printf '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
261
+ face_print stdout '%s\\n' "$(paint "$FACE_MUTED" '\u2502')"
193
262
  fi
194
263
  done
195
264
  }
@@ -220,16 +289,16 @@ face_receipt() {
220
289
  [ \${#face_text3} -gt "$face_w" ] && face_w=\${#face_text3}
221
290
  face_bar="$(paint "$FACE_ACCENT" '\u2502')"
222
291
  face_line="$(face_rule $((face_w + 4)))"
223
- printf '%s\\n' "$(paint "$FACE_ACCENT" "\u256D\${face_line}\u256E")"
224
- printf '%s %s %*s%s\\n' "$face_bar" "$(paint "$FACE_GREEN" '\u2714') $face_text1" "$((face_w - face_w1))" '' "$face_bar"
225
- printf '%s %s%*s %s\\n' "$face_bar" "$face_text2" "$((face_w - \${#face_text2}))" '' "$face_bar"
226
- printf '%s %s%*s %s\\n' "$face_bar" "$face_text3" "$((face_w - \${#face_text3}))" '' "$face_bar"
227
- printf '%s\\n' "$(paint "$FACE_ACCENT" "\u2570\${face_line}\u256F")"
228
- 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")"
229
298
  }
230
299
 
231
300
  face_refusal() {
232
- 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"
233
302
  }
234
303
 
235
304
  # face_status <text> \u2014 transient status, redrawn in place on STDERR (0.2.0). A served script has no
@@ -246,13 +315,13 @@ face_status() {
246
315
  2) face_f='\u25D3' ;;
247
316
  *) face_f='\u25D1' ;;
248
317
  esac
249
- 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"
250
319
  }
251
320
 
252
321
  # Always call before writing the durable line, or the finished step lands on a half-drawn frame.
253
322
  face_status_clear() {
254
323
  [ -t 2 ] || return 0
255
- printf '\\r\\033[2K' >&2
324
+ face_print spinner '\\r\\033[2K'
256
325
  }
257
326
  # <<< installer-face:end
258
327
  `;
@@ -271,7 +340,7 @@ $FaceDash = [char] 0x2014
271
340
 
272
341
  $FaceName = '${identity.name}'
273
342
  $FaceAccent = '${identity.accent}'
274
- $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 + '")}'
275
344
  $FaceMuted = '${PALETTE.muted}'
276
345
  $FaceGreen = '${PALETTE.green}'
277
346
 
@@ -288,6 +357,28 @@ catch {
288
357
  # A console that refuses UTF-8 still gets the glyphs its code page can draw.
289
358
  }
290
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
+
291
382
  function Write-Paint {
292
383
  param([string] $Sgr, [string] $Text)
293
384
  if ($FaceColor) {
@@ -297,10 +388,10 @@ function Write-Paint {
297
388
  }
298
389
 
299
390
  function Write-FaceWelcome {
300
- Write-Host ((Write-Paint $FaceAccent ([string] $FaceDiamond)) + ' ' + (Write-Paint $FaceAccent $FaceName) + ' ' + $FaceDash + ' Mutatis Mutandis')
301
- Write-Host (Write-Paint $FaceMuted ([string] $FaceBar))
302
- Write-Host ((Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + $FaceWarm)
303
- 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))
304
395
  }
305
396
 
306
397
  # A title longer than the step column is shortened in the middle: a path's start and tail identify it.
@@ -317,17 +408,17 @@ function Get-FaceTitle {
317
408
  function Write-FaceStep {
318
409
  param([string] $Title, [DateTime] $Started)
319
410
  $seconds = [Math]::Max(0, [int] ([DateTime]::UtcNow - $Started).TotalSeconds)
320
- 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')))
321
412
  }
322
413
 
323
414
  # Another program's output, kept inside the face at the step-title column.
324
415
  function Write-FaceRelay {
325
416
  param([string] $Line)
326
417
  if ([string]::IsNullOrWhiteSpace($Line)) {
327
- Write-Host (Write-Paint $FaceMuted ([string] $FaceBar))
418
+ Write-FaceOutput (Write-Paint $FaceMuted ([string] $FaceBar))
328
419
  return
329
420
  }
330
- Write-Host ((Write-Paint $FaceMuted ([string] $FaceBar)) + '${RELAY_INDENT}' + $Line)
421
+ Write-FaceOutput ((Write-Paint $FaceMuted ([string] $FaceBar)) + '${RELAY_INDENT}' + $Line)
331
422
  }
332
423
 
333
424
  function Write-FaceReceipt {
@@ -337,17 +428,17 @@ function Write-FaceReceipt {
337
428
  $third = 'Check health any time: ' + $Next
338
429
  $inner = ($first.Length, $second.Length, $third.Length | Measure-Object -Maximum).Maximum
339
430
  $rule = [string]::new([char] 0x2500, $inner + 4)
340
- Write-Host (Write-Paint $FaceAccent ([string] [char] 0x256D + $rule + [char] 0x256E))
431
+ Write-FaceOutput (Write-Paint $FaceAccent ([string] [char] 0x256D + $rule + [char] 0x256E))
341
432
  foreach ($text in @($first, $second, $third)) {
342
- 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)))
343
434
  }
344
- Write-Host (Write-Paint $FaceAccent ([string] [char] 0x2570 + $rule + [char] 0x256F))
345
- 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')))
346
437
  }
347
438
 
348
439
  function Write-FaceRefusal {
349
440
  param([string] $Message)
350
- [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)
351
442
  }
352
443
 
353
444
  # Transient status, redrawn in place on STDERR (0.2.0). Frames are emitted BY CODE POINT for the same
@@ -363,7 +454,7 @@ function Write-FaceStatus {
363
454
  }
364
455
  $script:FaceFrame = ($script:FaceFrame + 1) % $FaceFrames.Length
365
456
  $line = (Write-Paint $FaceMuted ([string] $FaceBar)) + ' ' + (Write-Paint $FaceMuted ([string] $FaceFrames[$script:FaceFrame])) + ' ' + $Text
366
- [Console]::Error.Write([string] [char] 13 + ([char] 27) + '[2K' + $line)
457
+ Write-FaceOutput -Channel spinner -NoNewline ([string] [char] 13 + ([char] 27) + '[2K' + $line)
367
458
  }
368
459
 
369
460
  # Always call before writing the durable line.
@@ -371,7 +462,7 @@ function Clear-FaceStatus {
371
462
  if ([Console]::IsErrorRedirected) {
372
463
  return
373
464
  }
374
- [Console]::Error.Write([string] [char] 13 + ([char] 27) + '[2K')
465
+ Write-FaceOutput -Channel spinner -NoNewline ([string] [char] 13 + ([char] 27) + '[2K')
375
466
  }
376
467
  # <<< installer-face:end
377
468
  `;
@@ -419,35 +510,109 @@ function clip(text, width) {
419
510
  }
420
511
 
421
512
  // src/spinner.ts
513
+ import { appendFileSync, writeSync as writeSync2 } from "node:fs";
514
+ import { Worker } from "node:worker_threads";
422
515
  var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
423
- 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;
424
543
  let timer = null;
425
- let text = "";
544
+ let worker = null;
545
+ let control = null;
546
+ let frames = [];
426
547
  let frame = 0;
427
- const clear = () => {
428
- 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)}`) : [];
429
559
  };
430
560
  const draw = () => {
431
- if (!animate) return;
432
- const line = face.step(text, null, "note").split("\n")[0].replace(GLYPH.dot, FRAMES[frame % FRAMES.length]);
433
- stream.write(`\r\x1B[2K${line}`);
434
- 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;
435
576
  };
436
577
  return {
437
- start(next) {
438
- text = next;
578
+ start(text, measure = null) {
579
+ if (!animate) return;
580
+ halt();
581
+ render(text, measure);
439
582
  frame = 0;
440
583
  draw();
441
- 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
+ }
442
605
  },
443
- say(next) {
444
- text = next;
606
+ say(text, measure = null) {
607
+ if (!animate) return;
608
+ pause();
609
+ render(text, measure);
445
610
  draw();
611
+ worker?.postMessage({ frames, frame });
446
612
  },
447
613
  stop() {
448
- if (timer) clearInterval(timer);
449
- timer = null;
450
- clear();
614
+ halt();
615
+ if (animate) write("\r\x1B[2K");
451
616
  }
452
617
  };
453
618
  }
@@ -475,7 +640,42 @@ function assertFaceConformance(lines, options) {
475
640
  const head = plain[0] ?? "";
476
641
  const signOffLine = `${GLYPH.diamond} ${identity.name} \xB7 Mutatis Mutandis`;
477
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
+ }
478
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
+ }
479
679
  if (options.tty === false) {
480
680
  for (const line of plain) {
481
681
  const glyph = [...line].find((char) => ALLOWED.has(char));
@@ -511,6 +711,14 @@ function assertFaceConformance(lines, options) {
511
711
  if (!plain[bottom].endsWith(GLYPH.boxBottomEnd)) fail("R5 receipt frame", `the bottom row does not pair its corners: ${JSON.stringify(plain[bottom])}`);
512
712
  const widths = new Set(plain.slice(top, bottom + 1).map(visibleWidth));
513
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
+ }
514
722
  }
515
723
  const inReceipt = (index) => top !== -1 && index >= top && index <= plain.findIndex((line, at) => at > top && line.startsWith(GLYPH.boxBottom));
516
724
  const welcomeEnd = opensWithWelcome ? 4 : 0;
@@ -544,7 +752,7 @@ function assertFaceConformance(lines, options) {
544
752
  if (opensWithWelcome) {
545
753
  const expected = `${GLYPH.diamond} ${identity.name} \u2014 Mutatis Mutandis`;
546
754
  if (head !== expected) fail("welcome is the product table's", `expected ${JSON.stringify(expected)}, got ${JSON.stringify(head)}`);
547
- 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));
548
756
  if (!warm) fail("welcome is the product table's", `the warm line for ${options.product} is missing or reworded`);
549
757
  }
550
758
  const painted = rendered.join("\n");
@@ -579,6 +787,391 @@ function assertScriptConformance(script, options) {
579
787
  }
580
788
  if (!/NO_COLOR/u.test(text)) fail("served script", "NO_COLOR must disable colour");
581
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
+ }
582
1175
  export {
583
1176
  GLYPH,
584
1177
  PALETTE,
@@ -586,15 +1179,24 @@ export {
586
1179
  SPINNER_FRAMES,
587
1180
  TITLE_COLUMN,
588
1181
  assertFaceConformance,
1182
+ assertInstallerTranscript,
589
1183
  assertScriptConformance,
590
1184
  createFace,
1185
+ createInstallerRun,
591
1186
  createSpinner,
592
1187
  faceWidth,
593
1188
  identityFor,
1189
+ readInstallerOutcome,
1190
+ renderPayloadEntry,
594
1191
  renderPowerShellFace,
595
1192
  renderRows,
596
1193
  renderShellFace,
1194
+ runInstaller,
1195
+ runMaintenance,
597
1196
  stripColor,
1197
+ validateInstallerOutcome,
1198
+ validateInstallerProduct,
598
1199
  visibleWidth,
599
- wrapWords
1200
+ wrapWords,
1201
+ writeInstallerOutcome
600
1202
  };