@mutmutco/installer-face 0.4.2 → 0.4.4
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 +13 -0
- package/README.md +19 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +188 -44
- package/dist/maintenance.d.ts +59 -15
- package/dist/run.d.ts +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
# 0.4.4
|
|
2
|
+
|
|
3
|
+
- Report engine-measured surface times without including release discovery or callback delays.
|
|
4
|
+
- Leave unmeasured surfaces untimed while preserving measured zero durations.
|
|
5
|
+
|
|
6
|
+
# 0.4.3
|
|
7
|
+
|
|
8
|
+
- Resolve freshly installed npm commands outside the inherited PATH.
|
|
9
|
+
- Preserve typed engine phases, safe authentication relay and product JSON through shared maintenance.
|
|
10
|
+
- Own quiet output, cancellation and failure diagnostics in the shared lifecycle.
|
|
11
|
+
- Preserve mid-run authentication handoff, measured progress timing and terminal routing.
|
|
12
|
+
- Use ASCII plain labels without changing Unicode paths, names or authorization URLs.
|
|
13
|
+
|
|
1
14
|
# 0.4.2
|
|
2
15
|
|
|
3
16
|
- Preserve rollback and log facts, including retry guidance for early failures.
|
package/README.md
CHANGED
|
@@ -20,6 +20,15 @@ only shared code and a shared guard can.
|
|
|
20
20
|
Product install/update entrypoints call `runMaintenance(declaration, args, engines)`.
|
|
21
21
|
The package owns phase ordering, self-update handoff, scheduler completion, outcome reporting and
|
|
22
22
|
cleanup. Product engines return version, surface and failure facts; they do not draw a run.
|
|
23
|
+
Engines receive `onPhaseEvent({ id, state, detail?, measure?, measured? })` for phase facts.
|
|
24
|
+
Repeated running progress retains the original start time; the package measures elapsed time.
|
|
25
|
+
Typed `measured` facts carry percent, bytes, or a count with an optional total. Unknown totals stay unknown.
|
|
26
|
+
Each completed phase needs its own verdict: starting another phase does not imply success.
|
|
27
|
+
`relay(text, channel?, record?)` defaults to live-only output, keeping authorization URLs out of transcripts.
|
|
28
|
+
Optional `jsonResult(summary, schedule)` preserves product machine output while the shared owner serializes it.
|
|
29
|
+
`safeErrorDetail(error)` supplies an actionable, non-sensitive failure message; the default is generic.
|
|
30
|
+
Scheduling and self-handoff callbacks are optional, but a requested handoff without an implementation fails.
|
|
31
|
+
Return `cancelled: true` to end without readiness. The package also owns `--quiet` output selection.
|
|
23
32
|
The declaration contains the canonical product key (`mmi` and `jerv` aliases are accepted), gate
|
|
24
33
|
URL, canonical doctor command, and a nonempty unique surface list
|
|
25
34
|
`{ id, npm?, bin?, kind?, activation? }`.
|
|
@@ -27,6 +36,7 @@ URL, canonical doctor command, and a nonempty unique surface list
|
|
|
27
36
|
`createInstallerRun` is the lower-level owner used by the shared launcher, not a reason for each
|
|
28
37
|
product to author another installer. `renderPayloadEntry` generates the npm-install/converge entry
|
|
29
38
|
from tarball names and a declared convergence command. Product release scripts supply those facts.
|
|
39
|
+
The npm mode resolves that command from the global prefix after installation; it never relies on a refreshed PATH.
|
|
30
40
|
For an embedded-runtime product, supply `installedRuntime: { entry, package, installRootEnv }`;
|
|
31
41
|
`converge` then contains entry arguments rather than a binary name. The shared entry resolves the
|
|
32
42
|
installed product and re-enters it with `--payload` and `--installed`, without invoking npm.
|
|
@@ -44,17 +54,24 @@ Pass `dryRun: true` in options so individual rows also say “would update”.
|
|
|
44
54
|
`operation: "install"` selects the canonical first-install welcome.
|
|
45
55
|
`start()` and `finish()` are idempotent. `stop()` clears transient work. The orchestration wrapper
|
|
46
56
|
always stops the spinner, including when an operation throws; the caller retains error handling.
|
|
47
|
-
Use the
|
|
57
|
+
Use the shared maintenance handoff for self-reexec. For a mid-engine authentication retry,
|
|
58
|
+
`withCustody` pauses the shared output and supplies completed `faceContinues` phases to the product
|
|
59
|
+
operation. Return `{ kind: "resume", value }` to continue, or `{ kind: "reexec", code }` when the
|
|
60
|
+
child owns the final result. The shared owner resumes timing or transfers receipt ownership.
|
|
48
61
|
|
|
49
62
|
`signIn({url, code})` owns device-login instructions. The code is visible in the terminal and
|
|
50
63
|
redacted in transcripts. `relay(text, channel)` and `milestone({step,state,ms})` accept only safe
|
|
51
64
|
child diagnostics: never pass authentication responses, credentials or raw provider errors.
|
|
52
65
|
|
|
53
|
-
|
|
66
|
+
Maintenance callers can declare `render: { tty, color, columns, env, animate }` without supplying
|
|
67
|
+
a writer or constructing a run. `--no-color`, `NO_COLOR`, and `TERM=dumb` disable color.
|
|
68
|
+
The lower-level owner also accepts an output observer for tests.
|
|
54
69
|
Omit `write` for production terminal output and worker animation during synchronous work.
|
|
55
70
|
An injected `write` observer uses timer animation; its exceptions are retained in `run.errors`.
|
|
56
71
|
Default terminal writes and transcript/outcome filesystem failures remain operation errors.
|
|
57
72
|
Non-TTY output is plain append-only text. NO_COLOR disables color, not the visual structure.
|
|
73
|
+
The shared plain lane uses ASCII labels and separators while preserving dynamic Unicode data.
|
|
74
|
+
Do not transliterate paths or authorization URLs through a consumer output callback.
|
|
58
75
|
Nested processes suppress greetings and send their final outcome to the console owner.
|
|
59
76
|
The private outcome channel works through Windows command shims as well as direct child processes;
|
|
60
77
|
a successful process exit never turns a deferred result into readiness.
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type { InstallerProduct, InstallerRun, InstallerRunOptions, InstallerPhas
|
|
|
14
14
|
export { renderPayloadEntry } from './payload.js';
|
|
15
15
|
export type { PayloadEntryOptions } from './payload.js';
|
|
16
16
|
export { runMaintenance } from './maintenance.js';
|
|
17
|
-
export type { MaintenanceArm, MaintenanceSummary } from './maintenance.js';
|
|
17
|
+
export type { MaintenanceArm, MaintenanceSummary, MaintenancePhaseEvent, MaintenanceCustodyResult, MaintenanceEngineOptions, MaintenanceSchedule } from './maintenance.js';
|
|
18
18
|
export { validateInstallerOutcome, readInstallerOutcome, writeInstallerOutcome } from './outcome.js';
|
|
19
19
|
export type { InstallerOutcome } from './outcome.js';
|
|
20
20
|
export { assertInstallerTranscript } from './transcript.js';
|
package/dist/index.js
CHANGED
|
@@ -655,7 +655,7 @@ function assertFaceConformance(lines, options) {
|
|
|
655
655
|
}
|
|
656
656
|
}
|
|
657
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}
|
|
658
|
+
const greetings = plain.filter((line) => ["\u2014", "-"].some((separator) => line.includes(`${identity.name} ${separator} Mutatis Mutandis`)));
|
|
659
659
|
if (greetings.length > 1) fail("R8 one welcome per run", "duplicate product greeting");
|
|
660
660
|
if (plain.filter((line) => line.startsWith(GLYPH.boxTop)).length > 1) {
|
|
661
661
|
fail("R8 one receipt per run", "duplicate receipt");
|
|
@@ -895,7 +895,7 @@ function createInstallerRun(value, options = {}) {
|
|
|
895
895
|
product: declaration.product,
|
|
896
896
|
columns: options.columns,
|
|
897
897
|
env,
|
|
898
|
-
color: tty && options.color !== false && env.NO_COLOR === void 0
|
|
898
|
+
color: tty && options.color !== false && env.NO_COLOR === void 0 && env.TERM !== "dumb"
|
|
899
899
|
});
|
|
900
900
|
const errors = [];
|
|
901
901
|
const write = options.write ? (text, channel) => {
|
|
@@ -931,9 +931,13 @@ function createInstallerRun(value, options = {}) {
|
|
|
931
931
|
const start = () => {
|
|
932
932
|
if (started || finished) return;
|
|
933
933
|
started = true;
|
|
934
|
+
if (options.quiet) return;
|
|
934
935
|
const welcome = face.welcome();
|
|
935
936
|
if (tty) lines(welcome);
|
|
936
|
-
else if (welcome.length)
|
|
937
|
+
else if (welcome.length) {
|
|
938
|
+
const warm = options.operation === "install" ? face.identity.installWarm : face.identity.warm;
|
|
939
|
+
lines([`${face.identity.name} - Mutatis Mutandis`, warm.replaceAll("\u2014", "-").replaceAll("\u2026", "...")]);
|
|
940
|
+
}
|
|
937
941
|
};
|
|
938
942
|
const durable = (title, measure, kind) => {
|
|
939
943
|
spinner.stop();
|
|
@@ -963,12 +967,13 @@ function createInstallerRun(value, options = {}) {
|
|
|
963
967
|
const surface = declaration.surfaces.find((surface2) => surface2.id === facts.id);
|
|
964
968
|
if (!surface) throw new Error("installer run: undeclared surface");
|
|
965
969
|
start();
|
|
966
|
-
const versions = facts.to ? facts.from && facts.from !== facts.to ? ` ${facts.from} \u2192 ${facts.to}` : ` ${facts.to}` : "";
|
|
970
|
+
const versions = facts.to ? facts.from && facts.from !== facts.to ? ` ${facts.from} ${tty ? "\u2192" : "->"} ${facts.to}` : ` ${facts.to}` : "";
|
|
967
971
|
const status = { updated: options.dryRun ? "would update" : "updated", current: "already current", failed: "failed", skipped: "skipped", retry: "retrying", pending: "pending", kept: "kept" }[facts.state];
|
|
968
972
|
if (!status) throw new Error("installer run: unknown surface state");
|
|
969
|
-
const
|
|
973
|
+
const separator = tty ? "\xB7" : "-";
|
|
974
|
+
const activation = facts.state === "updated" && surface.activation ? ` ${separator} ${surface.activation}` : "";
|
|
970
975
|
const completed = ["updated", "current", "kept", "skipped"].includes(facts.state);
|
|
971
|
-
durable(`${facts.id}${versions}
|
|
976
|
+
durable(`${facts.id}${versions} ${separator} ${status}${activation}`, completed ? facts.seconds ?? null : null, facts.state === "failed" ? "fail" : facts.state === "updated" ? "ok" : "note");
|
|
972
977
|
if (facts.detail) run.relay(facts.detail);
|
|
973
978
|
},
|
|
974
979
|
milestone({ step, state, ms }) {
|
|
@@ -995,6 +1000,20 @@ function createInstallerRun(value, options = {}) {
|
|
|
995
1000
|
`);
|
|
996
1001
|
}
|
|
997
1002
|
},
|
|
1003
|
+
cancel() {
|
|
1004
|
+
if (finished) return;
|
|
1005
|
+
if (env.MM_INSTALLER_OUTCOME_FILE) writeInstallerOutcome(
|
|
1006
|
+
env.MM_INSTALLER_OUTCOME_FILE,
|
|
1007
|
+
{ total: 0, updated: 0, failed: 0, deferred: true, detail: "Operation cancelled." }
|
|
1008
|
+
);
|
|
1009
|
+
start();
|
|
1010
|
+
spinner.stop();
|
|
1011
|
+
finished = true;
|
|
1012
|
+
if (!face.nested || !env.MM_INSTALLER_OUTCOME_FILE) {
|
|
1013
|
+
lines(tty ? face.receipt(["Operation cancelled."], { ready: false }) : ["Operation cancelled."]);
|
|
1014
|
+
}
|
|
1015
|
+
if (tty) lines([face.signOff()]);
|
|
1016
|
+
},
|
|
998
1017
|
finish(facts) {
|
|
999
1018
|
if (finished) return;
|
|
1000
1019
|
validateInstallerOutcome(facts);
|
|
@@ -1002,6 +1021,7 @@ function createInstallerRun(value, options = {}) {
|
|
|
1002
1021
|
start();
|
|
1003
1022
|
spinner.stop();
|
|
1004
1023
|
finished = true;
|
|
1024
|
+
if (options.quiet && facts.updated === 0 && facts.failed === 0 && !facts.operationFailed) return;
|
|
1005
1025
|
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}.`;
|
|
1006
1026
|
const ready = facts.failed === 0 && !facts.dryRun && !facts.deferred && !facts.operationFailed && Boolean(facts.version);
|
|
1007
1027
|
const body = [
|
|
@@ -1074,7 +1094,15 @@ const root = candidates.find((candidate) => {
|
|
|
1074
1094
|
catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false; throw error; }
|
|
1075
1095
|
});
|
|
1076
1096
|
if (!root) {
|
|
1077
|
-
|
|
1097
|
+
const archives = ${JSON.stringify(tarballs)}.map((name) => '"' + join(payloadDir, name) + '"').join(' ');
|
|
1098
|
+
const viaLauncher = !/^node(\.exe)?$/iu.test(basename(process.execPath));
|
|
1099
|
+
process.stderr.write([
|
|
1100
|
+
'Installer payload has no installed product to converge: ' + payloadDir,
|
|
1101
|
+
'NOT READY — nothing was armed and no host projection was written.',
|
|
1102
|
+
'Finish by installing the payload archive offline:',
|
|
1103
|
+
' npm install --global --offline ' + archives,
|
|
1104
|
+
'Then retry: "' + process.execPath + '" ' + (viaLauncher ? '--run ' : '') + '"' + process.argv[1] + '" --payload "' + payloadDir + '"',
|
|
1105
|
+
].join('\n') + '\n');
|
|
1078
1106
|
process.exitCode = 1;
|
|
1079
1107
|
} else {
|
|
1080
1108
|
const viaLauncher = !/^node(\.exe)?$/iu.test(basename(process.execPath));
|
|
@@ -1088,26 +1116,35 @@ if (!root) {
|
|
|
1088
1116
|
}
|
|
1089
1117
|
`;
|
|
1090
1118
|
}
|
|
1119
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(converge[0])) {
|
|
1120
|
+
throw new Error("installer payload: npm converge command must be a bin name");
|
|
1121
|
+
}
|
|
1091
1122
|
return String.raw`// Generated by @mutmutco/installer-face. Product code declares operations, never this program.
|
|
1092
1123
|
import { spawnSync } from 'node:child_process';
|
|
1093
|
-
import { readFileSync } from 'node:fs';
|
|
1094
|
-
import { dirname, join } from 'node:path';
|
|
1124
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
1125
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
1095
1126
|
|
|
1096
1127
|
const payloadDir = dirname(process.argv[1]);
|
|
1097
1128
|
const tarballs = ${JSON.stringify(tarballs.map((name) => `./${name}`))};
|
|
1098
1129
|
const converge = ${JSON.stringify(converge)};
|
|
1099
1130
|
const shippedFlag = ${JSON.stringify(shippedFlag ?? null)};
|
|
1100
1131
|
|
|
1101
|
-
function step(command, args) {
|
|
1102
|
-
const options = { cwd: payloadDir, stdio: 'inherit', windowsHide: true };
|
|
1132
|
+
function step(command, args, capture = false) {
|
|
1133
|
+
const options = { cwd: payloadDir, stdio: capture ? 'pipe' : 'inherit', windowsHide: true, encoding: 'utf8' };
|
|
1134
|
+
if (process.platform === 'win32' && [command, ...args].some((arg) => /[\x00-\x1f"%!]/u.test(arg))) {
|
|
1135
|
+
process.stderr.write('Installer command contains unsupported Windows shell characters.\n');
|
|
1136
|
+
process.exit(1);
|
|
1137
|
+
}
|
|
1103
1138
|
// One prequoted command and no argv avoids Node's args+shell warning for Windows .cmd shims.
|
|
1104
1139
|
const result = process.platform === 'win32'
|
|
1105
|
-
? spawnSync([command, ...args].map((arg) =>
|
|
1140
|
+
? spawnSync([command, ...args].map((arg) => /[\s&|<>^()]/u.test(arg) ? '"' + arg + '"' : arg).join(' '), [], { ...options, shell: true })
|
|
1106
1141
|
: spawnSync(command, args, options);
|
|
1107
1142
|
if (result.error || result.status !== 0) {
|
|
1143
|
+
if (capture && result.stderr) process.stderr.write(result.stderr);
|
|
1108
1144
|
process.stderr.write('Installer operation failed.\n');
|
|
1109
1145
|
process.exit(result.status === null || result.status === undefined ? 1 : result.status);
|
|
1110
1146
|
}
|
|
1147
|
+
return result.stdout ?? '';
|
|
1111
1148
|
}
|
|
1112
1149
|
|
|
1113
1150
|
step('npm', ['install', '--global', '--no-fund', '--no-audit', ...tarballs]);
|
|
@@ -1125,88 +1162,195 @@ if (shippedFlag) {
|
|
|
1125
1162
|
}
|
|
1126
1163
|
if (!shipped) process.stdout.write('Payload carries no shipped version; running the declared operation.\n');
|
|
1127
1164
|
}
|
|
1128
|
-
|
|
1165
|
+
// The new global bin directory may not be on this process's inherited PATH.
|
|
1166
|
+
const prefix = step('npm', ['prefix', '--global'], true).trim();
|
|
1167
|
+
if (!prefix || !isAbsolute(prefix)) {
|
|
1168
|
+
process.stderr.write('Installer could not resolve the global npm prefix.\n');
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
}
|
|
1171
|
+
const candidates = process.platform === 'win32'
|
|
1172
|
+
? [join(prefix, converge[0] + '.cmd'), join(prefix, converge[0])]
|
|
1173
|
+
: [join(prefix, 'bin', converge[0]), join(prefix, converge[0])];
|
|
1174
|
+
const command = candidates.find((candidate) => {
|
|
1175
|
+
try { return statSync(candidate).isFile(); }
|
|
1176
|
+
catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false; throw error; }
|
|
1177
|
+
});
|
|
1178
|
+
if (!command) {
|
|
1179
|
+
process.stderr.write('Installer could not find the installed command: ' + converge[0] + '\n');
|
|
1180
|
+
process.exit(1);
|
|
1181
|
+
}
|
|
1182
|
+
step(command, [...converge.slice(1), ...(shipped ? [shippedFlag, shipped] : [])]);
|
|
1129
1183
|
`;
|
|
1130
1184
|
}
|
|
1131
1185
|
|
|
1132
1186
|
// src/maintenance.ts
|
|
1133
1187
|
async function runMaintenance(product, args, dependencies) {
|
|
1134
1188
|
const json = args.includes("--json");
|
|
1189
|
+
const quiet = args.includes("--quiet");
|
|
1135
1190
|
const dryRun = args.includes("--dry-run");
|
|
1136
1191
|
const installed = args.includes("install");
|
|
1192
|
+
const render = dependencies.render;
|
|
1193
|
+
const env = render?.env ?? process.env;
|
|
1194
|
+
const tty = render?.tty ?? Boolean(process.stdout.isTTY);
|
|
1137
1195
|
const run = json ? null : dependencies.run ?? createInstallerRun(product, {
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1196
|
+
...render,
|
|
1197
|
+
tty,
|
|
1198
|
+
env,
|
|
1199
|
+
color: tty && render?.color !== false && !args.includes("--no-color") && env.TERM !== "dumb",
|
|
1200
|
+
columns: render?.columns ?? process.stdout.columns,
|
|
1141
1201
|
dryRun,
|
|
1202
|
+
quiet,
|
|
1142
1203
|
operation: installed ? "install" : "update"
|
|
1143
1204
|
});
|
|
1144
|
-
run?.start();
|
|
1145
|
-
let
|
|
1205
|
+
if (!quiet) run?.start();
|
|
1206
|
+
let active;
|
|
1207
|
+
let activeFacts;
|
|
1208
|
+
let handedOff;
|
|
1209
|
+
const shown = new Set((env.MM_FACE_CONTINUES ?? "").split(",").map((value) => value.trim()).filter(Boolean));
|
|
1210
|
+
if (!quiet && !json) shown.add("welcome");
|
|
1211
|
+
let phaseFailed = false;
|
|
1212
|
+
let armFailed = false;
|
|
1213
|
+
let knownFailure;
|
|
1214
|
+
const phaseStarted = /* @__PURE__ */ new Map();
|
|
1215
|
+
const phase = (event) => {
|
|
1216
|
+
if (handedOff !== void 0) return;
|
|
1217
|
+
if (event.state === "fail") phaseFailed = true;
|
|
1218
|
+
const started = phaseStarted.get(event.id);
|
|
1219
|
+
if (event.state === "running") {
|
|
1220
|
+
if (started === void 0) phaseStarted.set(event.id, Date.now());
|
|
1221
|
+
active = event.id;
|
|
1222
|
+
} else {
|
|
1223
|
+
phaseStarted.delete(event.id);
|
|
1224
|
+
if (active === event.id) active = void 0;
|
|
1225
|
+
}
|
|
1226
|
+
if (!quiet && event.state === "running" && event.detail) run?.relay(event.detail);
|
|
1227
|
+
const measured = event.measured;
|
|
1228
|
+
const measure = measured?.kind === "percent" ? `${Math.max(0, Math.min(100, Math.round(measured.percent)))}%` : measured?.kind === "bytes" ? `${measured.done}/${measured.total} B` : measured?.kind === "count" ? `${measured.done}${measured.total === void 0 ? "" : `/${measured.total}`}` : event.measure;
|
|
1229
|
+
const facts = {
|
|
1230
|
+
state: event.state,
|
|
1231
|
+
detail: event.detail,
|
|
1232
|
+
measure,
|
|
1233
|
+
...event.state !== "running" && started !== void 0 ? { seconds: (Date.now() - started) / 1e3 } : {}
|
|
1234
|
+
};
|
|
1235
|
+
if (event.state === "running") activeFacts = facts;
|
|
1236
|
+
if (!quiet) {
|
|
1237
|
+
run?.phase(event.id, facts);
|
|
1238
|
+
if (event.state === "ok" && run) shown.add(event.id);
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1146
1241
|
const pending = [];
|
|
1147
1242
|
let streaming = false;
|
|
1148
|
-
const emitArm = (
|
|
1243
|
+
const emitArm = (arm) => {
|
|
1244
|
+
if (quiet) return;
|
|
1149
1245
|
run?.surface({
|
|
1150
1246
|
id: arm.surface,
|
|
1151
1247
|
from: arm.from ?? void 0,
|
|
1152
1248
|
to: arm.to ?? void 0,
|
|
1153
1249
|
state: arm.verdict === "fail" ? "failed" : arm.verdict === "defer" ? "retry" : arm.verdict === "skip" ? "kept" : arm.from === arm.to && !arm.launchStaged ? "current" : "updated",
|
|
1154
|
-
seconds,
|
|
1155
|
-
detail: dependencies.diagnose(arm)
|
|
1250
|
+
...arm.durationMs === void 0 ? {} : { seconds: arm.durationMs / 1e3 },
|
|
1251
|
+
detail: dependencies.diagnose?.(arm)
|
|
1156
1252
|
});
|
|
1157
1253
|
};
|
|
1254
|
+
const writeJson = (summary, schedule) => {
|
|
1255
|
+
process.stdout.write(JSON.stringify(dependencies.jsonResult ? dependencies.jsonResult(summary, schedule) : installed ? { convergence: summary, autoupdate: schedule } : summary, null, 2) + "\n");
|
|
1256
|
+
};
|
|
1158
1257
|
try {
|
|
1159
1258
|
const summary = await dependencies.engine({
|
|
1160
1259
|
dryRun,
|
|
1260
|
+
withCustody: async (operation) => {
|
|
1261
|
+
if (handedOff !== void 0) throw new Error("installer terminal ownership already transferred");
|
|
1262
|
+
run?.stop();
|
|
1263
|
+
const result = await operation({ faceContinues: [...shown] });
|
|
1264
|
+
if (result.kind === "reexec") {
|
|
1265
|
+
if (!Number.isSafeInteger(result.code) || result.code < 0) throw new Error("invalid handoff exit code");
|
|
1266
|
+
handedOff = result.code;
|
|
1267
|
+
} else if (result.kind === "resume") {
|
|
1268
|
+
if (!quiet && active) run?.phase(active, activeFacts);
|
|
1269
|
+
} else throw new Error("invalid terminal custody result");
|
|
1270
|
+
return result;
|
|
1271
|
+
},
|
|
1161
1272
|
shippedTarget: installed ? dependencies.shippedTarget : void 0,
|
|
1162
|
-
narrate: args.includes("--verbose") ? (text) =>
|
|
1163
|
-
|
|
1164
|
-
|
|
1273
|
+
narrate: !quiet && !json && args.includes("--verbose") ? (text) => {
|
|
1274
|
+
if (handedOff === void 0) run?.relay(text, "stderr", false);
|
|
1275
|
+
} : void 0,
|
|
1276
|
+
relay: (text, channel = "stdout", record = false) => {
|
|
1277
|
+
if (handedOff === void 0) run?.relay(text, channel, record);
|
|
1278
|
+
},
|
|
1279
|
+
onTargetResolved: (target) => phase({ id: "verify-release", detail: target, state: "ok" }),
|
|
1280
|
+
onPhase: () => {
|
|
1281
|
+
if (!quiet && handedOff === void 0) run?.phase("check", { state: "running" });
|
|
1282
|
+
},
|
|
1283
|
+
onPhaseEvent: phase,
|
|
1165
1284
|
onArm: (arm) => {
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
pending.push(
|
|
1285
|
+
if (handedOff !== void 0) return;
|
|
1286
|
+
if (arm.verdict === "fail") armFailed = true;
|
|
1287
|
+
pending.push(arm);
|
|
1169
1288
|
if (pending.length > 1) streaming = true;
|
|
1170
1289
|
if (streaming) for (const result of pending.splice(0)) emitArm(result);
|
|
1171
1290
|
}
|
|
1172
1291
|
});
|
|
1292
|
+
if (handedOff !== void 0) return handedOff;
|
|
1173
1293
|
if (summary.reexec) {
|
|
1294
|
+
if (!dependencies.handOff) {
|
|
1295
|
+
knownFailure = "The updated version needs a handoff, but no handoff is configured.";
|
|
1296
|
+
throw new Error(knownFailure);
|
|
1297
|
+
}
|
|
1174
1298
|
run?.stop();
|
|
1175
|
-
|
|
1299
|
+
knownFailure = "The updated updater could not launch.";
|
|
1300
|
+
return await dependencies.handOff(summary.reexec, args, { ...process.env, MM_FACE_CONTINUES: "welcome" });
|
|
1301
|
+
}
|
|
1302
|
+
const counts2 = dependencies.counts(summary);
|
|
1303
|
+
validateInstallerOutcome({ total: counts2.total, updated: counts2.updated, failed: counts2.failed });
|
|
1304
|
+
if (!Number.isSafeInteger(counts2.retry) || counts2.retry < 0 || counts2.retry > counts2.total) throw new Error("invalid retry count");
|
|
1305
|
+
if (summary.cancelled && summary.exit === 0 && !phaseFailed && !armFailed && counts2.failed === 0 && counts2.updated === 0) {
|
|
1306
|
+
if (json) writeJson(summary, null);
|
|
1307
|
+
else run?.cancel();
|
|
1308
|
+
return 0;
|
|
1176
1309
|
}
|
|
1177
1310
|
for (const arm of pending) emitArm(arm);
|
|
1178
1311
|
const waiting = summary.surfaces.filter((surface) => surface.present && surface.action === "arm-pending");
|
|
1179
|
-
for (const surface of waiting) run?.surface({ id: surface.id, state: "pending" });
|
|
1312
|
+
if (!quiet) for (const surface of waiting) run?.surface({ id: surface.id, state: "pending" });
|
|
1180
1313
|
let schedule = null;
|
|
1181
|
-
if (installed && !dryRun) {
|
|
1182
|
-
|
|
1314
|
+
if (installed && !dryRun && dependencies.schedule) {
|
|
1315
|
+
phase({ id: "arm", state: "running" });
|
|
1183
1316
|
schedule = dependencies.schedule();
|
|
1184
|
-
|
|
1185
|
-
state: schedule.ok ? "ok" : "fail",
|
|
1186
|
-
seconds: (Date.now() - started) / 1e3,
|
|
1187
|
-
detail: schedule.ok ? void 0 : schedule.detail
|
|
1188
|
-
});
|
|
1317
|
+
if (schedule) phase({ id: "arm", state: schedule.ok ? "ok" : "fail", detail: schedule.ok ? void 0 : schedule.detail });
|
|
1189
1318
|
}
|
|
1190
|
-
const
|
|
1319
|
+
const failed = phaseFailed || armFailed || counts2.failed > 0 || Boolean(schedule && !schedule.ok);
|
|
1320
|
+
const exit = Math.max(summary.exit, failed ? 1 : 0);
|
|
1191
1321
|
if (json) {
|
|
1192
|
-
|
|
1322
|
+
writeJson({ ...summary, exit }, schedule);
|
|
1193
1323
|
} else {
|
|
1194
1324
|
run.finish({
|
|
1195
1325
|
version: summary.target ?? void 0,
|
|
1196
1326
|
total: counts2.total,
|
|
1197
1327
|
updated: counts2.updated,
|
|
1198
1328
|
failed: counts2.failed,
|
|
1199
|
-
operationFailed:
|
|
1200
|
-
deferred: Boolean(counts2.retry || waiting.length || summary.deferred || !summary.target),
|
|
1329
|
+
operationFailed: exit !== 0,
|
|
1330
|
+
deferred: !failed && Boolean(counts2.retry || waiting.length || summary.deferred || summary.cancelled || !summary.target),
|
|
1201
1331
|
dryRun,
|
|
1202
1332
|
installed,
|
|
1203
|
-
detail: schedule && !schedule.ok ? schedule.detail : summary.detail
|
|
1333
|
+
detail: schedule && !schedule.ok ? schedule.detail : summary.detail ?? (summary.cancelled ? "Operation cancelled." : void 0),
|
|
1334
|
+
retry: summary.retry,
|
|
1335
|
+
rollback: summary.rollback,
|
|
1336
|
+
logPath: summary.logPath,
|
|
1337
|
+
logState: summary.logState
|
|
1204
1338
|
});
|
|
1205
1339
|
}
|
|
1206
|
-
return
|
|
1340
|
+
return exit;
|
|
1207
1341
|
} catch (error) {
|
|
1208
|
-
|
|
1209
|
-
|
|
1342
|
+
if (handedOff !== void 0) throw error;
|
|
1343
|
+
let detail = knownFailure ?? (active === "sign-in" ? "Sign-in did not complete." : installed ? "Installation did not complete." : "Update did not complete.");
|
|
1344
|
+
if (!knownFailure && dependencies.safeErrorDetail) {
|
|
1345
|
+
try {
|
|
1346
|
+
detail = dependencies.safeErrorDetail(error);
|
|
1347
|
+
} catch {
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
if (active) phase({ id: active, state: "fail" });
|
|
1351
|
+
run?.relay(detail, "stderr");
|
|
1352
|
+
run?.finish({ total: 0, updated: 0, failed: 0, operationFailed: true, detail });
|
|
1353
|
+
if (json) process.stdout.write(JSON.stringify({ error: detail }) + "\n");
|
|
1210
1354
|
return 1;
|
|
1211
1355
|
} finally {
|
|
1212
1356
|
run?.stop();
|
package/dist/maintenance.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type InstallerRun } from './run.js';
|
|
1
|
+
import { type InstallerChannel, type InstallerFinish, type InstallerPhase, type InstallerPhaseFacts, type InstallerRunOptions, type InstallerRun } from './run.js';
|
|
2
2
|
/** Facts produced by a product's convergence engine. No terminal labels or layout. */
|
|
3
3
|
export interface MaintenanceArm {
|
|
4
4
|
surface: string;
|
|
@@ -8,6 +8,8 @@ export interface MaintenanceArm {
|
|
|
8
8
|
detail: string;
|
|
9
9
|
repeatFailure?: boolean;
|
|
10
10
|
launchStaged?: boolean;
|
|
11
|
+
/** Engine-measured surface work, excluding discovery and time between callbacks. */
|
|
12
|
+
durationMs?: number;
|
|
11
13
|
}
|
|
12
14
|
export interface MaintenanceSummary {
|
|
13
15
|
target: string | null;
|
|
@@ -19,34 +21,76 @@ export interface MaintenanceSummary {
|
|
|
19
21
|
exit: number;
|
|
20
22
|
detail?: string;
|
|
21
23
|
deferred?: boolean;
|
|
24
|
+
cancelled?: boolean;
|
|
25
|
+
retry?: string;
|
|
26
|
+
rollback?: InstallerFinish['rollback'];
|
|
27
|
+
logPath?: string;
|
|
28
|
+
logState?: InstallerFinish['logState'];
|
|
22
29
|
reexec: {
|
|
23
30
|
dist: string;
|
|
24
31
|
target: string;
|
|
25
32
|
from: string | null;
|
|
26
33
|
} | null;
|
|
27
34
|
}
|
|
35
|
+
export interface MaintenancePhaseEvent {
|
|
36
|
+
id: InstallerPhase;
|
|
37
|
+
state: 'running' | 'ok' | 'fail' | 'note';
|
|
38
|
+
detail?: string;
|
|
39
|
+
measure?: InstallerPhaseFacts['measure'];
|
|
40
|
+
measured?: {
|
|
41
|
+
kind: 'percent';
|
|
42
|
+
percent: number;
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'bytes';
|
|
45
|
+
done: number;
|
|
46
|
+
total: number;
|
|
47
|
+
} | {
|
|
48
|
+
kind: 'count';
|
|
49
|
+
done: number;
|
|
50
|
+
total?: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export type MaintenanceCustodyResult<T> = {
|
|
54
|
+
kind: 'reexec';
|
|
55
|
+
code: number;
|
|
56
|
+
} | {
|
|
57
|
+
kind: 'resume';
|
|
58
|
+
value: T;
|
|
59
|
+
};
|
|
60
|
+
export interface MaintenanceEngineOptions {
|
|
61
|
+
dryRun: boolean;
|
|
62
|
+
withCustody: <T>(operation: (context: {
|
|
63
|
+
faceContinues: readonly string[];
|
|
64
|
+
}) => Promise<MaintenanceCustodyResult<T>>) => Promise<MaintenanceCustodyResult<T>>;
|
|
65
|
+
shippedTarget?: string;
|
|
66
|
+
narrate?: (line: string) => void;
|
|
67
|
+
onTargetResolved?: (target: string, gate: string | null) => void;
|
|
68
|
+
/** Legacy engine notification; it does not imply a completed phase. */
|
|
69
|
+
onPhase?: (line: string) => void;
|
|
70
|
+
onPhaseEvent?: (event: MaintenancePhaseEvent) => void;
|
|
71
|
+
relay?: (text: string, channel?: InstallerChannel, record?: boolean) => void;
|
|
72
|
+
onArm?: (arm: MaintenanceArm) => void;
|
|
73
|
+
}
|
|
74
|
+
export type MaintenanceSchedule = {
|
|
75
|
+
ok: boolean;
|
|
76
|
+
detail: string;
|
|
77
|
+
} | null;
|
|
28
78
|
/** One install/update lifecycle, including scheduler ordering and self-update continuation. */
|
|
29
79
|
export declare function runMaintenance<S extends MaintenanceSummary>(product: unknown, args: string[], dependencies: {
|
|
30
|
-
engine: (options:
|
|
31
|
-
dryRun: boolean;
|
|
32
|
-
shippedTarget?: string;
|
|
33
|
-
narrate?: (line: string) => void;
|
|
34
|
-
onTargetResolved?: (target: string, gate: string | null) => void;
|
|
35
|
-
onPhase?: (line: string) => void;
|
|
36
|
-
onArm?: (arm: MaintenanceArm) => void;
|
|
37
|
-
}) => Promise<S>;
|
|
80
|
+
engine: (options: MaintenanceEngineOptions) => Promise<S>;
|
|
38
81
|
counts: (summary: S) => {
|
|
39
82
|
total: number;
|
|
40
83
|
updated: number;
|
|
41
84
|
failed: number;
|
|
42
85
|
retry: number;
|
|
43
86
|
};
|
|
44
|
-
diagnose
|
|
45
|
-
schedule
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
87
|
+
diagnose?: (arm: MaintenanceArm) => string | undefined;
|
|
88
|
+
schedule?: () => MaintenanceSchedule;
|
|
89
|
+
handOff?: (next: NonNullable<S['reexec']>, args: string[], env: NodeJS.ProcessEnv) => number | Promise<number>;
|
|
90
|
+
jsonResult?: (summary: S, schedule: MaintenanceSchedule) => object;
|
|
91
|
+
/** Return a safe, actionable diagnosis, never raw authentication output. */
|
|
92
|
+
safeErrorDetail?: (error: unknown) => string;
|
|
50
93
|
shippedTarget?: string;
|
|
51
94
|
run?: InstallerRun;
|
|
95
|
+
render?: Pick<InstallerRunOptions, 'tty' | 'color' | 'columns' | 'env' | 'animate'>;
|
|
52
96
|
}): Promise<number>;
|
package/dist/run.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export interface InstallerRunOptions {
|
|
|
32
32
|
operation?: 'install' | 'update';
|
|
33
33
|
tty?: boolean;
|
|
34
34
|
dryRun?: boolean;
|
|
35
|
+
quiet?: boolean;
|
|
35
36
|
color?: boolean;
|
|
36
37
|
columns?: number;
|
|
37
38
|
env?: Readonly<NodeJS.ProcessEnv>;
|
|
@@ -85,6 +86,7 @@ export declare function createInstallerRun(value: unknown, options?: InstallerRu
|
|
|
85
86
|
code: string;
|
|
86
87
|
}): void;
|
|
87
88
|
relay(text: string, channel?: InstallerChannel, record?: boolean): void;
|
|
89
|
+
cancel(): void;
|
|
88
90
|
finish(facts: InstallerFinish): void;
|
|
89
91
|
stop(): void;
|
|
90
92
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mutmutco/installer-face",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "The MM Terminal Line installer face: one renderer, the canonical product table, shell/PowerShell fragments for served one-liners, and the drift guard every surface runs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|