@mutmutco/installer-face 0.4.3 → 0.4.5

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,15 @@
1
+ # 0.4.5
2
+
3
+ - Keep one compact terminal stream and resume live progress after child output.
4
+ - Carry nested running phases through Windows command wrappers.
5
+ - Use the same install/update wording and hourly-update clock across products.
6
+ - Ship the shared download page and bootstrap templates for both public homes.
7
+
8
+ # 0.4.4
9
+
10
+ - Report engine-measured surface times without including release discovery or callback delays.
11
+ - Leave unmeasured surfaces untimed while preserving measured zero durations.
12
+
1
13
  # 0.4.3
2
14
 
3
15
  - Resolve freshly installed npm commands outside the inherited PATH.
package/dist/face.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ProductIdentity } from './products.js';
2
2
  /** The guidebook glyph set. Nothing outside this may appear in a face. */
3
3
  export declare const GLYPH: Readonly<{
4
+ clock: "⏰";
4
5
  diamond: "◆";
5
6
  hollow: "◇";
6
7
  bar: "│";
@@ -70,6 +71,7 @@ export interface Face {
70
71
  continues(phase: string, kind?: StepKind): boolean;
71
72
  /** One durable line for one finished step: glyph, title, and the right-column measure. */
72
73
  step(title: string, measure?: StepMeasure, kind?: StepKind): string;
74
+ progress(title: string, measure?: StepMeasure): boolean;
73
75
  /**
74
76
  * The canonical success receipt, composed from the product table: `✔ <name> is ready.`, the one
75
77
  * fact the surface alone knows, and the table's own health command. A surface supplies ONLY what
package/dist/index.js CHANGED
@@ -1,34 +1,34 @@
1
1
  // src/face.ts
2
- import { writeSync } from "node:fs";
2
+ import { appendFileSync, writeSync } from "node:fs";
3
3
 
4
4
  // src/products.ts
5
5
  var PRODUCTS = Object.freeze({
6
6
  "mm-strategy": Object.freeze({
7
7
  name: "MM Strategy",
8
- installWarm: "Welcome. Let's set up MM Strategy \u2014 about a minute.",
8
+ installWarm: "Welcome. Let's get you ready.",
9
9
  accent: "38;2;249;115;22",
10
- warm: "Welcome. Let's set up MM Strategy \u2014 about a minute.",
10
+ warm: "Welcome back. Checking for updates...",
11
11
  doctor: "mm-strategy doctor"
12
12
  }),
13
13
  "mmi-hub": Object.freeze({
14
14
  name: "mmi-hub",
15
- installWarm: "Welcome. Setting up mmi-hub \u2014 about a minute.",
15
+ installWarm: "Welcome. Let's get you ready.",
16
16
  accent: "38;2;125;211;252",
17
- warm: "Welcome back. Checking your surfaces\u2026",
17
+ warm: "Welcome back. Checking for updates...",
18
18
  doctor: "mmi doctor"
19
19
  }),
20
20
  "jerv-hub": Object.freeze({
21
21
  name: "jerv-hub",
22
- installWarm: "Welcome. Setting up jerv-hub \u2014 about a minute.",
22
+ installWarm: "Welcome. Let's get you ready.",
23
23
  accent: "38;2;248;113;113",
24
- warm: "Welcome back. Checking your surfaces\u2026",
24
+ warm: "Welcome back. Checking for updates...",
25
25
  doctor: "jerv doctor"
26
26
  }),
27
27
  jervcode: Object.freeze({
28
28
  name: "JervCode",
29
- installWarm: "Welcome. Setting up JervCode \u2014 about a minute.",
29
+ installWarm: "Welcome. Let's get you ready.",
30
30
  accent: "38;2;192;132;252",
31
- warm: "Welcome back. Keeping JervCode current\u2026",
31
+ warm: "Welcome back. Checking for updates...",
32
32
  doctor: "jervcode doctor"
33
33
  })
34
34
  });
@@ -42,6 +42,7 @@ function identityFor(product) {
42
42
 
43
43
  // src/face.ts
44
44
  var GLYPH = Object.freeze({
45
+ clock: "\u23F0",
45
46
  diamond: "\u25C6",
46
47
  hollow: "\u25C7",
47
48
  bar: "\u2502",
@@ -113,12 +114,16 @@ function createFace({ product, color = false, columns, env = process.env, operat
113
114
  const continuesFace = continuedPhases.size > 0;
114
115
  const nested = env.MM_OUTER_CONSOLE === "1";
115
116
  const progressFd = readProgressFd(env);
117
+ const progressFile = env.MM_PROGRESS_PROTOCOL === "1" ? env.MM_PROGRESS_FILE : void 0;
116
118
  const emitMilestone = (title, measure, kind) => {
117
- if (progressFd === null) return false;
119
+ if (progressFd === null && !progressFile) return false;
118
120
  const record = { v: PROGRESS_PROTOCOL, step: stripColor(title), state: kind };
119
121
  if (typeof measure === "number") record.ms = Math.max(0, Math.round(measure * 1e3));
122
+ if (kind === "running" && typeof measure === "string") record.measure = measure;
120
123
  try {
121
- writeSync(progressFd, `${JSON.stringify(record)}
124
+ if (progressFile) appendFileSync(progressFile, `${JSON.stringify(record)}
125
+ `);
126
+ else writeSync(progressFd, `${JSON.stringify(record)}
122
127
  `);
123
128
  return true;
124
129
  } catch {
@@ -137,7 +142,7 @@ function createFace({ product, color = false, columns, env = process.env, operat
137
142
  };
138
143
  const step = (title, measure = null, kind = "ok") => {
139
144
  if (emitMilestone(title, measure, kind)) return "";
140
- const glyph = kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.dot) : paint(PALETTE.green, GLYPH.check);
145
+ const glyph = title === "Armed hourly updates" && kind === "ok" ? GLYPH.clock : kind === "fail" ? paint(identity.accent, GLYPH.cross) : kind === "note" ? paint(PALETTE.muted, GLYPH.dot) : paint(PALETTE.green, GLYPH.check);
141
146
  const measured = measure === null || measure === void 0 ? "" : paint(PALETTE.muted, typeof measure === "number" ? `${Math.max(0, Math.round(measure))}s` : String(measure).trim());
142
147
  const time = measured;
143
148
  const column = Math.min(44, Math.max(0, width - 8));
@@ -147,7 +152,7 @@ function createFace({ product, color = false, columns, env = process.env, operat
147
152
  const pad = Math.max(1, column - visibleWidth(head));
148
153
  return [time ? `${head}${" ".repeat(pad)}${time}` : head, ...rest.map((line) => `${bar()}${indent}${line}`)].join("\n");
149
154
  };
150
- const relay = (text) => String(text).split("\n").flatMap((line) => line.trim() === "" ? [bar()] : (visibleWidth(line) <= width - TITLE_COLUMN ? [line] : wrapWords(line, width - TITLE_COLUMN)).map((part) => `${bar()}${indent}${part}`)).join("\n");
155
+ const relay = (text) => String(text).replace(/\r?\n$/, "").split(/\r?\n/).flatMap((line) => line.trim() === "" ? [bar()] : (visibleWidth(line) <= width - TITLE_COLUMN ? [line] : wrapWords(line, width - TITLE_COLUMN)).map((part) => `${bar()}${indent}${part}`)).join("\n");
151
156
  const receipt = (lines, { ready = true } = {}) => {
152
157
  if (ready && nested) return [];
153
158
  const body = (Array.isArray(lines) ? lines : String(lines).split("\n")).flatMap((raw) => {
@@ -178,7 +183,7 @@ function createFace({ product, color = false, columns, env = process.env, operat
178
183
  };
179
184
  const signOff = () => nested ? "" : `${paint(identity.accent, GLYPH.diamond)} ${paint(identity.accent, `${identity.name} \xB7 Mutatis Mutandis`)}`;
180
185
  const refusal = (message) => `${bar()} ${paint(identity.accent, GLYPH.cross)} ${message}`;
181
- return { identity, width, nested, emitsProgress: progressFd !== null, welcome, continues, step, relay, receipt, outcome, signOff, refusal, paint };
186
+ return { identity, width, nested, emitsProgress: progressFd !== null || Boolean(progressFile), welcome, continues, step, progress: (title, measure = null) => emitMilestone(title, measure, "running"), relay, receipt, outcome, signOff, refusal, paint };
182
187
  }
183
188
 
184
189
  // src/shell.ts
@@ -510,7 +515,7 @@ function clip(text, width) {
510
515
  }
511
516
 
512
517
  // src/spinner.ts
513
- import { appendFileSync, writeSync as writeSync2 } from "node:fs";
518
+ import { appendFileSync as appendFileSync2, writeSync as writeSync2 } from "node:fs";
514
519
  import { Worker } from "node:worker_threads";
515
520
  var FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
516
521
  var WORKER_SOURCE = `
@@ -522,7 +527,7 @@ function draw() {
522
527
  if (Atomics.compareExchange(control, 1, 0, 1) !== 0) return;
523
528
  try {
524
529
  if (Atomics.load(control, 0) || Atomics.load(control, 2) || !frames.length) return;
525
- const text = frames[frame++ % frames.length];
530
+ const text = frames[frame++ % frames.length].replace('__elapsed__', Math.floor((Date.now() - workerData.started) / 1000) + 's');
526
531
  writeSync(2, text);
527
532
  if (workerData.transcriptPath) appendFileSync(workerData.transcriptPath, JSON.stringify({ channel: 'spinner', text }) + '\\n');
528
533
  } finally {
@@ -534,7 +539,6 @@ parentPort.on('message', (next) => {
534
539
  if (Atomics.load(control, 2)) return;
535
540
  frames = next.frames;
536
541
  frame = next.frame;
537
- Atomics.store(control, 0, 0);
538
542
  });
539
543
  setInterval(draw, workerData.intervalMs);
540
544
  `;
@@ -545,20 +549,22 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
545
549
  let control = null;
546
550
  let frames = [];
547
551
  let frame = 0;
552
+ let started = 0;
553
+ let suspended = false;
548
554
  const write = (text) => {
549
555
  if (stream) stream.write(text);
550
556
  else {
551
557
  writeSync2(2, text);
552
- if (transcriptPath) appendFileSync(transcriptPath, `${JSON.stringify({ channel: "spinner", text })}
558
+ if (transcriptPath) appendFileSync2(transcriptPath, `${JSON.stringify({ channel: "spinner", text })}
553
559
  `);
554
560
  }
555
561
  };
556
562
  const render = (text, measure) => {
557
- const line = face.step(text, measure, "note").split("\n")[0];
563
+ const line = face.step(text, measure ?? "__elapsed__", "note").split("\n")[0];
558
564
  frames = line ? FRAMES.map((glyph) => `\r\x1B[2K${line.replace(GLYPH.dot, glyph)}`) : [];
559
565
  };
560
566
  const draw = () => {
561
- if (animate && frames.length) write(frames[frame++ % frames.length]);
567
+ if (animate && !suspended && frames.length) write(frames[frame++ % frames.length].replace("__elapsed__", `${Math.floor((Date.now() - started) / 1e3)}s`));
562
568
  };
563
569
  const pause = () => {
564
570
  if (!control) return;
@@ -578,6 +584,8 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
578
584
  start(text, measure = null) {
579
585
  if (!animate) return;
580
586
  halt();
587
+ suspended = false;
588
+ started = Date.now();
581
589
  render(text, measure);
582
590
  frame = 0;
583
591
  draw();
@@ -591,7 +599,8 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
591
599
  frames,
592
600
  frame,
593
601
  intervalMs,
594
- transcriptPath
602
+ transcriptPath,
603
+ started
595
604
  } });
596
605
  const active = worker;
597
606
  worker.on("error", () => {
@@ -609,9 +618,22 @@ function createSpinner(face, { animate, stream, intervalMs = 90, transcriptPath
609
618
  render(text, measure);
610
619
  draw();
611
620
  worker?.postMessage({ frames, frame });
621
+ if (control && !suspended) Atomics.store(control, 0, 0);
622
+ },
623
+ pause() {
624
+ if (suspended) return;
625
+ suspended = true;
626
+ pause();
627
+ if (animate && frames.length) write("\r\x1B[2K");
628
+ },
629
+ resume() {
630
+ suspended = false;
631
+ if (control) Atomics.store(control, 0, 0);
632
+ if (timer || worker) draw();
612
633
  },
613
634
  stop() {
614
635
  halt();
636
+ frames = [];
615
637
  if (animate) write("\r\x1B[2K");
616
638
  }
617
639
  };
@@ -789,7 +811,7 @@ function assertScriptConformance(script, options) {
789
811
  }
790
812
 
791
813
  // src/run.ts
792
- import { appendFileSync as appendFileSync2 } from "node:fs";
814
+ import { appendFileSync as appendFileSync3 } from "node:fs";
793
815
 
794
816
  // src/outcome.ts
795
817
  import { readFileSync, writeFileSync } from "node:fs";
@@ -882,6 +904,7 @@ var PHASES = {
882
904
  "verify-release": ["Checking the release version", "Verified the release version"],
883
905
  verify: ["Verifying the payload", "Verified the payload"],
884
906
  install: ["Installing the product", "Installed the product"],
907
+ configure: ["Configuring the product", "Configured the product"],
885
908
  activate: ["Activating surfaces", "Activated surfaces"],
886
909
  doctor: ["Checking health", "Checked health"],
887
910
  rollback: ["Restoring the previous version", "Restored the previous version"]
@@ -911,7 +934,7 @@ function createInstallerRun(value, options = {}) {
911
934
  if (!text) return;
912
935
  write(text, channel);
913
936
  if (env.MM_FACE_TRANSCRIPT) {
914
- appendFileSync2(env.MM_FACE_TRANSCRIPT, `${JSON.stringify({ channel, text: recorded })}
937
+ appendFileSync3(env.MM_FACE_TRANSCRIPT, `${JSON.stringify({ channel, text: recorded })}
915
938
  `, "utf8");
916
939
  }
917
940
  };
@@ -926,6 +949,17 @@ function createInstallerRun(value, options = {}) {
926
949
  return true;
927
950
  } } } : { transcriptPath: env.MM_FACE_TRANSCRIPT }
928
951
  });
952
+ let activeTitle;
953
+ let chunkOpen = false;
954
+ let chunkChannel = "stdout";
955
+ let chunkColumn = 0;
956
+ const trailingCR = /* @__PURE__ */ new Set();
957
+ let deferredActivity;
958
+ const closeChunk = () => {
959
+ if (chunkOpen) emit("\n", chunkChannel);
960
+ chunkOpen = false;
961
+ chunkColumn = 0;
962
+ };
929
963
  let started = false;
930
964
  let finished = false;
931
965
  const start = () => {
@@ -940,6 +974,9 @@ function createInstallerRun(value, options = {}) {
940
974
  }
941
975
  };
942
976
  const durable = (title, measure, kind) => {
977
+ closeChunk();
978
+ activeTitle = void 0;
979
+ deferredActivity = void 0;
943
980
  spinner.stop();
944
981
  const rendered = face.step(title, measure, kind);
945
982
  if (rendered) lines([tty ? rendered : `${kind === "fail" ? "Failed: " : ""}${title}${measure === null ? "" : ` (${typeof measure === "number" ? `${Math.max(0, Math.round(measure))}s` : measure})`}`]);
@@ -956,7 +993,12 @@ function createInstallerRun(value, options = {}) {
956
993
  const state = facts.state ?? "ok";
957
994
  const title = PHASES[id][state === "ok" ? 1 : 0];
958
995
  if (state === "running") {
959
- spinner.start(title, facts.measure ?? null);
996
+ if (face.progress(title, facts.measure ?? null)) return;
997
+ if (activeTitle === title) spinner.say(title, facts.measure ?? null);
998
+ else {
999
+ activeTitle = title;
1000
+ spinner.start(title, facts.measure ?? null);
1001
+ }
960
1002
  return;
961
1003
  }
962
1004
  if (!face.continues(id, state)) durable(title, facts.seconds ?? facts.measure ?? null, state);
@@ -976,8 +1018,20 @@ function createInstallerRun(value, options = {}) {
976
1018
  durable(`${facts.id}${versions} ${separator} ${status}${activation}`, completed ? facts.seconds ?? null : null, facts.state === "failed" ? "fail" : facts.state === "updated" ? "ok" : "note");
977
1019
  if (facts.detail) run.relay(facts.detail);
978
1020
  },
979
- milestone({ step, state, ms }) {
1021
+ milestone({ step, state, ms, measure }) {
980
1022
  start();
1023
+ if (state === "running") {
1024
+ if (chunkOpen) {
1025
+ deferredActivity = { title: step, measure: measure ?? null };
1026
+ return;
1027
+ }
1028
+ if (activeTitle === step) spinner.say(step, measure ?? null);
1029
+ else {
1030
+ activeTitle = step;
1031
+ spinner.start(step, measure ?? null);
1032
+ }
1033
+ return;
1034
+ }
981
1035
  durable(step, ms === void 0 ? null : ms / 1e3, state);
982
1036
  },
983
1037
  signIn({ url, code }) {
@@ -991,7 +1045,8 @@ function createInstallerRun(value, options = {}) {
991
1045
  },
992
1046
  // Only pass safe diagnostic text, never authentication output or credentials.
993
1047
  relay(text, channel = "stdout", record = true) {
994
- spinner.stop();
1048
+ closeChunk();
1049
+ spinner.pause();
995
1050
  const rendered = tty ? face.relay(text) : stripColor(text);
996
1051
  for (const row of rendered.split("\n")) if (row) {
997
1052
  emit(`${row}
@@ -999,6 +1054,37 @@ function createInstallerRun(value, options = {}) {
999
1054
  ` : `${tty ? face.relay("[external output omitted]") : "[external output omitted]"}
1000
1055
  `);
1001
1056
  }
1057
+ spinner.resume();
1058
+ },
1059
+ relayChunk(text, channel = "stdout") {
1060
+ spinner.pause();
1061
+ if (chunkOpen && chunkChannel !== channel) closeChunk();
1062
+ chunkChannel = channel;
1063
+ if (trailingCR.delete(channel) && text.startsWith("\n")) text = text.slice(1);
1064
+ if (text.endsWith("\r")) trailingCR.add(channel);
1065
+ const parts = text.replace(/\r\n|\r/g, "\n").split("\n");
1066
+ for (let i = 0; i < parts.length; i++) {
1067
+ const characters = [...stripColor(parts[i])];
1068
+ while (characters.length) {
1069
+ if (tty && chunkColumn >= face.width - 6) closeChunk();
1070
+ const part = characters.splice(0, tty ? face.width - 6 - chunkColumn : characters.length).join("");
1071
+ emit(tty && !chunkOpen ? face.relay(part) : part, channel, chunkOpen ? "" : tty ? face.relay("[external output omitted]") : "[external output omitted]");
1072
+ chunkColumn += [...part].length;
1073
+ chunkOpen = true;
1074
+ }
1075
+ if (i < parts.length - 1) {
1076
+ emit("\n", channel);
1077
+ chunkOpen = false;
1078
+ chunkColumn = 0;
1079
+ }
1080
+ }
1081
+ if (!chunkOpen) {
1082
+ if (deferredActivity) {
1083
+ activeTitle = deferredActivity.title;
1084
+ spinner.start(deferredActivity.title, deferredActivity.measure);
1085
+ deferredActivity = void 0;
1086
+ } else spinner.resume();
1087
+ }
1002
1088
  },
1003
1089
  cancel() {
1004
1090
  if (finished) return;
@@ -1007,6 +1093,8 @@ function createInstallerRun(value, options = {}) {
1007
1093
  { total: 0, updated: 0, failed: 0, deferred: true, detail: "Operation cancelled." }
1008
1094
  );
1009
1095
  start();
1096
+ closeChunk();
1097
+ activeTitle = void 0;
1010
1098
  spinner.stop();
1011
1099
  finished = true;
1012
1100
  if (!face.nested || !env.MM_INSTALLER_OUTCOME_FILE) {
@@ -1019,6 +1107,8 @@ function createInstallerRun(value, options = {}) {
1019
1107
  validateInstallerOutcome(facts);
1020
1108
  if (env.MM_INSTALLER_OUTCOME_FILE) writeInstallerOutcome(env.MM_INSTALLER_OUTCOME_FILE, facts);
1021
1109
  start();
1110
+ closeChunk();
1111
+ activeTitle = void 0;
1022
1112
  spinner.stop();
1023
1113
  finished = true;
1024
1114
  if (options.quiet && facts.updated === 0 && facts.failed === 0 && !facts.operationFailed) return;
@@ -1039,6 +1129,8 @@ function createInstallerRun(value, options = {}) {
1039
1129
  if (tty) lines([face.signOff()]);
1040
1130
  },
1041
1131
  stop() {
1132
+ closeChunk();
1133
+ activeTitle = void 0;
1042
1134
  spinner.stop();
1043
1135
  }
1044
1136
  };
@@ -1203,7 +1295,6 @@ async function runMaintenance(product, args, dependencies) {
1203
1295
  operation: installed ? "install" : "update"
1204
1296
  });
1205
1297
  if (!quiet) run?.start();
1206
- let mark = Date.now();
1207
1298
  let active;
1208
1299
  let activeFacts;
1209
1300
  let handedOff;
@@ -1241,14 +1332,14 @@ async function runMaintenance(product, args, dependencies) {
1241
1332
  };
1242
1333
  const pending = [];
1243
1334
  let streaming = false;
1244
- const emitArm = ({ arm, seconds }) => {
1335
+ const emitArm = (arm) => {
1245
1336
  if (quiet) return;
1246
1337
  run?.surface({
1247
1338
  id: arm.surface,
1248
1339
  from: arm.from ?? void 0,
1249
1340
  to: arm.to ?? void 0,
1250
1341
  state: arm.verdict === "fail" ? "failed" : arm.verdict === "defer" ? "retry" : arm.verdict === "skip" ? "kept" : arm.from === arm.to && !arm.launchStaged ? "current" : "updated",
1251
- seconds,
1342
+ ...arm.durationMs === void 0 ? {} : { seconds: arm.durationMs / 1e3 },
1252
1343
  detail: dependencies.diagnose?.(arm)
1253
1344
  });
1254
1345
  };
@@ -1285,9 +1376,7 @@ async function runMaintenance(product, args, dependencies) {
1285
1376
  onArm: (arm) => {
1286
1377
  if (handedOff !== void 0) return;
1287
1378
  if (arm.verdict === "fail") armFailed = true;
1288
- const seconds = (Date.now() - mark) / 1e3;
1289
- mark = Date.now();
1290
- pending.push({ arm, seconds });
1379
+ pending.push(arm);
1291
1380
  if (pending.length > 1) streaming = true;
1292
1381
  if (streaming) for (const result of pending.splice(0)) emitArm(result);
1293
1382
  }