@mutmutco/hub 4.0.3 → 4.0.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.
Files changed (2) hide show
  1. package/dist/index.cjs +310 -42
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -27,6 +27,8 @@ __export(index_exports, {
27
27
  hermesArm: () => hermesArm,
28
28
  hermesHome: () => hermesHome,
29
29
  hubStatus: () => hubStatus,
30
+ launchdPlist: () => launchdPlist,
31
+ launcherSh: () => launcherSh,
30
32
  launcherVbs: () => launcherVbs,
31
33
  localStartBoundary: () => localStartBoundary,
32
34
  main: () => main,
@@ -37,6 +39,8 @@ __export(index_exports, {
37
39
  retryHint: () => retryHint,
38
40
  schedulerStatus: () => schedulerStatus,
39
41
  startSpinner: () => startSpinner,
42
+ systemdServiceUnit: () => systemdServiceUnit,
43
+ systemdTimerUnit: () => systemdTimerUnit,
40
44
  taskXml: () => taskXml,
41
45
  updateHeader: () => updateHeader,
42
46
  wrapWords: () => wrapWords
@@ -150,6 +154,22 @@ function journalHighWater(path, surface) {
150
154
  }
151
155
  return high;
152
156
  }
157
+ function journalSurfaceRepeatedFail(path, surface, currentRun) {
158
+ let lastVerdict = null;
159
+ try {
160
+ for (const line of (0, import_node_fs.readFileSync)(path, "utf8").split("\n")) {
161
+ if (!line.trim()) continue;
162
+ try {
163
+ const event = JSON.parse(line);
164
+ if (event.dryRun || event.kind !== "arm" || event.surface !== surface || event.run === currentRun) continue;
165
+ lastVerdict = event.verdict;
166
+ } catch {
167
+ }
168
+ }
169
+ } catch {
170
+ }
171
+ return lastVerdict === "fail";
172
+ }
153
173
  function parseSemver(version) {
154
174
  const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version.trim());
155
175
  if (!match) return null;
@@ -200,6 +220,11 @@ function runNpm(args, env = process.env, options = {}) {
200
220
  const result = npm.kind === "node-script" ? (0, import_node_child_process.spawnSync)(process.execPath, [npm.script, ...args], { encoding: "utf8", env, cwd: options.cwd, windowsHide: true }) : (0, import_node_child_process.spawnSync)(npm.command, args, { encoding: "utf8", env, cwd: options.cwd, shell: process.platform === "win32", windowsHide: true });
201
221
  return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
202
222
  }
223
+ function globalRoot(env = process.env) {
224
+ const root = runNpm(["root", "-g"], env);
225
+ if (root.status !== 0 || !root.stdout.trim()) return null;
226
+ return root.stdout.trim();
227
+ }
203
228
  function npmView(spec, field, env = process.env) {
204
229
  const args = ["view", spec, "--json"];
205
230
  if (field) args.splice(2, 0, field);
@@ -314,11 +339,12 @@ function checkAtomicity(cachePath, tagCommit, bom, candidate, npm) {
314
339
  rows.push({ id: row.id, name, ok: false, detail: `${name}@${candidate} not on npm` });
315
340
  continue;
316
341
  }
317
- if (integrity !== row.identity.value) {
342
+ const legacySsri = /^sha\d+-/.test(row.identity.value ?? "");
343
+ if (legacySsri && integrity !== row.identity.value) {
318
344
  rows.push({ id: row.id, name, ok: false, detail: `${name}@${candidate} dist.integrity differs from BOM identity` });
319
345
  continue;
320
346
  }
321
- rows.push({ id: row.id, name, ok: true, detail: `${name}@${candidate} published, integrity matches` });
347
+ rows.push({ id: row.id, name, ok: true, detail: legacySsri ? `${name}@${candidate} published, integrity matches` : `${name}@${candidate} published` });
322
348
  }
323
349
  return { ok: rows.length > 0 && rows.every((row) => row.ok), rows };
324
350
  }
@@ -362,9 +388,8 @@ var import_node_fs5 = require("node:fs");
362
388
  var import_node_child_process3 = require("node:child_process");
363
389
  var import_node_path4 = require("node:path");
364
390
  function globalCliDist(env) {
365
- const prefix = runNpm(["prefix", "-g"], env);
366
- if (prefix.status !== 0 || !prefix.stdout.trim()) return null;
367
- return (0, import_node_path4.join)(prefix.stdout.trim(), "node_modules", "@mutmutco", "cli", "dist", "index.cjs");
391
+ const root = globalRoot(env);
392
+ return root ? (0, import_node_path4.join)(root, "@mutmutco", "cli", "dist", "index.cjs") : null;
368
393
  }
369
394
  function probeCliVersion(distPath) {
370
395
  if (!fileExists(distPath)) return null;
@@ -421,9 +446,10 @@ function cliArm(options) {
421
446
  if (globalInstall.status !== 0) {
422
447
  return { surface: "cli", from: installed, to: target, verdict: "defer", detail: `global install failed: ${(globalInstall.stderr || globalInstall.stdout).split("\n").filter(Boolean).slice(-3).join(" | ")}` };
423
448
  }
424
- const switched = probeCliVersion(globalCliDist(env) ?? "");
449
+ const switchedDist = globalCliDist(env);
450
+ const switched = probeCliVersion(switchedDist ?? "");
425
451
  if (switched !== target) {
426
- return { surface: "cli", from: installed, to: switched ?? installed ?? "unknown", verdict: "fail", detail: `post-switch probe returned ${switched ?? "dead"}, expected ${target}` };
452
+ return { surface: "cli", from: installed, to: switched ?? installed ?? "unknown", verdict: "fail", detail: `post-switch probe returned ${switched ?? "dead"} at ${switchedDist ?? "an unresolvable path"}, expected ${target}; the global install already ran \u2014 nothing was rolled back` };
427
453
  }
428
454
  return { surface: "cli", from: installed, to: target, verdict: "ok", detail: `converged ${installed ?? "absent"} -> ${target} (staged, verified, switched)` };
429
455
  }
@@ -433,9 +459,8 @@ var import_node_fs6 = require("node:fs");
433
459
  var import_node_child_process4 = require("node:child_process");
434
460
  var import_node_path5 = require("node:path");
435
461
  function globalDist(env, packageName, distRel) {
436
- const prefix = runNpm(["prefix", "-g"], env);
437
- if (prefix.status !== 0 || !prefix.stdout.trim()) return null;
438
- return (0, import_node_path5.join)(prefix.stdout.trim(), "node_modules", ...packageName.split("/"), ...distRel.split("/"));
462
+ const root = globalRoot(env);
463
+ return root ? (0, import_node_path5.join)(root, ...packageName.split("/"), ...distRel.split("/")) : null;
439
464
  }
440
465
  function probeVersion(distPath) {
441
466
  if (!fileExists(distPath)) return null;
@@ -501,9 +526,10 @@ function npmGlobalArm(options) {
501
526
  if (globalInstall.status !== 0) {
502
527
  return result(installed, target, "defer", `global install failed: ${tail(globalInstall.stderr || globalInstall.stdout)}`);
503
528
  }
504
- const switched = probeVersion(globalDist(env, packageName, distRel) ?? "");
529
+ const switchedDist = globalDist(env, packageName, distRel);
530
+ const switched = probeVersion(switchedDist ?? "");
505
531
  if (switched !== target) {
506
- return result(installed, switched ?? installed ?? "unknown", "fail", `post-switch probe returned ${switched ?? "dead"}, expected ${target}`);
532
+ return result(installed, switched ?? installed ?? "unknown", "fail", `post-switch probe returned ${switched ?? "dead"} at ${switchedDist ?? "an unresolvable path"}, expected ${target}; the global install already ran \u2014 nothing was rolled back`);
507
533
  }
508
534
  return result(installed, target, "ok", `converged ${installed ?? "absent"} -> ${target} (staged, verified, switched)`);
509
535
  }
@@ -523,6 +549,9 @@ var import_node_child_process5 = require("node:child_process");
523
549
  var import_node_fs7 = require("node:fs");
524
550
  var import_node_os3 = require("node:os");
525
551
  var import_node_path6 = require("node:path");
552
+ function convergedAtOrAbove(installed, target, healthy) {
553
+ return healthy && typeof installed === "string" && compareSemver(installed, target) >= 0;
554
+ }
526
555
  function cmdArg(value) {
527
556
  return /^[A-Za-z0-9_@./:\\=+-]+$/.test(value) ? value : `"${value.replaceAll('"', '""')}"`;
528
557
  }
@@ -646,9 +675,12 @@ function claudeArm(options) {
646
675
  }
647
676
  observed = claudeMmi(runner, env);
648
677
  }
649
- if (observed.row?.version !== options.target || observed.row.enabled !== true) {
678
+ if (!convergedAtOrAbove(observed.row?.version, options.target, observed.row?.enabled === true)) {
650
679
  return { surface: "claude", from, to: observed.row?.version ?? "unknown", verdict: "fail", detail: `post-update evidence is version ${observed.row?.version ?? "missing"}, enabled=${String(observed.row?.enabled)}, expected ${options.target}/true` };
651
680
  }
681
+ if (observed.row.version !== options.target) {
682
+ return { surface: "claude", from, to: observed.row.version, verdict: "skip", detail: `installed ${observed.row.version} is above candidate ${options.target} (monotonic)` };
683
+ }
652
684
  return { surface: "claude", from, to: options.target, verdict: "ok", detail: `catalog refreshed; converged and verified ${from ?? "absent"} -> ${options.target}` };
653
685
  }
654
686
  var SNAPSHOT_MISSING = /marketplace snapshot|marketplace root does not contain/i;
@@ -688,9 +720,12 @@ function codexArm(options) {
688
720
  }
689
721
  observed = codexMmi(runner, env);
690
722
  }
691
- if (observed.row?.version !== options.target || observed.row.installed !== true || observed.row.enabled !== true) {
723
+ if (!convergedAtOrAbove(observed.row?.version, options.target, observed.row?.installed === true && observed.row?.enabled === true)) {
692
724
  return { surface: "codex", from, to: observed.row?.version ?? "unknown", verdict: "fail", detail: `post-upgrade evidence is version ${observed.row?.version ?? "missing"}, installed=${String(observed.row?.installed)}, enabled=${String(observed.row?.enabled)}, expected ${options.target}/true/true` };
693
725
  }
726
+ if (observed.row.version !== options.target) {
727
+ return { surface: "codex", from, to: observed.row.version, verdict: "skip", detail: `installed ${observed.row.version} is above candidate ${options.target} (monotonic)` };
728
+ }
694
729
  return { surface: "codex", from, to: options.target, verdict: "ok", detail: `marketplace upgraded; payload refresh and native evidence verified at ${options.target}` };
695
730
  }
696
731
  var KILO_PACKAGE = "@mutmutco/kilo-plugin";
@@ -1787,6 +1822,7 @@ function reconcile(options) {
1787
1822
  };
1788
1823
  const summary = { run, target: null, gate: null, atomicity: null, cli: null, updater: null, plugins: [], reap: [], surfaces: [], journalOk: true, exit: 0 };
1789
1824
  const recordArm = (arm) => {
1825
+ if (arm.verdict === "fail") arm.repeatFailure = journalSurfaceRepeatedFail(paths.journalPath, arm.surface, run);
1790
1826
  journal({ kind: "arm", surface: arm.surface, from: arm.from, to: arm.to, verdict: arm.verdict, detail: arm.detail, compat: arm.compat });
1791
1827
  options.onArm?.(arm);
1792
1828
  if (arm.verdict === "defer") summary.exit = Math.max(summary.exit, 2);
@@ -1890,16 +1926,37 @@ function reconcile(options) {
1890
1926
  // src/scheduler.ts
1891
1927
  var import_node_child_process9 = require("node:child_process");
1892
1928
  var import_node_fs14 = require("node:fs");
1929
+ var import_node_os5 = require("node:os");
1893
1930
  var import_node_path12 = require("node:path");
1894
1931
  var TASK_NAME = "MMI Fleet Updater";
1895
1932
  var LAUNCHER_FILE = "reconcile-hidden.vbs";
1896
1933
  var RUN_BOUND = "PT15M";
1897
1934
  var REPEAT_INTERVAL = "PT1H";
1898
1935
  var CADENCE = `hourly from registration, StartWhenAvailable, single-instance, ${RUN_BOUND} bound`;
1936
+ var POSIX_LAUNCHER_FILE = "reconcile.sh";
1937
+ var STARTINTERVAL_SECONDS = 3600;
1938
+ var RUN_BOUND_SECONDS = 900;
1939
+ var POSIX_CADENCE = "hourly from registration, catch-up-if-missed, single-instance";
1940
+ var LAUNCHD_LABEL = "com.mutmutco.mmi-hub.updater";
1941
+ var LAUNCHD_PLIST_NAME = `${LAUNCHD_LABEL}.plist`;
1942
+ var SYSTEMD_UNIT = "mmi-hub-updater";
1943
+ var SYSTEMD_SERVICE_NAME = `${SYSTEMD_UNIT}.service`;
1944
+ var SYSTEMD_TIMER_NAME = `${SYSTEMD_UNIT}.timer`;
1899
1945
  function schtasks(args) {
1900
1946
  const result = (0, import_node_child_process9.spawnSync)("schtasks.exe", args, { encoding: "utf8", windowsHide: true });
1901
1947
  return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
1902
1948
  }
1949
+ function launchctl(args) {
1950
+ const result = (0, import_node_child_process9.spawnSync)("launchctl", args, { encoding: "utf8", windowsHide: true });
1951
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
1952
+ }
1953
+ function systemctlUser(args) {
1954
+ const result = (0, import_node_child_process9.spawnSync)("systemctl", ["--user", ...args], { encoding: "utf8", windowsHide: true });
1955
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
1956
+ }
1957
+ function uid() {
1958
+ return typeof process.getuid === "function" ? process.getuid() : 0;
1959
+ }
1903
1960
  function xmlEscape(value) {
1904
1961
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1905
1962
  }
@@ -1918,6 +1975,14 @@ code = shell.Run("""${vbsQuote(nodeExe)}"" ""${vbsQuote(hubDist)}"" update", 0,
1918
1975
  WScript.Quit code
1919
1976
  `;
1920
1977
  }
1978
+ function launcherSh(nodeExe, hubDist) {
1979
+ const shQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
1980
+ return `#!/bin/sh
1981
+ # MMI Hub maintenance launcher \u2014 generated by \`mmi-hub autoupdate on\`.
1982
+ # The exit code is propagated and reported by \`mmi-hub status\`.
1983
+ exec ${shQuote(nodeExe)} ${shQuote(hubDist)} update
1984
+ `;
1985
+ }
1921
1986
  function taskXml(launcherPath, workDir, userId, startBoundary) {
1922
1987
  return `<?xml version="1.0" encoding="UTF-16"?>
1923
1988
  <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
@@ -1960,7 +2025,53 @@ function taskXml(launcherPath, workDir, userId, startBoundary) {
1960
2025
  </Task>
1961
2026
  `;
1962
2027
  }
1963
- function readLauncher(path) {
2028
+ function launchdPlist(launcherPath, workDir) {
2029
+ return `<?xml version="1.0" encoding="UTF-8"?>
2030
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2031
+ <plist version="1.0">
2032
+ <dict>
2033
+ <key>Label</key>
2034
+ <string>${LAUNCHD_LABEL}</string>
2035
+ <key>ProgramArguments</key>
2036
+ <array>
2037
+ <string>/bin/sh</string>
2038
+ <string>${xmlEscape(launcherPath)}</string>
2039
+ </array>
2040
+ <key>WorkingDirectory</key>
2041
+ <string>${xmlEscape(workDir)}</string>
2042
+ <key>StartInterval</key>
2043
+ <integer>${STARTINTERVAL_SECONDS}</integer>
2044
+ <key>RunAtLoad</key>
2045
+ <true/>
2046
+ </dict>
2047
+ </plist>
2048
+ `;
2049
+ }
2050
+ function systemdServiceUnit(launcherPath, workDir) {
2051
+ return `[Unit]
2052
+ Description=MMI Hub maintenance \u2014 converge this machine's MMI surfaces to the newest gated release
2053
+
2054
+ [Service]
2055
+ Type=oneshot
2056
+ WorkingDirectory=${workDir}
2057
+ ExecStart=/bin/sh ${launcherPath}
2058
+ TimeoutStartSec=${RUN_BOUND_SECONDS}
2059
+ `;
2060
+ }
2061
+ function systemdTimerUnit() {
2062
+ return `[Unit]
2063
+ Description=MMI Hub maintenance hourly reconcile timer
2064
+
2065
+ [Timer]
2066
+ OnUnitActiveSec=${STARTINTERVAL_SECONDS}
2067
+ Persistent=true
2068
+ AccuracySec=1min
2069
+
2070
+ [Install]
2071
+ WantedBy=timers.target
2072
+ `;
2073
+ }
2074
+ function readFileOrNull(path) {
1964
2075
  try {
1965
2076
  return (0, import_node_fs14.readFileSync)(path, "utf8");
1966
2077
  } catch {
@@ -1983,6 +2094,7 @@ function elevatedRelaunch(mode, env) {
1983
2094
  }
1984
2095
  var NEVER_RUN_RESULT = 267011;
1985
2096
  var NEVER_RUN_YEAR = 1999;
2097
+ var EMPTY_RUN_INFO = { lastRunTime: null, lastTaskResult: null, nextRunTime: null };
1986
2098
  function taskRunInfo() {
1987
2099
  const result = (0, import_node_child_process9.spawnSync)(
1988
2100
  "powershell.exe",
@@ -2004,24 +2116,20 @@ function taskRunInfo() {
2004
2116
  function neverRan(info) {
2005
2117
  return info.lastTaskResult === NEVER_RUN_RESULT || (info.lastRunTime?.includes(String(NEVER_RUN_YEAR)) ?? false);
2006
2118
  }
2007
- function schedulerStatus(env = process.env) {
2008
- const empty = { lastRunTime: null, lastTaskResult: null, nextRunTime: null };
2009
- if (process.platform !== "win32") {
2010
- return { ok: false, supported: false, enabled: false, ...empty, detail: "automatic scheduling is available on Windows Task Scheduler only" };
2011
- }
2119
+ function windowsSchedulerStatus(env) {
2012
2120
  const query = schtasks(["/query", "/tn", TASK_NAME, "/xml"]);
2013
2121
  if (query.status !== 0) {
2014
- return { ok: false, supported: true, enabled: false, ...empty, detail: `"${TASK_NAME}" is not registered \u2014 run \`mmi-hub autoupdate on\`` };
2122
+ return { ok: false, supported: true, enabled: false, ...EMPTY_RUN_INFO, detail: `"${TASK_NAME}" is not registered \u2014 run \`mmi-hub autoupdate on\`` };
2015
2123
  }
2016
2124
  const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2017
2125
  const launcherPath = (0, import_node_path12.join)(statePaths(env).root, LAUNCHER_FILE);
2018
2126
  const info = taskRunInfo();
2019
- const runInfo = info ?? empty;
2127
+ const runInfo = info ?? EMPTY_RUN_INFO;
2020
2128
  const fail = (detail) => ({ ok: false, supported: true, enabled: true, ...runInfo, detail });
2021
2129
  if (!query.stdout.includes(launcherPath)) {
2022
2130
  return fail(`"${TASK_NAME}" action does not run the hidden launcher ${launcherPath} \u2014 run \`mmi-hub autoupdate on\``);
2023
2131
  }
2024
- const launcher = readLauncher(launcherPath);
2132
+ const launcher = readFileOrNull(launcherPath);
2025
2133
  if (hubDist && !launcher?.includes(hubDist)) {
2026
2134
  return fail(`"${TASK_NAME}" launcher does not point at the current @mutmutco/hub dist ${hubDist} \u2014 run \`mmi-hub autoupdate on\``);
2027
2135
  }
@@ -2038,9 +2146,74 @@ function schedulerStatus(env = process.env) {
2038
2146
  const ran = !info ? "run history unreadable" : neverRan(info) ? "never run yet" : `last run ${info.lastRunTime} \u2192 exit ${info.lastTaskResult ?? "unknown"}`;
2039
2147
  return { ok: true, supported: true, enabled: true, ...runInfo, detail: `"${TASK_NAME}" registered ${CADENCE}; ${points}; ${ran}; next ${runInfo.nextRunTime ?? "unscheduled"}` };
2040
2148
  }
2041
- function installTask(env = process.env) {
2042
- if (process.platform !== "win32") return { ok: false, detail: "scheduler registration is Windows Task Scheduler only" };
2043
- const current = schedulerStatus(env);
2149
+ function darwinSchedulerStatus(env) {
2150
+ const plistPath = (0, import_node_path12.join)((0, import_node_os5.homedir)(), "Library", "LaunchAgents", LAUNCHD_PLIST_NAME);
2151
+ const plist = readFileOrNull(plistPath);
2152
+ if (!plist) {
2153
+ return { ok: false, supported: true, enabled: false, ...EMPTY_RUN_INFO, detail: `"${LAUNCHD_LABEL}" is not registered \u2014 run \`mmi-hub autoupdate on\`` };
2154
+ }
2155
+ const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2156
+ const launcherPath = (0, import_node_path12.join)(statePaths(env).root, POSIX_LAUNCHER_FILE);
2157
+ const fail = (detail) => ({ ok: false, supported: true, enabled: true, ...EMPTY_RUN_INFO, detail });
2158
+ if (!plist.includes(launcherPath)) {
2159
+ return fail(`"${LAUNCHD_LABEL}" does not run the reconcile launcher ${launcherPath} \u2014 run \`mmi-hub autoupdate on\``);
2160
+ }
2161
+ const launcher = readFileOrNull(launcherPath);
2162
+ if (hubDist && !launcher?.includes(hubDist)) {
2163
+ return fail(`"${LAUNCHD_LABEL}" launcher does not point at the current @mutmutco/hub dist ${hubDist} \u2014 run \`mmi-hub autoupdate on\``);
2164
+ }
2165
+ if (!plist.includes(`<integer>${STARTINTERVAL_SECONDS}</integer>`)) {
2166
+ return fail(`"${LAUNCHD_LABEL}" has no ${STARTINTERVAL_SECONDS}s StartInterval \u2014 run \`mmi-hub autoupdate on\``);
2167
+ }
2168
+ const loaded = launchctl(["print", `gui/${uid()}/${LAUNCHD_LABEL}`]);
2169
+ if (loaded.status !== 0) {
2170
+ return fail(`"${LAUNCHD_LABEL}" is registered but not loaded \u2014 run \`mmi-hub autoupdate on\``);
2171
+ }
2172
+ const points = `points at ${hubDist ?? "the registered Hub dist"}`;
2173
+ return { ok: true, supported: true, enabled: true, ...EMPTY_RUN_INFO, detail: `"${LAUNCHD_LABEL}" registered ${POSIX_CADENCE}; ${points}` };
2174
+ }
2175
+ function linuxSchedulerStatus(env) {
2176
+ const unitDir = (0, import_node_path12.join)((0, import_node_os5.homedir)(), ".config", "systemd", "user");
2177
+ const timerPath = (0, import_node_path12.join)(unitDir, SYSTEMD_TIMER_NAME);
2178
+ const timer = readFileOrNull(timerPath);
2179
+ const service = readFileOrNull((0, import_node_path12.join)(unitDir, SYSTEMD_SERVICE_NAME));
2180
+ if (!timer || !service) {
2181
+ return { ok: false, supported: true, enabled: false, ...EMPTY_RUN_INFO, detail: `"${SYSTEMD_TIMER_NAME}" is not registered \u2014 run \`mmi-hub autoupdate on\`` };
2182
+ }
2183
+ const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2184
+ const launcherPath = (0, import_node_path12.join)(statePaths(env).root, POSIX_LAUNCHER_FILE);
2185
+ const fail = (detail) => ({ ok: false, supported: true, enabled: true, ...EMPTY_RUN_INFO, detail });
2186
+ if (!service.includes(launcherPath)) {
2187
+ return fail(`"${SYSTEMD_SERVICE_NAME}" does not run the reconcile launcher ${launcherPath} \u2014 run \`mmi-hub autoupdate on\``);
2188
+ }
2189
+ const launcher = readFileOrNull(launcherPath);
2190
+ if (hubDist && !launcher?.includes(hubDist)) {
2191
+ return fail(`"${SYSTEMD_SERVICE_NAME}" launcher does not point at the current @mutmutco/hub dist ${hubDist} \u2014 run \`mmi-hub autoupdate on\``);
2192
+ }
2193
+ if (!timer.includes(`OnUnitActiveSec=${STARTINTERVAL_SECONDS}`)) {
2194
+ return fail(`"${SYSTEMD_TIMER_NAME}" has no ${STARTINTERVAL_SECONDS}s OnUnitActiveSec \u2014 run \`mmi-hub autoupdate on\``);
2195
+ }
2196
+ const enabled = systemctlUser(["is-enabled", SYSTEMD_TIMER_NAME]);
2197
+ if (enabled.status !== 0) {
2198
+ return fail(`"${SYSTEMD_TIMER_NAME}" is registered but not enabled \u2014 run \`mmi-hub autoupdate on\``);
2199
+ }
2200
+ const points = `points at ${hubDist ?? "the registered Hub dist"}`;
2201
+ return { ok: true, supported: true, enabled: true, ...EMPTY_RUN_INFO, detail: `"${SYSTEMD_TIMER_NAME}" registered ${POSIX_CADENCE}, ${RUN_BOUND_SECONDS}s bound; ${points}` };
2202
+ }
2203
+ function schedulerStatus(env = process.env) {
2204
+ if (process.platform === "win32") return windowsSchedulerStatus(env);
2205
+ if (process.platform === "darwin") return darwinSchedulerStatus(env);
2206
+ if (process.platform === "linux") return linuxSchedulerStatus(env);
2207
+ return {
2208
+ ok: false,
2209
+ supported: false,
2210
+ enabled: false,
2211
+ ...EMPTY_RUN_INFO,
2212
+ detail: "automatic scheduling is available on Windows Task Scheduler, macOS launchd, and Linux systemd user timers only"
2213
+ };
2214
+ }
2215
+ function installWindowsTask(env) {
2216
+ const current = windowsSchedulerStatus(env);
2044
2217
  if (current.ok) return { ok: true, detail: `already enabled \u2014 ${current.detail}` };
2045
2218
  const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2046
2219
  if (!hubDist) return { ok: false, detail: "cannot resolve the global @mutmutco/hub dist \u2014 run `npm install -g @mutmutco/hub` first" };
@@ -2057,18 +2230,68 @@ function installTask(env = process.env) {
2057
2230
  if (!denied || !elevatedRelaunch("on", env)) {
2058
2231
  return { ok: false, detail: denied ? "task creation needs elevation and UAC consent was not granted \u2014 run `mmi-hub autoupdate on` from an elevated terminal" : `schtasks /create failed: ${(create.stderr || create.stdout).trim()}` };
2059
2232
  }
2060
- const verify = schedulerStatus(env);
2233
+ const verify = windowsSchedulerStatus(env);
2061
2234
  return verify.ok ? { ok: true, detail: `enabled "${TASK_NAME}" (elevated) \u2014 ${CADENCE}` } : { ok: false, detail: `elevated registration did not take: ${verify.detail}` };
2062
2235
  }
2063
2236
  return { ok: true, detail: `enabled "${TASK_NAME}" \u2014 ${CADENCE} \u2192 ${hubDist}` };
2064
2237
  }
2065
- function uninstallTask(env = process.env) {
2066
- if (process.platform !== "win32") return { ok: false, detail: "scheduler registration is Windows Task Scheduler only" };
2238
+ function installLaunchd(env) {
2239
+ const current = darwinSchedulerStatus(env);
2240
+ if (current.ok) return { ok: true, detail: `already enabled \u2014 ${current.detail}` };
2241
+ const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2242
+ if (!hubDist) return { ok: false, detail: "cannot resolve the global @mutmutco/hub dist \u2014 run `npm install -g @mutmutco/hub` first" };
2243
+ const paths = ensureState(env);
2244
+ const launcherPath = (0, import_node_path12.join)(paths.root, POSIX_LAUNCHER_FILE);
2245
+ (0, import_node_fs14.writeFileSync)(launcherPath, launcherSh(process.execPath, hubDist), "utf8");
2246
+ (0, import_node_fs14.chmodSync)(launcherPath, 493);
2247
+ const agentsDir = (0, import_node_path12.join)((0, import_node_os5.homedir)(), "Library", "LaunchAgents");
2248
+ (0, import_node_fs14.mkdirSync)(agentsDir, { recursive: true });
2249
+ const plistPath = (0, import_node_path12.join)(agentsDir, LAUNCHD_PLIST_NAME);
2250
+ (0, import_node_fs14.writeFileSync)(plistPath, launchdPlist(launcherPath, paths.root), "utf8");
2251
+ launchctl(["bootout", `gui/${uid()}/${LAUNCHD_LABEL}`]);
2252
+ const load = launchctl(["bootstrap", `gui/${uid()}`, plistPath]);
2253
+ if (load.status !== 0) {
2254
+ return { ok: false, detail: `launchctl bootstrap failed: ${(load.stderr || load.stdout).trim() || "unknown error"}` };
2255
+ }
2256
+ const verify = darwinSchedulerStatus(env);
2257
+ return verify.ok ? { ok: true, detail: `enabled "${LAUNCHD_LABEL}" \u2014 ${POSIX_CADENCE} \u2192 ${hubDist}` } : { ok: false, detail: `registration did not take: ${verify.detail}` };
2258
+ }
2259
+ function installSystemdTimer(env) {
2260
+ const current = linuxSchedulerStatus(env);
2261
+ if (current.ok) return { ok: true, detail: `already enabled \u2014 ${current.detail}` };
2262
+ const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2263
+ if (!hubDist) return { ok: false, detail: "cannot resolve the global @mutmutco/hub dist \u2014 run `npm install -g @mutmutco/hub` first" };
2264
+ const paths = ensureState(env);
2265
+ const launcherPath = (0, import_node_path12.join)(paths.root, POSIX_LAUNCHER_FILE);
2266
+ (0, import_node_fs14.writeFileSync)(launcherPath, launcherSh(process.execPath, hubDist), "utf8");
2267
+ (0, import_node_fs14.chmodSync)(launcherPath, 493);
2268
+ const unitDir = (0, import_node_path12.join)((0, import_node_os5.homedir)(), ".config", "systemd", "user");
2269
+ (0, import_node_fs14.mkdirSync)(unitDir, { recursive: true });
2270
+ (0, import_node_fs14.writeFileSync)((0, import_node_path12.join)(unitDir, SYSTEMD_SERVICE_NAME), systemdServiceUnit(launcherPath, paths.root), "utf8");
2271
+ (0, import_node_fs14.writeFileSync)((0, import_node_path12.join)(unitDir, SYSTEMD_TIMER_NAME), systemdTimerUnit(), "utf8");
2272
+ const reload = systemctlUser(["daemon-reload"]);
2273
+ if (reload.status !== 0) {
2274
+ return { ok: false, detail: `systemctl --user daemon-reload failed: ${(reload.stderr || reload.stdout).trim() || "unknown error \u2014 is a user systemd instance running?"}` };
2275
+ }
2276
+ const enable = systemctlUser(["enable", "--now", SYSTEMD_TIMER_NAME]);
2277
+ if (enable.status !== 0) {
2278
+ return { ok: false, detail: `systemctl --user enable --now ${SYSTEMD_TIMER_NAME} failed: ${(enable.stderr || enable.stdout).trim() || "unknown error"}` };
2279
+ }
2280
+ const verify = linuxSchedulerStatus(env);
2281
+ return verify.ok ? { ok: true, detail: `enabled "${SYSTEMD_TIMER_NAME}" \u2014 ${POSIX_CADENCE} \u2192 ${hubDist}` } : { ok: false, detail: `registration did not take: ${verify.detail}` };
2282
+ }
2283
+ function installTask(env = process.env) {
2284
+ if (process.platform === "win32") return installWindowsTask(env);
2285
+ if (process.platform === "darwin") return installLaunchd(env);
2286
+ if (process.platform === "linux") return installSystemdTimer(env);
2287
+ return { ok: false, detail: "scheduler registration is available on Windows Task Scheduler, macOS launchd, and Linux systemd user timers only" };
2288
+ }
2289
+ function uninstallWindowsTask(env) {
2067
2290
  const del = schtasks(["/delete", "/tn", TASK_NAME, "/f"]);
2068
2291
  if (del.status !== 0) {
2069
2292
  const query = schtasks(["/query", "/tn", TASK_NAME]);
2070
2293
  if (query.status !== 0 && !/access is denied/i.test(del.stderr + del.stdout)) {
2071
- dropLauncher(env);
2294
+ dropLauncher(env, LAUNCHER_FILE);
2072
2295
  return { ok: true, detail: `automatic updates already off \u2014 "${TASK_NAME}" is not registered; installed tooling was kept` };
2073
2296
  }
2074
2297
  const denied = /access is denied/i.test(del.stderr + del.stdout);
@@ -2079,12 +2302,47 @@ function uninstallTask(env = process.env) {
2079
2302
  return { ok: false, detail: `elevated deletion did not take: "${TASK_NAME}" is still registered` };
2080
2303
  }
2081
2304
  }
2082
- dropLauncher(env);
2305
+ dropLauncher(env, LAUNCHER_FILE);
2083
2306
  return { ok: true, detail: `automatic updates off \u2014 removed "${TASK_NAME}"; installed tooling was kept` };
2084
2307
  }
2085
- function dropLauncher(env) {
2308
+ function uninstallLaunchd(env) {
2309
+ const plistPath = (0, import_node_path12.join)((0, import_node_os5.homedir)(), "Library", "LaunchAgents", LAUNCHD_PLIST_NAME);
2310
+ const boot = launchctl(["bootout", `gui/${uid()}/${LAUNCHD_LABEL}`]);
2311
+ if (boot.status !== 0 && (0, import_node_fs14.existsSync)(plistPath)) {
2312
+ return { ok: false, detail: `launchctl bootout failed: ${(boot.stderr || boot.stdout).trim() || "unknown error"}` };
2313
+ }
2314
+ try {
2315
+ (0, import_node_fs14.rmSync)(plistPath, { force: true });
2316
+ } catch {
2317
+ }
2318
+ dropLauncher(env, POSIX_LAUNCHER_FILE);
2319
+ return { ok: true, detail: `automatic updates off \u2014 removed "${LAUNCHD_LABEL}"; installed tooling was kept` };
2320
+ }
2321
+ function uninstallSystemdTimer(env) {
2322
+ const unitDir = (0, import_node_path12.join)((0, import_node_os5.homedir)(), ".config", "systemd", "user");
2323
+ const timerPath = (0, import_node_path12.join)(unitDir, SYSTEMD_TIMER_NAME);
2324
+ const disable = systemctlUser(["disable", "--now", SYSTEMD_TIMER_NAME]);
2325
+ if (disable.status !== 0 && (0, import_node_fs14.existsSync)(timerPath)) {
2326
+ return { ok: false, detail: `systemctl --user disable --now ${SYSTEMD_TIMER_NAME} failed: ${(disable.stderr || disable.stdout).trim() || "unknown error"}` };
2327
+ }
2328
+ try {
2329
+ (0, import_node_fs14.rmSync)(timerPath, { force: true });
2330
+ (0, import_node_fs14.rmSync)((0, import_node_path12.join)(unitDir, SYSTEMD_SERVICE_NAME), { force: true });
2331
+ } catch {
2332
+ }
2333
+ systemctlUser(["daemon-reload"]);
2334
+ dropLauncher(env, POSIX_LAUNCHER_FILE);
2335
+ return { ok: true, detail: `automatic updates off \u2014 removed "${SYSTEMD_TIMER_NAME}"; installed tooling was kept` };
2336
+ }
2337
+ function uninstallTask(env = process.env) {
2338
+ if (process.platform === "win32") return uninstallWindowsTask(env);
2339
+ if (process.platform === "darwin") return uninstallLaunchd(env);
2340
+ if (process.platform === "linux") return uninstallSystemdTimer(env);
2341
+ return { ok: false, detail: "scheduler registration is available on Windows Task Scheduler, macOS launchd, and Linux systemd user timers only" };
2342
+ }
2343
+ function dropLauncher(env, file) {
2086
2344
  try {
2087
- (0, import_node_fs14.rmSync)((0, import_node_path12.join)(statePaths(env).root, LAUNCHER_FILE), { force: true });
2345
+ (0, import_node_fs14.rmSync)((0, import_node_path12.join)(statePaths(env).root, file), { force: true });
2088
2346
  } catch {
2089
2347
  }
2090
2348
  }
@@ -2149,7 +2407,7 @@ function hubStatus(hubVersion, env = process.env, invokedPath = process.argv[1])
2149
2407
  const failures = [];
2150
2408
  if (error) failures.push(`${error} \u2014 inspect permissions for ${paths.journalPath}`);
2151
2409
  if (!events.length && !error) failures.push("no maintenance journal yet \u2014 run `mmi-hub install`");
2152
- if (!schedule.ok) failures.push(schedule.detail);
2410
+ if (!schedule.ok && schedule.supported) failures.push(schedule.detail);
2153
2411
  for (const [surface, row] of Object.entries(installed)) {
2154
2412
  if (!row.installed) failures.push(`${surface}: installed version is unreadable at ${row.location ?? "an unknown location"} \u2014 run \`mmi-hub update\``);
2155
2413
  if (row.state === "behind") failures.push(`${surface}: installed ${row.installed} is behind expected ${row.expected} \u2014 run \`mmi-hub update\``);
@@ -2157,7 +2415,8 @@ function hubStatus(hubVersion, env = process.env, invokedPath = process.argv[1])
2157
2415
  for (const event of latestRunEvents) {
2158
2416
  if ((event.kind === "arm" || event.kind === "reconcile") && (event.verdict === "fail" || event.verdict === "defer")) {
2159
2417
  const surface = event.surface === "updater" ? "hub" : event.surface ?? event.kind;
2160
- failures.push(`last run ${surface}: ${event.detail ?? event.verdict} \u2014 run \`mmi-hub update\``);
2418
+ const repeat = event.kind === "arm" && event.verdict === "fail" && event.surface && journalSurfaceRepeatedFail(paths.journalPath, event.surface, latestEvent.run);
2419
+ failures.push(repeat ? `last run ${surface}: ${event.detail ?? event.verdict} \u2014 already failed the previous run too; \`mmi-hub update\` will not clear it, inspect the journal` : `last run ${surface}: ${event.detail ?? event.verdict} \u2014 run \`mmi-hub update\``);
2161
2420
  }
2162
2421
  }
2163
2422
  return {
@@ -2218,9 +2477,10 @@ var RETRY_HINTS = [
2218
2477
  [/elevat|access is denied/, "run from elevated terminal"]
2219
2478
  ];
2220
2479
  var FAIL_CONSEQUENCE = [
2221
- [/post-switch probe|post-update evidence|post-install evidence|expected .*\/true/, "not verified \u2014 previous version restored, safe to use"],
2222
- [/rollback|did not restore/, "rollback incomplete \u2014 run doctor now"]
2480
+ [/rollback failed|did not restore/, "rollback incomplete \u2014 run doctor now"],
2481
+ [/post-switch probe|post-update evidence|post-install evidence|post-switch marker|expected .*\/true/, "not verified \u2014 switch already applied, nothing was rolled back"]
2223
2482
  ];
2483
+ var RESTORE_EVIDENCE = /\brestored\b|\brolled back to\b/i;
2224
2484
  function matchFirst(patterns, detail) {
2225
2485
  const haystack = (detail ?? "").toLowerCase();
2226
2486
  for (const [pattern, text] of patterns) if (pattern.test(haystack)) return text;
@@ -2229,13 +2489,17 @@ function matchFirst(patterns, detail) {
2229
2489
  function retryHint(detail) {
2230
2490
  return matchFirst(RETRY_HINTS, detail) ?? "retries next hourly run";
2231
2491
  }
2232
- function failConsequence(detail) {
2492
+ function failConsequence(row) {
2493
+ const detail = row.detail ?? "";
2494
+ if (RESTORE_EVIDENCE.test(detail)) {
2495
+ return `not verified \u2014 restored previous version ${row.from ?? "unknown"}, safe to use`;
2496
+ }
2233
2497
  return matchFirst(FAIL_CONSEQUENCE, detail) ?? "update failed \u2014 previous version kept";
2234
2498
  }
2235
2499
  function classify(arm) {
2236
2500
  const kind = arm.verdict === "ok" ? arm.from !== null && arm.from === arm.to ? "current" : "updated" : arm.verdict === "skip" ? "keep" : arm.verdict === "defer" ? "retry" : "failed";
2237
2501
  const note = kind === "updated" ? ACTIVATION_NOTE[arm.surface] ?? null : kind === "keep" ? "above target" : kind === "retry" ? retryHint(arm.detail) : null;
2238
- return { name: arm.surface === "updater" ? "hub" : arm.surface, from: arm.from, to: arm.to, kind, note, detail: arm.detail };
2502
+ return { name: arm.surface === "updater" ? "hub" : arm.surface, from: arm.from, to: arm.to, kind, note, detail: arm.detail, repeatFailure: arm.repeatFailure === true };
2239
2503
  }
2240
2504
  function statusText(kind, dryRun) {
2241
2505
  if (dryRun && kind === "updated") return "would update";
@@ -2309,7 +2573,7 @@ function collectRows(summary) {
2309
2573
  for (const arm of summary.plugins) rows.push(classify(arm));
2310
2574
  for (const surface of summary.surfaces) {
2311
2575
  if (surface.present && surface.action === "arm-pending") {
2312
- rows.push({ name: surface.id, from: null, to: summary.target ?? "?", kind: "pending", note: "arm not landed", detail: "" });
2576
+ rows.push({ name: surface.id, from: null, to: summary.target ?? "?", kind: "pending", note: "arm not landed", detail: "", repeatFailure: false });
2313
2577
  }
2314
2578
  }
2315
2579
  if (summary.updater) rows.push(classify(summary.updater));
@@ -2360,8 +2624,8 @@ function reportFooterLines(summary, options = {}) {
2360
2624
  out.push("");
2361
2625
  for (const row of rows.filter((row2) => row2.kind === "failed")) {
2362
2626
  const mark = tty ? paint("failed", "\u2717 ") : "";
2363
- out.push(...noteLines(" " + mark + row.name, failConsequence(row.detail), width));
2364
- out.push(...noteLines(" fix: ", `mmi-cli doctor, then mmi-hub update`, width));
2627
+ out.push(...noteLines(" " + mark + row.name, failConsequence(row), width));
2628
+ out.push(...noteLines(" fix: ", row.repeatFailure ? "doctor + update already tried and did not clear this \u2014 see log, do not repeat" : "mmi-cli doctor, then mmi-hub update", width));
2365
2629
  out.push(...noteLines(" log: ", journalPath, width));
2366
2630
  }
2367
2631
  }
@@ -2596,6 +2860,8 @@ if (typeof require !== "undefined" && require.main === module) {
2596
2860
  hermesArm,
2597
2861
  hermesHome,
2598
2862
  hubStatus,
2863
+ launchdPlist,
2864
+ launcherSh,
2599
2865
  launcherVbs,
2600
2866
  localStartBoundary,
2601
2867
  main,
@@ -2606,6 +2872,8 @@ if (typeof require !== "undefined" && require.main === module) {
2606
2872
  retryHint,
2607
2873
  schedulerStatus,
2608
2874
  startSpinner,
2875
+ systemdServiceUnit,
2876
+ systemdTimerUnit,
2609
2877
  taskXml,
2610
2878
  updateHeader,
2611
2879
  wrapWords
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/hub",
3
- "version": "4.0.3",
3
+ "version": "4.0.5",
4
4
  "description": "Install and maintain the MMI CLI and every present host surface from one release-gated Hub command.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",