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