@mutmutco/installer-face 0.4.1 → 0.4.2

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,9 @@
1
+ # 0.4.2
2
+
3
+ - Preserve rollback and log facts, including retry guidance for early failures.
4
+ - Isolate injected output observers while keeping terminal and filesystem failures visible.
5
+ - Generate embedded-runtime convergence from a declaration without global npm installation.
6
+
1
7
  # 0.4.1
2
8
 
3
9
  - Capture bootstrap output and validate recorded installer transcripts.
package/README.md CHANGED
@@ -27,12 +27,17 @@ URL, canonical doctor command, and a nonempty unique surface list
27
27
  `createInstallerRun` is the lower-level owner used by the shared launcher, not a reason for each
28
28
  product to author another installer. `renderPayloadEntry` generates the npm-install/converge entry
29
29
  from tarball names and a declared convergence command. Product release scripts supply those facts.
30
+ For an embedded-runtime product, supply `installedRuntime: { entry, package, installRootEnv }`;
31
+ `converge` then contains entry arguments rather than a binary name. The shared entry resolves the
32
+ installed product and re-enters it with `--payload` and `--installed`, without invoking npm.
30
33
 
31
34
  The package owns greeting, phase names, spinner, surface status, receipt and sign-off.
32
35
  `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.
36
+ arm, verify-release and rollback. Its facts are state (running/ok/fail/note), seconds, measure and safe detail.
34
37
  `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.
38
+ total, updated, failed and optional retry, dryRun, installed, deferred, operationFailed and safe detail.
39
+ Failure facts can include rollback (`completed`, `partial`, `not-needed`, `unknown`), logPath,
40
+ or logState (`unavailable`, `omitted`). Early operation failures retain retry guidance even with zero surfaces.
36
41
  Missing version or deferred work never reports ready. Counts must describe the same operation set.
37
42
  Pass `dryRun: true` in options so individual rows also say “would update”.
38
43
 
@@ -46,6 +51,9 @@ redacted in transcripts. `relay(text, channel)` and `milestone({step,state,ms})`
46
51
  child diagnostics: never pass authentication responses, credentials or raw provider errors.
47
52
 
48
53
  TTY, color, width, environment, animation and output sink can be injected through options.
54
+ Omit `write` for production terminal output and worker animation during synchronous work.
55
+ An injected `write` observer uses timer animation; its exceptions are retained in `run.errors`.
56
+ Default terminal writes and transcript/outcome filesystem failures remain operation errors.
49
57
  Non-TTY output is plain append-only text. NO_COLOR disables color, not the visual structure.
50
58
  Nested processes suppress greetings and send their final outcome to the console owner.
51
59
  The private outcome channel works through Windows command shims as well as direct child processes;
package/dist/index.js CHANGED
@@ -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);
@@ -893,9 +897,16 @@ function createInstallerRun(value, options = {}) {
893
897
  env,
894
898
  color: tty && options.color !== false && env.NO_COLOR === void 0
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);
@@ -930,6 +941,9 @@ function createInstallerRun(value, options = {}) {
930
941
  if (rendered) lines([tty ? rendered : `${kind === "fail" ? "Failed: " : ""}${title}${measure === null ? "" : ` (${typeof measure === "number" ? `${Math.max(0, Math.round(measure))}s` : measure})`}`]);
931
942
  };
932
943
  const run = {
944
+ get errors() {
945
+ return [...errors];
946
+ },
933
947
  start,
934
948
  phase(id, facts = {}) {
935
949
  if (finished) throw new Error("installer run already finished");
@@ -995,7 +1009,9 @@ function createInstallerRun(value, options = {}) {
995
1009
  changed,
996
1010
  ...facts.failed ? [`Failed ${facts.failed} of ${facts.total} surfaces.`] : [],
997
1011
  ...facts.detail ? [facts.detail] : [],
998
- facts.retry && facts.failed ? `Retry: ${facts.retry}` : `Check health any time: ${declaration.doctor}`
1012
+ ...facts.rollback ? [`Rollback: ${facts.rollback}`] : [],
1013
+ ...facts.logPath ? [`Log: ${facts.logPath}`] : facts.logState ? [`Log: ${facts.logState}`] : [],
1014
+ facts.retry && (facts.failed > 0 || facts.operationFailed) ? `Retry: ${facts.retry}` : `Check health any time: ${declaration.doctor}`
999
1015
  ];
1000
1016
  if (!face.nested || !env.MM_INSTALLER_OUTCOME_FILE && (facts.failed > 0 || facts.operationFailed)) {
1001
1017
  lines(tty ? face.receipt(body, { ready }) : body.map((row) => row.replace(/^[✔✖●] /u, "")));
@@ -1021,7 +1037,7 @@ async function runInstaller(value, operation, options) {
1021
1037
  }
1022
1038
 
1023
1039
  // src/payload.ts
1024
- function renderPayloadEntry({ tarballs, converge, shippedFlag }) {
1040
+ function renderPayloadEntry({ tarballs, converge, shippedFlag, installedRuntime }) {
1025
1041
  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
1042
  throw new Error("installer payload: tarballs must be relative .tgz basenames");
1027
1043
  }
@@ -1031,6 +1047,47 @@ function renderPayloadEntry({ tarballs, converge, shippedFlag }) {
1031
1047
  if (shippedFlag !== void 0 && !/^--[a-z][a-z0-9-]*$/u.test(shippedFlag)) {
1032
1048
  throw new Error("installer payload: invalid shipped flag");
1033
1049
  }
1050
+ if (installedRuntime !== void 0) {
1051
+ 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))) {
1052
+ throw new Error("installer payload: invalid installed runtime");
1053
+ }
1054
+ if (shippedFlag) throw new Error("installer payload: installed runtime reads its own payload manifest");
1055
+ return String.raw`// Generated by @mutmutco/installer-face. Uses the installed product, never a registry.
1056
+ import { spawnSync } from 'node:child_process';
1057
+ import { statSync } from 'node:fs';
1058
+ import { basename, dirname, join, resolve } from 'node:path';
1059
+
1060
+ const runtime = ${JSON.stringify(installedRuntime)};
1061
+ const argv = process.argv.slice(2);
1062
+ const at = argv.indexOf('--payload');
1063
+ const payloadDir = resolve(at >= 0 && argv[at + 1] ? argv[at + 1] : dirname(process.argv[1]));
1064
+ const productDir = dirname(payloadDir);
1065
+ const packaged = join('node_modules', runtime.package);
1066
+ const candidates = [
1067
+ ...runtime.installRootEnv.map((name) => process.env[name]),
1068
+ productDir, join(productDir, 'app'), join(productDir, 'current'),
1069
+ join(productDir, packaged), payloadDir, join(payloadDir, packaged),
1070
+ ];
1071
+ const root = candidates.find((candidate) => {
1072
+ if (!candidate) return false;
1073
+ try { return statSync(join(candidate, runtime.entry)).isFile(); }
1074
+ catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false; throw error; }
1075
+ });
1076
+ if (!root) {
1077
+ process.stderr.write('Installer payload has no installed product to converge: ' + payloadDir + '\n');
1078
+ process.exitCode = 1;
1079
+ } else {
1080
+ const viaLauncher = !/^node(\.exe)?$/iu.test(basename(process.execPath));
1081
+ const args = [...(viaLauncher ? ['--run'] : []), join(root, runtime.entry),
1082
+ ...${JSON.stringify(converge)}, '--payload', payloadDir, '--installed', root];
1083
+ const child = spawnSync(process.execPath, args, { cwd: root, stdio: 'inherit', windowsHide: true });
1084
+ if (child.error) {
1085
+ process.stderr.write('Installer could not re-enter the installed product: ' + child.error.message + '\n');
1086
+ process.exitCode = 1;
1087
+ } else process.exitCode = child.status === null ? 1 : child.status;
1088
+ }
1089
+ `;
1090
+ }
1034
1091
  return String.raw`// Generated by @mutmutco/installer-face. Product code declares operations, never this program.
1035
1092
  import { spawnSync } from 'node:child_process';
1036
1093
  import { readFileSync } from 'node:fs';
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';
@@ -35,6 +36,8 @@ export interface InstallerRunOptions {
35
36
  columns?: number;
36
37
  env?: Readonly<NodeJS.ProcessEnv>;
37
38
  animate?: boolean;
39
+ /** Optional output observer. Exceptions are recorded in run.errors, never operation failures.
40
+ * Omit for production terminal output: the default worker animates during synchronous work. */
38
41
  write?: (text: string, channel: InstallerChannel) => void;
39
42
  }
40
43
  export interface InstallerFinish {
@@ -48,6 +51,9 @@ export interface InstallerFinish {
48
51
  detail?: string;
49
52
  deferred?: boolean;
50
53
  operationFailed?: boolean;
54
+ rollback?: 'completed' | 'partial' | 'not-needed' | 'unknown';
55
+ logPath?: string;
56
+ logState?: 'unavailable' | 'omitted';
51
57
  }
52
58
  export interface InstallerPhaseFacts {
53
59
  state?: 'running' | StepKind;
@@ -65,6 +71,7 @@ export interface InstallerSurfaceFacts {
65
71
  }
66
72
  /** One owner for the entire run. Callers supply measured facts, never terminal prose. */
67
73
  export declare function createInstallerRun(value: unknown, options?: InstallerRunOptions): {
74
+ readonly errors: readonly string[];
68
75
  start: () => void;
69
76
  phase(id: InstallerPhase, facts?: InstallerPhaseFacts): void;
70
77
  surface(facts: InstallerSurfaceFacts): void;
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.2",
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",