@mutmutco/installer-face 0.4.2 → 0.4.3

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 CHANGED
@@ -1,3 +1,11 @@
1
+ # 0.4.3
2
+
3
+ - Resolve freshly installed npm commands outside the inherited PATH.
4
+ - Preserve typed engine phases, safe authentication relay and product JSON through shared maintenance.
5
+ - Own quiet output, cancellation and failure diagnostics in the shared lifecycle.
6
+ - Preserve mid-run authentication handoff, measured progress timing and terminal routing.
7
+ - Use ASCII plain labels without changing Unicode paths, names or authorization URLs.
8
+
1
9
  # 0.4.2
2
10
 
3
11
  - 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 manual lifecycle for a self-reexec that transfers final receipt ownership to its child.
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
- TTY, color, width, environment, animation and output sink can be injected through options.
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} \u2014 Mutatis Mutandis`));
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) lines([`${face.identity.name} \u2014 Mutatis Mutandis`, options.operation === "install" ? face.identity.installWarm : face.identity.warm]);
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 activation = facts.state === "updated" && surface.activation ? ` \xB7 ${surface.activation}` : "";
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} \xB7 ${status}${activation}`, completed ? facts.seconds ?? null : null, facts.state === "failed" ? "fail" : facts.state === "updated" ? "ok" : "note");
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
- process.stderr.write('Installer payload has no installed product to converge: ' + payloadDir + '\n');
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) => /\s/.test(arg) ? '"' + arg + '"' : arg).join(' '), [], { ...options, shell: true })
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,44 +1162,129 @@ if (shippedFlag) {
1125
1162
  }
1126
1163
  if (!shipped) process.stdout.write('Payload carries no shipped version; running the declared operation.\n');
1127
1164
  }
1128
- step(converge[0], [...converge.slice(1), ...(shipped ? [shippedFlag, shipped] : [])]);
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
- tty: Boolean(process.stdout.isTTY),
1139
- color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR,
1140
- columns: process.stdout.columns,
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();
1205
+ if (!quiet) run?.start();
1145
1206
  let mark = Date.now();
1207
+ let active;
1208
+ let activeFacts;
1209
+ let handedOff;
1210
+ const shown = new Set((env.MM_FACE_CONTINUES ?? "").split(",").map((value) => value.trim()).filter(Boolean));
1211
+ if (!quiet && !json) shown.add("welcome");
1212
+ let phaseFailed = false;
1213
+ let armFailed = false;
1214
+ let knownFailure;
1215
+ const phaseStarted = /* @__PURE__ */ new Map();
1216
+ const phase = (event) => {
1217
+ if (handedOff !== void 0) return;
1218
+ if (event.state === "fail") phaseFailed = true;
1219
+ const started = phaseStarted.get(event.id);
1220
+ if (event.state === "running") {
1221
+ if (started === void 0) phaseStarted.set(event.id, Date.now());
1222
+ active = event.id;
1223
+ } else {
1224
+ phaseStarted.delete(event.id);
1225
+ if (active === event.id) active = void 0;
1226
+ }
1227
+ if (!quiet && event.state === "running" && event.detail) run?.relay(event.detail);
1228
+ const measured = event.measured;
1229
+ 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;
1230
+ const facts = {
1231
+ state: event.state,
1232
+ detail: event.detail,
1233
+ measure,
1234
+ ...event.state !== "running" && started !== void 0 ? { seconds: (Date.now() - started) / 1e3 } : {}
1235
+ };
1236
+ if (event.state === "running") activeFacts = facts;
1237
+ if (!quiet) {
1238
+ run?.phase(event.id, facts);
1239
+ if (event.state === "ok" && run) shown.add(event.id);
1240
+ }
1241
+ };
1146
1242
  const pending = [];
1147
1243
  let streaming = false;
1148
1244
  const emitArm = ({ arm, seconds }) => {
1245
+ if (quiet) return;
1149
1246
  run?.surface({
1150
1247
  id: arm.surface,
1151
1248
  from: arm.from ?? void 0,
1152
1249
  to: arm.to ?? void 0,
1153
1250
  state: arm.verdict === "fail" ? "failed" : arm.verdict === "defer" ? "retry" : arm.verdict === "skip" ? "kept" : arm.from === arm.to && !arm.launchStaged ? "current" : "updated",
1154
1251
  seconds,
1155
- detail: dependencies.diagnose(arm)
1252
+ detail: dependencies.diagnose?.(arm)
1156
1253
  });
1157
1254
  };
1255
+ const writeJson = (summary, schedule) => {
1256
+ process.stdout.write(JSON.stringify(dependencies.jsonResult ? dependencies.jsonResult(summary, schedule) : installed ? { convergence: summary, autoupdate: schedule } : summary, null, 2) + "\n");
1257
+ };
1158
1258
  try {
1159
1259
  const summary = await dependencies.engine({
1160
1260
  dryRun,
1261
+ withCustody: async (operation) => {
1262
+ if (handedOff !== void 0) throw new Error("installer terminal ownership already transferred");
1263
+ run?.stop();
1264
+ const result = await operation({ faceContinues: [...shown] });
1265
+ if (result.kind === "reexec") {
1266
+ if (!Number.isSafeInteger(result.code) || result.code < 0) throw new Error("invalid handoff exit code");
1267
+ handedOff = result.code;
1268
+ } else if (result.kind === "resume") {
1269
+ if (!quiet && active) run?.phase(active, activeFacts);
1270
+ } else throw new Error("invalid terminal custody result");
1271
+ return result;
1272
+ },
1161
1273
  shippedTarget: installed ? dependencies.shippedTarget : void 0,
1162
- narrate: args.includes("--verbose") ? (text) => run?.relay(text, "stderr") : void 0,
1163
- onTargetResolved: (target) => run?.phase("verify-release", { detail: target, state: "ok" }),
1164
- onPhase: () => run?.phase("check", { state: "running" }),
1274
+ narrate: !quiet && !json && args.includes("--verbose") ? (text) => {
1275
+ if (handedOff === void 0) run?.relay(text, "stderr", false);
1276
+ } : void 0,
1277
+ relay: (text, channel = "stdout", record = false) => {
1278
+ if (handedOff === void 0) run?.relay(text, channel, record);
1279
+ },
1280
+ onTargetResolved: (target) => phase({ id: "verify-release", detail: target, state: "ok" }),
1281
+ onPhase: () => {
1282
+ if (!quiet && handedOff === void 0) run?.phase("check", { state: "running" });
1283
+ },
1284
+ onPhaseEvent: phase,
1165
1285
  onArm: (arm) => {
1286
+ if (handedOff !== void 0) return;
1287
+ if (arm.verdict === "fail") armFailed = true;
1166
1288
  const seconds = (Date.now() - mark) / 1e3;
1167
1289
  mark = Date.now();
1168
1290
  pending.push({ arm, seconds });
@@ -1170,43 +1292,68 @@ async function runMaintenance(product, args, dependencies) {
1170
1292
  if (streaming) for (const result of pending.splice(0)) emitArm(result);
1171
1293
  }
1172
1294
  });
1295
+ if (handedOff !== void 0) return handedOff;
1173
1296
  if (summary.reexec) {
1297
+ if (!dependencies.handOff) {
1298
+ knownFailure = "The updated version needs a handoff, but no handoff is configured.";
1299
+ throw new Error(knownFailure);
1300
+ }
1174
1301
  run?.stop();
1175
- return dependencies.handOff(summary.reexec, args, { ...process.env, MM_FACE_CONTINUES: "welcome" });
1302
+ knownFailure = "The updated updater could not launch.";
1303
+ return await dependencies.handOff(summary.reexec, args, { ...process.env, MM_FACE_CONTINUES: "welcome" });
1304
+ }
1305
+ const counts2 = dependencies.counts(summary);
1306
+ validateInstallerOutcome({ total: counts2.total, updated: counts2.updated, failed: counts2.failed });
1307
+ if (!Number.isSafeInteger(counts2.retry) || counts2.retry < 0 || counts2.retry > counts2.total) throw new Error("invalid retry count");
1308
+ if (summary.cancelled && summary.exit === 0 && !phaseFailed && !armFailed && counts2.failed === 0 && counts2.updated === 0) {
1309
+ if (json) writeJson(summary, null);
1310
+ else run?.cancel();
1311
+ return 0;
1176
1312
  }
1177
1313
  for (const arm of pending) emitArm(arm);
1178
1314
  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" });
1315
+ if (!quiet) for (const surface of waiting) run?.surface({ id: surface.id, state: "pending" });
1180
1316
  let schedule = null;
1181
- if (installed && !dryRun) {
1182
- const started = Date.now();
1317
+ if (installed && !dryRun && dependencies.schedule) {
1318
+ phase({ id: "arm", state: "running" });
1183
1319
  schedule = dependencies.schedule();
1184
- run?.phase("arm", {
1185
- state: schedule.ok ? "ok" : "fail",
1186
- seconds: (Date.now() - started) / 1e3,
1187
- detail: schedule.ok ? void 0 : schedule.detail
1188
- });
1320
+ if (schedule) phase({ id: "arm", state: schedule.ok ? "ok" : "fail", detail: schedule.ok ? void 0 : schedule.detail });
1189
1321
  }
1190
- const counts2 = dependencies.counts(summary);
1322
+ const failed = phaseFailed || armFailed || counts2.failed > 0 || Boolean(schedule && !schedule.ok);
1323
+ const exit = Math.max(summary.exit, failed ? 1 : 0);
1191
1324
  if (json) {
1192
- process.stdout.write(JSON.stringify(installed ? { convergence: summary, autoupdate: schedule } : summary, null, 2) + "\n");
1325
+ writeJson({ ...summary, exit }, schedule);
1193
1326
  } else {
1194
1327
  run.finish({
1195
1328
  version: summary.target ?? void 0,
1196
1329
  total: counts2.total,
1197
1330
  updated: counts2.updated,
1198
1331
  failed: counts2.failed,
1199
- operationFailed: summary.exit !== 0 || Boolean(schedule && !schedule.ok),
1200
- deferred: Boolean(counts2.retry || waiting.length || summary.deferred || !summary.target),
1332
+ operationFailed: exit !== 0,
1333
+ deferred: !failed && Boolean(counts2.retry || waiting.length || summary.deferred || summary.cancelled || !summary.target),
1201
1334
  dryRun,
1202
1335
  installed,
1203
- detail: schedule && !schedule.ok ? schedule.detail : summary.detail
1336
+ detail: schedule && !schedule.ok ? schedule.detail : summary.detail ?? (summary.cancelled ? "Operation cancelled." : void 0),
1337
+ retry: summary.retry,
1338
+ rollback: summary.rollback,
1339
+ logPath: summary.logPath,
1340
+ logState: summary.logState
1204
1341
  });
1205
1342
  }
1206
- return Math.max(summary.exit, schedule && !schedule.ok ? 1 : 0);
1343
+ return exit;
1207
1344
  } catch (error) {
1208
- run?.finish({ total: 0, updated: 0, failed: 0, operationFailed: true, detail: error.message });
1209
- if (json) throw error;
1345
+ if (handedOff !== void 0) throw error;
1346
+ let detail = knownFailure ?? (active === "sign-in" ? "Sign-in did not complete." : installed ? "Installation did not complete." : "Update did not complete.");
1347
+ if (!knownFailure && dependencies.safeErrorDetail) {
1348
+ try {
1349
+ detail = dependencies.safeErrorDetail(error);
1350
+ } catch {
1351
+ }
1352
+ }
1353
+ if (active) phase({ id: active, state: "fail" });
1354
+ run?.relay(detail, "stderr");
1355
+ run?.finish({ total: 0, updated: 0, failed: 0, operationFailed: true, detail });
1356
+ if (json) process.stdout.write(JSON.stringify({ error: detail }) + "\n");
1210
1357
  return 1;
1211
1358
  } finally {
1212
1359
  run?.stop();
@@ -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;
@@ -19,34 +19,76 @@ export interface MaintenanceSummary {
19
19
  exit: number;
20
20
  detail?: string;
21
21
  deferred?: boolean;
22
+ cancelled?: boolean;
23
+ retry?: string;
24
+ rollback?: InstallerFinish['rollback'];
25
+ logPath?: string;
26
+ logState?: InstallerFinish['logState'];
22
27
  reexec: {
23
28
  dist: string;
24
29
  target: string;
25
30
  from: string | null;
26
31
  } | null;
27
32
  }
33
+ export interface MaintenancePhaseEvent {
34
+ id: InstallerPhase;
35
+ state: 'running' | 'ok' | 'fail' | 'note';
36
+ detail?: string;
37
+ measure?: InstallerPhaseFacts['measure'];
38
+ measured?: {
39
+ kind: 'percent';
40
+ percent: number;
41
+ } | {
42
+ kind: 'bytes';
43
+ done: number;
44
+ total: number;
45
+ } | {
46
+ kind: 'count';
47
+ done: number;
48
+ total?: number;
49
+ };
50
+ }
51
+ export type MaintenanceCustodyResult<T> = {
52
+ kind: 'reexec';
53
+ code: number;
54
+ } | {
55
+ kind: 'resume';
56
+ value: T;
57
+ };
58
+ export interface MaintenanceEngineOptions {
59
+ dryRun: boolean;
60
+ withCustody: <T>(operation: (context: {
61
+ faceContinues: readonly string[];
62
+ }) => Promise<MaintenanceCustodyResult<T>>) => Promise<MaintenanceCustodyResult<T>>;
63
+ shippedTarget?: string;
64
+ narrate?: (line: string) => void;
65
+ onTargetResolved?: (target: string, gate: string | null) => void;
66
+ /** Legacy engine notification; it does not imply a completed phase. */
67
+ onPhase?: (line: string) => void;
68
+ onPhaseEvent?: (event: MaintenancePhaseEvent) => void;
69
+ relay?: (text: string, channel?: InstallerChannel, record?: boolean) => void;
70
+ onArm?: (arm: MaintenanceArm) => void;
71
+ }
72
+ export type MaintenanceSchedule = {
73
+ ok: boolean;
74
+ detail: string;
75
+ } | null;
28
76
  /** One install/update lifecycle, including scheduler ordering and self-update continuation. */
29
77
  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>;
78
+ engine: (options: MaintenanceEngineOptions) => Promise<S>;
38
79
  counts: (summary: S) => {
39
80
  total: number;
40
81
  updated: number;
41
82
  failed: number;
42
83
  retry: number;
43
84
  };
44
- diagnose: (arm: MaintenanceArm) => string | undefined;
45
- schedule: () => {
46
- ok: boolean;
47
- detail: string;
48
- };
49
- handOff: (next: NonNullable<S['reexec']>, args: string[], env: NodeJS.ProcessEnv) => number;
85
+ diagnose?: (arm: MaintenanceArm) => string | undefined;
86
+ schedule?: () => MaintenanceSchedule;
87
+ handOff?: (next: NonNullable<S['reexec']>, args: string[], env: NodeJS.ProcessEnv) => number | Promise<number>;
88
+ jsonResult?: (summary: S, schedule: MaintenanceSchedule) => object;
89
+ /** Return a safe, actionable diagnosis, never raw authentication output. */
90
+ safeErrorDetail?: (error: unknown) => string;
50
91
  shippedTarget?: string;
51
92
  run?: InstallerRun;
93
+ render?: Pick<InstallerRunOptions, 'tty' | 'color' | 'columns' | 'env' | 'animate'>;
52
94
  }): 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.2",
3
+ "version": "0.4.3",
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",