@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 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 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,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
- 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();
1145
- let mark = Date.now();
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 = ({ arm, seconds }) => {
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) => run?.relay(text, "stderr") : void 0,
1163
- onTargetResolved: (target) => run?.phase("verify-release", { detail: target, state: "ok" }),
1164
- onPhase: () => run?.phase("check", { state: "running" }),
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
- const seconds = (Date.now() - mark) / 1e3;
1167
- mark = Date.now();
1168
- pending.push({ arm, seconds });
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
- return dependencies.handOff(summary.reexec, args, { ...process.env, MM_FACE_CONTINUES: "welcome" });
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
- const started = Date.now();
1314
+ if (installed && !dryRun && dependencies.schedule) {
1315
+ phase({ id: "arm", state: "running" });
1183
1316
  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
- });
1317
+ if (schedule) phase({ id: "arm", state: schedule.ok ? "ok" : "fail", detail: schedule.ok ? void 0 : schedule.detail });
1189
1318
  }
1190
- const counts2 = dependencies.counts(summary);
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
- process.stdout.write(JSON.stringify(installed ? { convergence: summary, autoupdate: schedule } : summary, null, 2) + "\n");
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: summary.exit !== 0 || Boolean(schedule && !schedule.ok),
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 Math.max(summary.exit, schedule && !schedule.ok ? 1 : 0);
1340
+ return exit;
1207
1341
  } catch (error) {
1208
- run?.finish({ total: 0, updated: 0, failed: 0, operationFailed: true, detail: error.message });
1209
- if (json) throw error;
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();
@@ -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: (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;
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.2",
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",