@mutmutco/installer-face 0.4.1 → 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,17 @@
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
+
9
+ # 0.4.2
10
+
11
+ - Preserve rollback and log facts, including retry guidance for early failures.
12
+ - Isolate injected output observers while keeping terminal and filesystem failures visible.
13
+ - Generate embedded-runtime convergence from a declaration without global npm installation.
14
+
1
15
  # 0.4.1
2
16
 
3
17
  - Capture bootstrap output and validate recorded installer transcripts.
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,26 +36,42 @@ 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.
40
+ For an embedded-runtime product, supply `installedRuntime: { entry, package, installRootEnv }`;
41
+ `converge` then contains entry arguments rather than a binary name. The shared entry resolves the
42
+ installed product and re-enters it with `--payload` and `--installed`, without invoking npm.
30
43
 
31
44
  The package owns greeting, phase names, spinner, surface status, receipt and sign-off.
32
45
  `phase` accepts preflight, resolve, download, verify, install, activate, doctor, sign-in, check,
33
- arm and verify-release. Its facts are state (running/ok/fail/note), seconds, measure and safe detail.
46
+ arm, verify-release and rollback. Its facts are state (running/ok/fail/note), seconds, measure and safe detail.
34
47
  `surface` accepts updated/current/failed/skipped/retry/pending/kept. `finish` accepts version,
35
- total, updated, failed and optional retry, dryRun, installed, deferred and safe detail.
48
+ total, updated, failed and optional retry, dryRun, installed, deferred, operationFailed and safe detail.
49
+ Failure facts can include rollback (`completed`, `partial`, `not-needed`, `unknown`), logPath,
50
+ or logState (`unavailable`, `omitted`). Early operation failures retain retry guidance even with zero surfaces.
36
51
  Missing version or deferred work never reports ready. Counts must describe the same operation set.
37
52
  Pass `dryRun: true` in options so individual rows also say “would update”.
38
53
 
39
54
  `operation: "install"` selects the canonical first-install welcome.
40
55
  `start()` and `finish()` are idempotent. `stop()` clears transient work. The orchestration wrapper
41
56
  always stops the spinner, including when an operation throws; the caller retains error handling.
42
- 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.
43
61
 
44
62
  `signIn({url, code})` owns device-login instructions. The code is visible in the terminal and
45
63
  redacted in transcripts. `relay(text, channel)` and `milestone({step,state,ms})` accept only safe
46
64
  child diagnostics: never pass authentication responses, credentials or raw provider errors.
47
65
 
48
- 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.
69
+ Omit `write` for production terminal output and worker animation during synchronous work.
70
+ An injected `write` observer uses timer animation; its exceptions are retained in `run.errors`.
71
+ Default terminal writes and transcript/outcome filesystem failures remain operation errors.
49
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.
50
75
  Nested processes suppress greetings and send their final outcome to the console owner.
51
76
  The private outcome channel works through Windows command shims as well as direct child processes;
52
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");
@@ -794,7 +794,7 @@ import { appendFileSync as appendFileSync2 } from "node:fs";
794
794
  // src/outcome.ts
795
795
  import { readFileSync, writeFileSync } from "node:fs";
796
796
  var counts = ["total", "updated", "failed"];
797
- var strings = ["version", "retry", "detail"];
797
+ var strings = ["version", "retry", "detail", "logPath"];
798
798
  var flags = ["dryRun", "installed", "deferred", "operationFailed"];
799
799
  function validateInstallerOutcome(value) {
800
800
  const invalid = () => {
@@ -802,12 +802,15 @@ function validateInstallerOutcome(value) {
802
802
  };
803
803
  if (!value || typeof value !== "object" || Array.isArray(value)) return invalid();
804
804
  const facts = value;
805
- const allowed = [...counts, ...strings, ...flags];
805
+ const allowed = [...counts, ...strings, ...flags, "rollback", "logState"];
806
806
  if (Object.keys(facts).some((key) => !allowed.includes(key))) return invalid();
807
807
  for (const key of counts) if (!Number.isSafeInteger(facts[key]) || facts[key] < 0) return invalid();
808
808
  if (facts.updated + facts.failed > facts.total) return invalid();
809
809
  for (const key of strings) if (facts[key] !== void 0 && (typeof facts[key] !== "string" || facts[key].length > 4096)) return invalid();
810
810
  for (const key of flags) if (facts[key] !== void 0 && typeof facts[key] !== "boolean") return invalid();
811
+ if (facts.rollback !== void 0 && !["completed", "partial", "not-needed", "unknown"].includes(facts.rollback)) return invalid();
812
+ if (facts.logState !== void 0 && !["unavailable", "omitted"].includes(facts.logState)) return invalid();
813
+ if (facts.logPath !== void 0 && (!facts.logPath.trim() || /[\x00-\x1f\x7f]/u.test(facts.logPath))) return invalid();
811
814
  return { ...facts };
812
815
  }
813
816
  function writeInstallerOutcome(path, value) {
@@ -880,7 +883,8 @@ var PHASES = {
880
883
  verify: ["Verifying the payload", "Verified the payload"],
881
884
  install: ["Installing the product", "Installed the product"],
882
885
  activate: ["Activating surfaces", "Activated surfaces"],
883
- doctor: ["Checking health", "Checked health"]
886
+ doctor: ["Checking health", "Checked health"],
887
+ rollback: ["Restoring the previous version", "Restored the previous version"]
884
888
  };
885
889
  function createInstallerRun(value, options = {}) {
886
890
  const declaration = validateInstallerProduct(value);
@@ -891,11 +895,18 @@ function createInstallerRun(value, options = {}) {
891
895
  product: declaration.product,
892
896
  columns: options.columns,
893
897
  env,
894
- color: tty && options.color !== false && env.NO_COLOR === void 0
898
+ color: tty && options.color !== false && env.NO_COLOR === void 0 && env.TERM !== "dumb"
895
899
  });
896
- const write = options.write ?? ((text, channel) => {
900
+ const errors = [];
901
+ const write = options.write ? (text, channel) => {
902
+ try {
903
+ options.write(text, channel);
904
+ } catch (error) {
905
+ errors.push(`installer output observer: ${error instanceof Error ? error.message : String(error)}`);
906
+ }
907
+ } : (text, channel) => {
897
908
  (channel === "stdout" ? process.stdout : process.stderr).write(text);
898
- });
909
+ };
899
910
  const emit = (text, channel = "stdout", recorded = text) => {
900
911
  if (!text) return;
901
912
  write(text, channel);
@@ -920,9 +931,13 @@ function createInstallerRun(value, options = {}) {
920
931
  const start = () => {
921
932
  if (started || finished) return;
922
933
  started = true;
934
+ if (options.quiet) return;
923
935
  const welcome = face.welcome();
924
936
  if (tty) lines(welcome);
925
- else if (welcome.length) lines([`${face.identity.name} \u2014 Mutatis Mutandis`, options.operation === "install" ? face.identity.installWarm : face.identity.warm]);
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
+ }
926
941
  };
927
942
  const durable = (title, measure, kind) => {
928
943
  spinner.stop();
@@ -930,6 +945,9 @@ function createInstallerRun(value, options = {}) {
930
945
  if (rendered) lines([tty ? rendered : `${kind === "fail" ? "Failed: " : ""}${title}${measure === null ? "" : ` (${typeof measure === "number" ? `${Math.max(0, Math.round(measure))}s` : measure})`}`]);
931
946
  };
932
947
  const run = {
948
+ get errors() {
949
+ return [...errors];
950
+ },
933
951
  start,
934
952
  phase(id, facts = {}) {
935
953
  if (finished) throw new Error("installer run already finished");
@@ -949,12 +967,13 @@ function createInstallerRun(value, options = {}) {
949
967
  const surface = declaration.surfaces.find((surface2) => surface2.id === facts.id);
950
968
  if (!surface) throw new Error("installer run: undeclared surface");
951
969
  start();
952
- 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}` : "";
953
971
  const status = { updated: options.dryRun ? "would update" : "updated", current: "already current", failed: "failed", skipped: "skipped", retry: "retrying", pending: "pending", kept: "kept" }[facts.state];
954
972
  if (!status) throw new Error("installer run: unknown surface state");
955
- 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}` : "";
956
975
  const completed = ["updated", "current", "kept", "skipped"].includes(facts.state);
957
- durable(`${facts.id}${versions} \xB7 ${status}${activation}`, completed ? facts.seconds ?? null : null, facts.state === "failed" ? "fail" : facts.state === "updated" ? "ok" : "note");
976
+ durable(`${facts.id}${versions} ${separator} ${status}${activation}`, completed ? facts.seconds ?? null : null, facts.state === "failed" ? "fail" : facts.state === "updated" ? "ok" : "note");
958
977
  if (facts.detail) run.relay(facts.detail);
959
978
  },
960
979
  milestone({ step, state, ms }) {
@@ -981,6 +1000,20 @@ function createInstallerRun(value, options = {}) {
981
1000
  `);
982
1001
  }
983
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
+ },
984
1017
  finish(facts) {
985
1018
  if (finished) return;
986
1019
  validateInstallerOutcome(facts);
@@ -988,6 +1021,7 @@ function createInstallerRun(value, options = {}) {
988
1021
  start();
989
1022
  spinner.stop();
990
1023
  finished = true;
1024
+ if (options.quiet && facts.updated === 0 && facts.failed === 0 && !facts.operationFailed) return;
991
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}.`;
992
1026
  const ready = facts.failed === 0 && !facts.dryRun && !facts.deferred && !facts.operationFailed && Boolean(facts.version);
993
1027
  const body = [
@@ -995,7 +1029,9 @@ function createInstallerRun(value, options = {}) {
995
1029
  changed,
996
1030
  ...facts.failed ? [`Failed ${facts.failed} of ${facts.total} surfaces.`] : [],
997
1031
  ...facts.detail ? [facts.detail] : [],
998
- facts.retry && facts.failed ? `Retry: ${facts.retry}` : `Check health any time: ${declaration.doctor}`
1032
+ ...facts.rollback ? [`Rollback: ${facts.rollback}`] : [],
1033
+ ...facts.logPath ? [`Log: ${facts.logPath}`] : facts.logState ? [`Log: ${facts.logState}`] : [],
1034
+ facts.retry && (facts.failed > 0 || facts.operationFailed) ? `Retry: ${facts.retry}` : `Check health any time: ${declaration.doctor}`
999
1035
  ];
1000
1036
  if (!face.nested || !env.MM_INSTALLER_OUTCOME_FILE && (facts.failed > 0 || facts.operationFailed)) {
1001
1037
  lines(tty ? face.receipt(body, { ready }) : body.map((row) => row.replace(/^[✔✖●] /u, "")));
@@ -1021,7 +1057,7 @@ async function runInstaller(value, operation, options) {
1021
1057
  }
1022
1058
 
1023
1059
  // src/payload.ts
1024
- function renderPayloadEntry({ tarballs, converge, shippedFlag }) {
1060
+ function renderPayloadEntry({ tarballs, converge, shippedFlag, installedRuntime }) {
1025
1061
  if (!Array.isArray(tarballs) || tarballs.length === 0 || tarballs.some((name) => typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/u.test(name))) {
1026
1062
  throw new Error("installer payload: tarballs must be relative .tgz basenames");
1027
1063
  }
@@ -1031,26 +1067,84 @@ function renderPayloadEntry({ tarballs, converge, shippedFlag }) {
1031
1067
  if (shippedFlag !== void 0 && !/^--[a-z][a-z0-9-]*$/u.test(shippedFlag)) {
1032
1068
  throw new Error("installer payload: invalid shipped flag");
1033
1069
  }
1070
+ if (installedRuntime !== void 0) {
1071
+ if (!installedRuntime || typeof installedRuntime.entry !== "string" || typeof installedRuntime.package !== "string" || !/^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(installedRuntime.entry) || installedRuntime.entry.split("/").some((part) => part === "." || part === "..") || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/u.test(installedRuntime.package) || installedRuntime.package.split("/").some((part) => part === "." || part === "..") || !Array.isArray(installedRuntime.installRootEnv) || installedRuntime.installRootEnv.some((name) => !/^[A-Z][A-Z0-9_]*$/u.test(name))) {
1072
+ throw new Error("installer payload: invalid installed runtime");
1073
+ }
1074
+ if (shippedFlag) throw new Error("installer payload: installed runtime reads its own payload manifest");
1075
+ return String.raw`// Generated by @mutmutco/installer-face. Uses the installed product, never a registry.
1076
+ import { spawnSync } from 'node:child_process';
1077
+ import { statSync } from 'node:fs';
1078
+ import { basename, dirname, join, resolve } from 'node:path';
1079
+
1080
+ const runtime = ${JSON.stringify(installedRuntime)};
1081
+ const argv = process.argv.slice(2);
1082
+ const at = argv.indexOf('--payload');
1083
+ const payloadDir = resolve(at >= 0 && argv[at + 1] ? argv[at + 1] : dirname(process.argv[1]));
1084
+ const productDir = dirname(payloadDir);
1085
+ const packaged = join('node_modules', runtime.package);
1086
+ const candidates = [
1087
+ ...runtime.installRootEnv.map((name) => process.env[name]),
1088
+ productDir, join(productDir, 'app'), join(productDir, 'current'),
1089
+ join(productDir, packaged), payloadDir, join(payloadDir, packaged),
1090
+ ];
1091
+ const root = candidates.find((candidate) => {
1092
+ if (!candidate) return false;
1093
+ try { return statSync(join(candidate, runtime.entry)).isFile(); }
1094
+ catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false; throw error; }
1095
+ });
1096
+ if (!root) {
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');
1106
+ process.exitCode = 1;
1107
+ } else {
1108
+ const viaLauncher = !/^node(\.exe)?$/iu.test(basename(process.execPath));
1109
+ const args = [...(viaLauncher ? ['--run'] : []), join(root, runtime.entry),
1110
+ ...${JSON.stringify(converge)}, '--payload', payloadDir, '--installed', root];
1111
+ const child = spawnSync(process.execPath, args, { cwd: root, stdio: 'inherit', windowsHide: true });
1112
+ if (child.error) {
1113
+ process.stderr.write('Installer could not re-enter the installed product: ' + child.error.message + '\n');
1114
+ process.exitCode = 1;
1115
+ } else process.exitCode = child.status === null ? 1 : child.status;
1116
+ }
1117
+ `;
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
+ }
1034
1122
  return String.raw`// Generated by @mutmutco/installer-face. Product code declares operations, never this program.
1035
1123
  import { spawnSync } from 'node:child_process';
1036
- import { readFileSync } from 'node:fs';
1037
- import { dirname, join } from 'node:path';
1124
+ import { readFileSync, statSync } from 'node:fs';
1125
+ import { dirname, isAbsolute, join } from 'node:path';
1038
1126
 
1039
1127
  const payloadDir = dirname(process.argv[1]);
1040
1128
  const tarballs = ${JSON.stringify(tarballs.map((name) => `./${name}`))};
1041
1129
  const converge = ${JSON.stringify(converge)};
1042
1130
  const shippedFlag = ${JSON.stringify(shippedFlag ?? null)};
1043
1131
 
1044
- function step(command, args) {
1045
- 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
+ }
1046
1138
  // One prequoted command and no argv avoids Node's args+shell warning for Windows .cmd shims.
1047
1139
  const result = process.platform === 'win32'
1048
- ? 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 })
1049
1141
  : spawnSync(command, args, options);
1050
1142
  if (result.error || result.status !== 0) {
1143
+ if (capture && result.stderr) process.stderr.write(result.stderr);
1051
1144
  process.stderr.write('Installer operation failed.\n');
1052
1145
  process.exit(result.status === null || result.status === undefined ? 1 : result.status);
1053
1146
  }
1147
+ return result.stdout ?? '';
1054
1148
  }
1055
1149
 
1056
1150
  step('npm', ['install', '--global', '--no-fund', '--no-audit', ...tarballs]);
@@ -1068,44 +1162,129 @@ if (shippedFlag) {
1068
1162
  }
1069
1163
  if (!shipped) process.stdout.write('Payload carries no shipped version; running the declared operation.\n');
1070
1164
  }
1071
- 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] : [])]);
1072
1183
  `;
1073
1184
  }
1074
1185
 
1075
1186
  // src/maintenance.ts
1076
1187
  async function runMaintenance(product, args, dependencies) {
1077
1188
  const json = args.includes("--json");
1189
+ const quiet = args.includes("--quiet");
1078
1190
  const dryRun = args.includes("--dry-run");
1079
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);
1080
1195
  const run = json ? null : dependencies.run ?? createInstallerRun(product, {
1081
- tty: Boolean(process.stdout.isTTY),
1082
- color: Boolean(process.stdout.isTTY) && !process.env.NO_COLOR,
1083
- columns: process.stdout.columns,
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,
1084
1201
  dryRun,
1202
+ quiet,
1085
1203
  operation: installed ? "install" : "update"
1086
1204
  });
1087
- run?.start();
1205
+ if (!quiet) run?.start();
1088
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
+ };
1089
1242
  const pending = [];
1090
1243
  let streaming = false;
1091
1244
  const emitArm = ({ arm, seconds }) => {
1245
+ if (quiet) return;
1092
1246
  run?.surface({
1093
1247
  id: arm.surface,
1094
1248
  from: arm.from ?? void 0,
1095
1249
  to: arm.to ?? void 0,
1096
1250
  state: arm.verdict === "fail" ? "failed" : arm.verdict === "defer" ? "retry" : arm.verdict === "skip" ? "kept" : arm.from === arm.to && !arm.launchStaged ? "current" : "updated",
1097
1251
  seconds,
1098
- detail: dependencies.diagnose(arm)
1252
+ detail: dependencies.diagnose?.(arm)
1099
1253
  });
1100
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
+ };
1101
1258
  try {
1102
1259
  const summary = await dependencies.engine({
1103
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
+ },
1104
1273
  shippedTarget: installed ? dependencies.shippedTarget : void 0,
1105
- narrate: args.includes("--verbose") ? (text) => run?.relay(text, "stderr") : void 0,
1106
- onTargetResolved: (target) => run?.phase("verify-release", { detail: target, state: "ok" }),
1107
- onPhase: () => run?.phase("check", { state: "running" }),
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,
1108
1285
  onArm: (arm) => {
1286
+ if (handedOff !== void 0) return;
1287
+ if (arm.verdict === "fail") armFailed = true;
1109
1288
  const seconds = (Date.now() - mark) / 1e3;
1110
1289
  mark = Date.now();
1111
1290
  pending.push({ arm, seconds });
@@ -1113,43 +1292,68 @@ async function runMaintenance(product, args, dependencies) {
1113
1292
  if (streaming) for (const result of pending.splice(0)) emitArm(result);
1114
1293
  }
1115
1294
  });
1295
+ if (handedOff !== void 0) return handedOff;
1116
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
+ }
1117
1301
  run?.stop();
1118
- 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;
1119
1312
  }
1120
1313
  for (const arm of pending) emitArm(arm);
1121
1314
  const waiting = summary.surfaces.filter((surface) => surface.present && surface.action === "arm-pending");
1122
- for (const surface of waiting) run?.surface({ id: surface.id, state: "pending" });
1315
+ if (!quiet) for (const surface of waiting) run?.surface({ id: surface.id, state: "pending" });
1123
1316
  let schedule = null;
1124
- if (installed && !dryRun) {
1125
- const started = Date.now();
1317
+ if (installed && !dryRun && dependencies.schedule) {
1318
+ phase({ id: "arm", state: "running" });
1126
1319
  schedule = dependencies.schedule();
1127
- run?.phase("arm", {
1128
- state: schedule.ok ? "ok" : "fail",
1129
- seconds: (Date.now() - started) / 1e3,
1130
- detail: schedule.ok ? void 0 : schedule.detail
1131
- });
1320
+ if (schedule) phase({ id: "arm", state: schedule.ok ? "ok" : "fail", detail: schedule.ok ? void 0 : schedule.detail });
1132
1321
  }
1133
- 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);
1134
1324
  if (json) {
1135
- process.stdout.write(JSON.stringify(installed ? { convergence: summary, autoupdate: schedule } : summary, null, 2) + "\n");
1325
+ writeJson({ ...summary, exit }, schedule);
1136
1326
  } else {
1137
1327
  run.finish({
1138
1328
  version: summary.target ?? void 0,
1139
1329
  total: counts2.total,
1140
1330
  updated: counts2.updated,
1141
1331
  failed: counts2.failed,
1142
- operationFailed: summary.exit !== 0 || Boolean(schedule && !schedule.ok),
1143
- 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),
1144
1334
  dryRun,
1145
1335
  installed,
1146
- 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
1147
1341
  });
1148
1342
  }
1149
- return Math.max(summary.exit, schedule && !schedule.ok ? 1 : 0);
1343
+ return exit;
1150
1344
  } catch (error) {
1151
- run?.finish({ total: 0, updated: 0, failed: 0, operationFailed: true, detail: error.message });
1152
- 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");
1153
1357
  return 1;
1154
1358
  } finally {
1155
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/payload.d.ts CHANGED
@@ -3,5 +3,12 @@ export interface PayloadEntryOptions {
3
3
  tarballs: string[];
4
4
  converge: string[];
5
5
  shippedFlag?: string;
6
+ /** Converge an already installed product through this launcher's embedded runtime.
7
+ * In this mode converge contains the entry's arguments, without a binary name. */
8
+ installedRuntime?: {
9
+ entry: string;
10
+ package: string;
11
+ installRootEnv: string[];
12
+ };
6
13
  }
7
- export declare function renderPayloadEntry({ tarballs, converge, shippedFlag }: PayloadEntryOptions): string;
14
+ export declare function renderPayloadEntry({ tarballs, converge, shippedFlag, installedRuntime }: PayloadEntryOptions): string;
package/dist/run.d.ts CHANGED
@@ -24,6 +24,7 @@ declare const PHASES: {
24
24
  readonly install: readonly ["Installing the product", "Installed the product"];
25
25
  readonly activate: readonly ["Activating surfaces", "Activated surfaces"];
26
26
  readonly doctor: readonly ["Checking health", "Checked health"];
27
+ readonly rollback: readonly ["Restoring the previous version", "Restored the previous version"];
27
28
  };
28
29
  export type InstallerPhase = keyof typeof PHASES;
29
30
  export type InstallerChannel = 'stdout' | 'stderr' | 'spinner';
@@ -31,10 +32,13 @@ export interface InstallerRunOptions {
31
32
  operation?: 'install' | 'update';
32
33
  tty?: boolean;
33
34
  dryRun?: boolean;
35
+ quiet?: boolean;
34
36
  color?: boolean;
35
37
  columns?: number;
36
38
  env?: Readonly<NodeJS.ProcessEnv>;
37
39
  animate?: boolean;
40
+ /** Optional output observer. Exceptions are recorded in run.errors, never operation failures.
41
+ * Omit for production terminal output: the default worker animates during synchronous work. */
38
42
  write?: (text: string, channel: InstallerChannel) => void;
39
43
  }
40
44
  export interface InstallerFinish {
@@ -48,6 +52,9 @@ export interface InstallerFinish {
48
52
  detail?: string;
49
53
  deferred?: boolean;
50
54
  operationFailed?: boolean;
55
+ rollback?: 'completed' | 'partial' | 'not-needed' | 'unknown';
56
+ logPath?: string;
57
+ logState?: 'unavailable' | 'omitted';
51
58
  }
52
59
  export interface InstallerPhaseFacts {
53
60
  state?: 'running' | StepKind;
@@ -65,6 +72,7 @@ export interface InstallerSurfaceFacts {
65
72
  }
66
73
  /** One owner for the entire run. Callers supply measured facts, never terminal prose. */
67
74
  export declare function createInstallerRun(value: unknown, options?: InstallerRunOptions): {
75
+ readonly errors: readonly string[];
68
76
  start: () => void;
69
77
  phase(id: InstallerPhase, facts?: InstallerPhaseFacts): void;
70
78
  surface(facts: InstallerSurfaceFacts): void;
@@ -78,6 +86,7 @@ export declare function createInstallerRun(value: unknown, options?: InstallerRu
78
86
  code: string;
79
87
  }): void;
80
88
  relay(text: string, channel?: InstallerChannel, record?: boolean): void;
89
+ cancel(): void;
81
90
  finish(facts: InstallerFinish): void;
82
91
  stop(): void;
83
92
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/installer-face",
3
- "version": "0.4.1",
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",