@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/CHANGELOG.md +14 -0
- package/README.md +44 -20
- package/dist/conformance.d.ts +8 -0
- package/dist/face.d.ts +35 -5
- package/dist/index.d.ts +10 -1
- package/dist/index.js +666 -64
- package/dist/maintenance.d.ts +52 -0
- package/dist/outcome.d.ts +6 -0
- package/dist/payload.d.ts +7 -0
- package/dist/products.d.ts +6 -0
- package/dist/run.d.ts +86 -0
- package/dist/spinner.d.ts +9 -12
- package/dist/transcript.d.ts +3 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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").
|
|
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
|
|
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.
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
+
face_print stdout '%s${RELAY_INDENT}%s\\n' "$(paint "$FACE_MUTED" '\u2502')" "$face_l"
|
|
191
260
|
else
|
|
192
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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-
|
|
301
|
-
Write-
|
|
302
|
-
Write-
|
|
303
|
-
Write-
|
|
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-
|
|
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-
|
|
418
|
+
Write-FaceOutput (Write-Paint $FaceMuted ([string] $FaceBar))
|
|
328
419
|
return
|
|
329
420
|
}
|
|
330
|
-
Write-
|
|
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-
|
|
431
|
+
Write-FaceOutput (Write-Paint $FaceAccent ([string] [char] 0x256D + $rule + [char] 0x256E))
|
|
341
432
|
foreach ($text in @($first, $second, $third)) {
|
|
342
|
-
Write-
|
|
433
|
+
Write-FaceOutput ((Write-Paint $FaceAccent ([string] $FaceBar)) + ' ' + $text.PadRight($inner) + ' ' + (Write-Paint $FaceAccent ([string] $FaceBar)))
|
|
343
434
|
}
|
|
344
|
-
Write-
|
|
345
|
-
Write-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
544
|
+
let worker = null;
|
|
545
|
+
let control = null;
|
|
546
|
+
let frames = [];
|
|
426
547
|
let frame = 0;
|
|
427
|
-
const
|
|
428
|
-
if (
|
|
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 (
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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(
|
|
438
|
-
|
|
578
|
+
start(text, measure = null) {
|
|
579
|
+
if (!animate) return;
|
|
580
|
+
halt();
|
|
581
|
+
render(text, measure);
|
|
439
582
|
frame = 0;
|
|
440
583
|
draw();
|
|
441
|
-
if (
|
|
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(
|
|
444
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
};
|