@mutmutco/hub 4.0.3 → 4.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.cjs +296 -38
  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);
@@ -362,9 +387,8 @@ var import_node_fs5 = require("node:fs");
362
387
  var import_node_child_process3 = require("node:child_process");
363
388
  var import_node_path4 = require("node:path");
364
389
  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");
390
+ const root = globalRoot(env);
391
+ return root ? (0, import_node_path4.join)(root, "@mutmutco", "cli", "dist", "index.cjs") : null;
368
392
  }
369
393
  function probeCliVersion(distPath) {
370
394
  if (!fileExists(distPath)) return null;
@@ -421,9 +445,10 @@ function cliArm(options) {
421
445
  if (globalInstall.status !== 0) {
422
446
  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
447
  }
424
- const switched = probeCliVersion(globalCliDist(env) ?? "");
448
+ const switchedDist = globalCliDist(env);
449
+ const switched = probeCliVersion(switchedDist ?? "");
425
450
  if (switched !== target) {
426
- return { surface: "cli", from: installed, to: switched ?? installed ?? "unknown", verdict: "fail", detail: `post-switch probe returned ${switched ?? "dead"}, expected ${target}` };
451
+ 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
452
  }
428
453
  return { surface: "cli", from: installed, to: target, verdict: "ok", detail: `converged ${installed ?? "absent"} -> ${target} (staged, verified, switched)` };
429
454
  }
@@ -433,9 +458,8 @@ var import_node_fs6 = require("node:fs");
433
458
  var import_node_child_process4 = require("node:child_process");
434
459
  var import_node_path5 = require("node:path");
435
460
  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("/"));
461
+ const root = globalRoot(env);
462
+ return root ? (0, import_node_path5.join)(root, ...packageName.split("/"), ...distRel.split("/")) : null;
439
463
  }
440
464
  function probeVersion(distPath) {
441
465
  if (!fileExists(distPath)) return null;
@@ -501,9 +525,10 @@ function npmGlobalArm(options) {
501
525
  if (globalInstall.status !== 0) {
502
526
  return result(installed, target, "defer", `global install failed: ${tail(globalInstall.stderr || globalInstall.stdout)}`);
503
527
  }
504
- const switched = probeVersion(globalDist(env, packageName, distRel) ?? "");
528
+ const switchedDist = globalDist(env, packageName, distRel);
529
+ const switched = probeVersion(switchedDist ?? "");
505
530
  if (switched !== target) {
506
- return result(installed, switched ?? installed ?? "unknown", "fail", `post-switch probe returned ${switched ?? "dead"}, expected ${target}`);
531
+ 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
532
  }
508
533
  return result(installed, target, "ok", `converged ${installed ?? "absent"} -> ${target} (staged, verified, switched)`);
509
534
  }
@@ -1787,6 +1812,7 @@ function reconcile(options) {
1787
1812
  };
1788
1813
  const summary = { run, target: null, gate: null, atomicity: null, cli: null, updater: null, plugins: [], reap: [], surfaces: [], journalOk: true, exit: 0 };
1789
1814
  const recordArm = (arm) => {
1815
+ if (arm.verdict === "fail") arm.repeatFailure = journalSurfaceRepeatedFail(paths.journalPath, arm.surface, run);
1790
1816
  journal({ kind: "arm", surface: arm.surface, from: arm.from, to: arm.to, verdict: arm.verdict, detail: arm.detail, compat: arm.compat });
1791
1817
  options.onArm?.(arm);
1792
1818
  if (arm.verdict === "defer") summary.exit = Math.max(summary.exit, 2);
@@ -1890,16 +1916,37 @@ function reconcile(options) {
1890
1916
  // src/scheduler.ts
1891
1917
  var import_node_child_process9 = require("node:child_process");
1892
1918
  var import_node_fs14 = require("node:fs");
1919
+ var import_node_os5 = require("node:os");
1893
1920
  var import_node_path12 = require("node:path");
1894
1921
  var TASK_NAME = "MMI Fleet Updater";
1895
1922
  var LAUNCHER_FILE = "reconcile-hidden.vbs";
1896
1923
  var RUN_BOUND = "PT15M";
1897
1924
  var REPEAT_INTERVAL = "PT1H";
1898
1925
  var CADENCE = `hourly from registration, StartWhenAvailable, single-instance, ${RUN_BOUND} bound`;
1926
+ var POSIX_LAUNCHER_FILE = "reconcile.sh";
1927
+ var STARTINTERVAL_SECONDS = 3600;
1928
+ var RUN_BOUND_SECONDS = 900;
1929
+ var POSIX_CADENCE = "hourly from registration, catch-up-if-missed, single-instance";
1930
+ var LAUNCHD_LABEL = "com.mutmutco.mmi-hub.updater";
1931
+ var LAUNCHD_PLIST_NAME = `${LAUNCHD_LABEL}.plist`;
1932
+ var SYSTEMD_UNIT = "mmi-hub-updater";
1933
+ var SYSTEMD_SERVICE_NAME = `${SYSTEMD_UNIT}.service`;
1934
+ var SYSTEMD_TIMER_NAME = `${SYSTEMD_UNIT}.timer`;
1899
1935
  function schtasks(args) {
1900
1936
  const result = (0, import_node_child_process9.spawnSync)("schtasks.exe", args, { encoding: "utf8", windowsHide: true });
1901
1937
  return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
1902
1938
  }
1939
+ function launchctl(args) {
1940
+ const result = (0, import_node_child_process9.spawnSync)("launchctl", args, { encoding: "utf8", windowsHide: true });
1941
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
1942
+ }
1943
+ function systemctlUser(args) {
1944
+ const result = (0, import_node_child_process9.spawnSync)("systemctl", ["--user", ...args], { encoding: "utf8", windowsHide: true });
1945
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
1946
+ }
1947
+ function uid() {
1948
+ return typeof process.getuid === "function" ? process.getuid() : 0;
1949
+ }
1903
1950
  function xmlEscape(value) {
1904
1951
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1905
1952
  }
@@ -1918,6 +1965,14 @@ code = shell.Run("""${vbsQuote(nodeExe)}"" ""${vbsQuote(hubDist)}"" update", 0,
1918
1965
  WScript.Quit code
1919
1966
  `;
1920
1967
  }
1968
+ function launcherSh(nodeExe, hubDist) {
1969
+ const shQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
1970
+ return `#!/bin/sh
1971
+ # MMI Hub maintenance launcher \u2014 generated by \`mmi-hub autoupdate on\`.
1972
+ # The exit code is propagated and reported by \`mmi-hub status\`.
1973
+ exec ${shQuote(nodeExe)} ${shQuote(hubDist)} update
1974
+ `;
1975
+ }
1921
1976
  function taskXml(launcherPath, workDir, userId, startBoundary) {
1922
1977
  return `<?xml version="1.0" encoding="UTF-16"?>
1923
1978
  <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
@@ -1960,7 +2015,53 @@ function taskXml(launcherPath, workDir, userId, startBoundary) {
1960
2015
  </Task>
1961
2016
  `;
1962
2017
  }
1963
- function readLauncher(path) {
2018
+ function launchdPlist(launcherPath, workDir) {
2019
+ return `<?xml version="1.0" encoding="UTF-8"?>
2020
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2021
+ <plist version="1.0">
2022
+ <dict>
2023
+ <key>Label</key>
2024
+ <string>${LAUNCHD_LABEL}</string>
2025
+ <key>ProgramArguments</key>
2026
+ <array>
2027
+ <string>/bin/sh</string>
2028
+ <string>${xmlEscape(launcherPath)}</string>
2029
+ </array>
2030
+ <key>WorkingDirectory</key>
2031
+ <string>${xmlEscape(workDir)}</string>
2032
+ <key>StartInterval</key>
2033
+ <integer>${STARTINTERVAL_SECONDS}</integer>
2034
+ <key>RunAtLoad</key>
2035
+ <true/>
2036
+ </dict>
2037
+ </plist>
2038
+ `;
2039
+ }
2040
+ function systemdServiceUnit(launcherPath, workDir) {
2041
+ return `[Unit]
2042
+ Description=MMI Hub maintenance \u2014 converge this machine's MMI surfaces to the newest gated release
2043
+
2044
+ [Service]
2045
+ Type=oneshot
2046
+ WorkingDirectory=${workDir}
2047
+ ExecStart=/bin/sh ${launcherPath}
2048
+ TimeoutStartSec=${RUN_BOUND_SECONDS}
2049
+ `;
2050
+ }
2051
+ function systemdTimerUnit() {
2052
+ return `[Unit]
2053
+ Description=MMI Hub maintenance hourly reconcile timer
2054
+
2055
+ [Timer]
2056
+ OnUnitActiveSec=${STARTINTERVAL_SECONDS}
2057
+ Persistent=true
2058
+ AccuracySec=1min
2059
+
2060
+ [Install]
2061
+ WantedBy=timers.target
2062
+ `;
2063
+ }
2064
+ function readFileOrNull(path) {
1964
2065
  try {
1965
2066
  return (0, import_node_fs14.readFileSync)(path, "utf8");
1966
2067
  } catch {
@@ -1983,6 +2084,7 @@ function elevatedRelaunch(mode, env) {
1983
2084
  }
1984
2085
  var NEVER_RUN_RESULT = 267011;
1985
2086
  var NEVER_RUN_YEAR = 1999;
2087
+ var EMPTY_RUN_INFO = { lastRunTime: null, lastTaskResult: null, nextRunTime: null };
1986
2088
  function taskRunInfo() {
1987
2089
  const result = (0, import_node_child_process9.spawnSync)(
1988
2090
  "powershell.exe",
@@ -2004,24 +2106,20 @@ function taskRunInfo() {
2004
2106
  function neverRan(info) {
2005
2107
  return info.lastTaskResult === NEVER_RUN_RESULT || (info.lastRunTime?.includes(String(NEVER_RUN_YEAR)) ?? false);
2006
2108
  }
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
- }
2109
+ function windowsSchedulerStatus(env) {
2012
2110
  const query = schtasks(["/query", "/tn", TASK_NAME, "/xml"]);
2013
2111
  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\`` };
2112
+ return { ok: false, supported: true, enabled: false, ...EMPTY_RUN_INFO, detail: `"${TASK_NAME}" is not registered \u2014 run \`mmi-hub autoupdate on\`` };
2015
2113
  }
2016
2114
  const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2017
2115
  const launcherPath = (0, import_node_path12.join)(statePaths(env).root, LAUNCHER_FILE);
2018
2116
  const info = taskRunInfo();
2019
- const runInfo = info ?? empty;
2117
+ const runInfo = info ?? EMPTY_RUN_INFO;
2020
2118
  const fail = (detail) => ({ ok: false, supported: true, enabled: true, ...runInfo, detail });
2021
2119
  if (!query.stdout.includes(launcherPath)) {
2022
2120
  return fail(`"${TASK_NAME}" action does not run the hidden launcher ${launcherPath} \u2014 run \`mmi-hub autoupdate on\``);
2023
2121
  }
2024
- const launcher = readLauncher(launcherPath);
2122
+ const launcher = readFileOrNull(launcherPath);
2025
2123
  if (hubDist && !launcher?.includes(hubDist)) {
2026
2124
  return fail(`"${TASK_NAME}" launcher does not point at the current @mutmutco/hub dist ${hubDist} \u2014 run \`mmi-hub autoupdate on\``);
2027
2125
  }
@@ -2038,9 +2136,74 @@ function schedulerStatus(env = process.env) {
2038
2136
  const ran = !info ? "run history unreadable" : neverRan(info) ? "never run yet" : `last run ${info.lastRunTime} \u2192 exit ${info.lastTaskResult ?? "unknown"}`;
2039
2137
  return { ok: true, supported: true, enabled: true, ...runInfo, detail: `"${TASK_NAME}" registered ${CADENCE}; ${points}; ${ran}; next ${runInfo.nextRunTime ?? "unscheduled"}` };
2040
2138
  }
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);
2139
+ function darwinSchedulerStatus(env) {
2140
+ const plistPath = (0, import_node_path12.join)((0, import_node_os5.homedir)(), "Library", "LaunchAgents", LAUNCHD_PLIST_NAME);
2141
+ const plist = readFileOrNull(plistPath);
2142
+ if (!plist) {
2143
+ return { ok: false, supported: true, enabled: false, ...EMPTY_RUN_INFO, detail: `"${LAUNCHD_LABEL}" is not registered \u2014 run \`mmi-hub autoupdate on\`` };
2144
+ }
2145
+ const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2146
+ const launcherPath = (0, import_node_path12.join)(statePaths(env).root, POSIX_LAUNCHER_FILE);
2147
+ const fail = (detail) => ({ ok: false, supported: true, enabled: true, ...EMPTY_RUN_INFO, detail });
2148
+ if (!plist.includes(launcherPath)) {
2149
+ return fail(`"${LAUNCHD_LABEL}" does not run the reconcile launcher ${launcherPath} \u2014 run \`mmi-hub autoupdate on\``);
2150
+ }
2151
+ const launcher = readFileOrNull(launcherPath);
2152
+ if (hubDist && !launcher?.includes(hubDist)) {
2153
+ return fail(`"${LAUNCHD_LABEL}" launcher does not point at the current @mutmutco/hub dist ${hubDist} \u2014 run \`mmi-hub autoupdate on\``);
2154
+ }
2155
+ if (!plist.includes(`<integer>${STARTINTERVAL_SECONDS}</integer>`)) {
2156
+ return fail(`"${LAUNCHD_LABEL}" has no ${STARTINTERVAL_SECONDS}s StartInterval \u2014 run \`mmi-hub autoupdate on\``);
2157
+ }
2158
+ const loaded = launchctl(["print", `gui/${uid()}/${LAUNCHD_LABEL}`]);
2159
+ if (loaded.status !== 0) {
2160
+ return fail(`"${LAUNCHD_LABEL}" is registered but not loaded \u2014 run \`mmi-hub autoupdate on\``);
2161
+ }
2162
+ const points = `points at ${hubDist ?? "the registered Hub dist"}`;
2163
+ return { ok: true, supported: true, enabled: true, ...EMPTY_RUN_INFO, detail: `"${LAUNCHD_LABEL}" registered ${POSIX_CADENCE}; ${points}` };
2164
+ }
2165
+ function linuxSchedulerStatus(env) {
2166
+ const unitDir = (0, import_node_path12.join)((0, import_node_os5.homedir)(), ".config", "systemd", "user");
2167
+ const timerPath = (0, import_node_path12.join)(unitDir, SYSTEMD_TIMER_NAME);
2168
+ const timer = readFileOrNull(timerPath);
2169
+ const service = readFileOrNull((0, import_node_path12.join)(unitDir, SYSTEMD_SERVICE_NAME));
2170
+ if (!timer || !service) {
2171
+ return { ok: false, supported: true, enabled: false, ...EMPTY_RUN_INFO, detail: `"${SYSTEMD_TIMER_NAME}" is not registered \u2014 run \`mmi-hub autoupdate on\`` };
2172
+ }
2173
+ const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2174
+ const launcherPath = (0, import_node_path12.join)(statePaths(env).root, POSIX_LAUNCHER_FILE);
2175
+ const fail = (detail) => ({ ok: false, supported: true, enabled: true, ...EMPTY_RUN_INFO, detail });
2176
+ if (!service.includes(launcherPath)) {
2177
+ return fail(`"${SYSTEMD_SERVICE_NAME}" does not run the reconcile launcher ${launcherPath} \u2014 run \`mmi-hub autoupdate on\``);
2178
+ }
2179
+ const launcher = readFileOrNull(launcherPath);
2180
+ if (hubDist && !launcher?.includes(hubDist)) {
2181
+ return fail(`"${SYSTEMD_SERVICE_NAME}" launcher does not point at the current @mutmutco/hub dist ${hubDist} \u2014 run \`mmi-hub autoupdate on\``);
2182
+ }
2183
+ if (!timer.includes(`OnUnitActiveSec=${STARTINTERVAL_SECONDS}`)) {
2184
+ return fail(`"${SYSTEMD_TIMER_NAME}" has no ${STARTINTERVAL_SECONDS}s OnUnitActiveSec \u2014 run \`mmi-hub autoupdate on\``);
2185
+ }
2186
+ const enabled = systemctlUser(["is-enabled", SYSTEMD_TIMER_NAME]);
2187
+ if (enabled.status !== 0) {
2188
+ return fail(`"${SYSTEMD_TIMER_NAME}" is registered but not enabled \u2014 run \`mmi-hub autoupdate on\``);
2189
+ }
2190
+ const points = `points at ${hubDist ?? "the registered Hub dist"}`;
2191
+ return { ok: true, supported: true, enabled: true, ...EMPTY_RUN_INFO, detail: `"${SYSTEMD_TIMER_NAME}" registered ${POSIX_CADENCE}, ${RUN_BOUND_SECONDS}s bound; ${points}` };
2192
+ }
2193
+ function schedulerStatus(env = process.env) {
2194
+ if (process.platform === "win32") return windowsSchedulerStatus(env);
2195
+ if (process.platform === "darwin") return darwinSchedulerStatus(env);
2196
+ if (process.platform === "linux") return linuxSchedulerStatus(env);
2197
+ return {
2198
+ ok: false,
2199
+ supported: false,
2200
+ enabled: false,
2201
+ ...EMPTY_RUN_INFO,
2202
+ detail: "automatic scheduling is available on Windows Task Scheduler, macOS launchd, and Linux systemd user timers only"
2203
+ };
2204
+ }
2205
+ function installWindowsTask(env) {
2206
+ const current = windowsSchedulerStatus(env);
2044
2207
  if (current.ok) return { ok: true, detail: `already enabled \u2014 ${current.detail}` };
2045
2208
  const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2046
2209
  if (!hubDist) return { ok: false, detail: "cannot resolve the global @mutmutco/hub dist \u2014 run `npm install -g @mutmutco/hub` first" };
@@ -2057,18 +2220,68 @@ function installTask(env = process.env) {
2057
2220
  if (!denied || !elevatedRelaunch("on", env)) {
2058
2221
  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
2222
  }
2060
- const verify = schedulerStatus(env);
2223
+ const verify = windowsSchedulerStatus(env);
2061
2224
  return verify.ok ? { ok: true, detail: `enabled "${TASK_NAME}" (elevated) \u2014 ${CADENCE}` } : { ok: false, detail: `elevated registration did not take: ${verify.detail}` };
2062
2225
  }
2063
2226
  return { ok: true, detail: `enabled "${TASK_NAME}" \u2014 ${CADENCE} \u2192 ${hubDist}` };
2064
2227
  }
2065
- function uninstallTask(env = process.env) {
2066
- if (process.platform !== "win32") return { ok: false, detail: "scheduler registration is Windows Task Scheduler only" };
2228
+ function installLaunchd(env) {
2229
+ const current = darwinSchedulerStatus(env);
2230
+ if (current.ok) return { ok: true, detail: `already enabled \u2014 ${current.detail}` };
2231
+ const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2232
+ if (!hubDist) return { ok: false, detail: "cannot resolve the global @mutmutco/hub dist \u2014 run `npm install -g @mutmutco/hub` first" };
2233
+ const paths = ensureState(env);
2234
+ const launcherPath = (0, import_node_path12.join)(paths.root, POSIX_LAUNCHER_FILE);
2235
+ (0, import_node_fs14.writeFileSync)(launcherPath, launcherSh(process.execPath, hubDist), "utf8");
2236
+ (0, import_node_fs14.chmodSync)(launcherPath, 493);
2237
+ const agentsDir = (0, import_node_path12.join)((0, import_node_os5.homedir)(), "Library", "LaunchAgents");
2238
+ (0, import_node_fs14.mkdirSync)(agentsDir, { recursive: true });
2239
+ const plistPath = (0, import_node_path12.join)(agentsDir, LAUNCHD_PLIST_NAME);
2240
+ (0, import_node_fs14.writeFileSync)(plistPath, launchdPlist(launcherPath, paths.root), "utf8");
2241
+ launchctl(["bootout", `gui/${uid()}/${LAUNCHD_LABEL}`]);
2242
+ const load = launchctl(["bootstrap", `gui/${uid()}`, plistPath]);
2243
+ if (load.status !== 0) {
2244
+ return { ok: false, detail: `launchctl bootstrap failed: ${(load.stderr || load.stdout).trim() || "unknown error"}` };
2245
+ }
2246
+ const verify = darwinSchedulerStatus(env);
2247
+ return verify.ok ? { ok: true, detail: `enabled "${LAUNCHD_LABEL}" \u2014 ${POSIX_CADENCE} \u2192 ${hubDist}` } : { ok: false, detail: `registration did not take: ${verify.detail}` };
2248
+ }
2249
+ function installSystemdTimer(env) {
2250
+ const current = linuxSchedulerStatus(env);
2251
+ if (current.ok) return { ok: true, detail: `already enabled \u2014 ${current.detail}` };
2252
+ const hubDist = globalDist(env, "@mutmutco/hub", "dist/index.cjs");
2253
+ if (!hubDist) return { ok: false, detail: "cannot resolve the global @mutmutco/hub dist \u2014 run `npm install -g @mutmutco/hub` first" };
2254
+ const paths = ensureState(env);
2255
+ const launcherPath = (0, import_node_path12.join)(paths.root, POSIX_LAUNCHER_FILE);
2256
+ (0, import_node_fs14.writeFileSync)(launcherPath, launcherSh(process.execPath, hubDist), "utf8");
2257
+ (0, import_node_fs14.chmodSync)(launcherPath, 493);
2258
+ const unitDir = (0, import_node_path12.join)((0, import_node_os5.homedir)(), ".config", "systemd", "user");
2259
+ (0, import_node_fs14.mkdirSync)(unitDir, { recursive: true });
2260
+ (0, import_node_fs14.writeFileSync)((0, import_node_path12.join)(unitDir, SYSTEMD_SERVICE_NAME), systemdServiceUnit(launcherPath, paths.root), "utf8");
2261
+ (0, import_node_fs14.writeFileSync)((0, import_node_path12.join)(unitDir, SYSTEMD_TIMER_NAME), systemdTimerUnit(), "utf8");
2262
+ const reload = systemctlUser(["daemon-reload"]);
2263
+ if (reload.status !== 0) {
2264
+ return { ok: false, detail: `systemctl --user daemon-reload failed: ${(reload.stderr || reload.stdout).trim() || "unknown error \u2014 is a user systemd instance running?"}` };
2265
+ }
2266
+ const enable = systemctlUser(["enable", "--now", SYSTEMD_TIMER_NAME]);
2267
+ if (enable.status !== 0) {
2268
+ return { ok: false, detail: `systemctl --user enable --now ${SYSTEMD_TIMER_NAME} failed: ${(enable.stderr || enable.stdout).trim() || "unknown error"}` };
2269
+ }
2270
+ const verify = linuxSchedulerStatus(env);
2271
+ return verify.ok ? { ok: true, detail: `enabled "${SYSTEMD_TIMER_NAME}" \u2014 ${POSIX_CADENCE} \u2192 ${hubDist}` } : { ok: false, detail: `registration did not take: ${verify.detail}` };
2272
+ }
2273
+ function installTask(env = process.env) {
2274
+ if (process.platform === "win32") return installWindowsTask(env);
2275
+ if (process.platform === "darwin") return installLaunchd(env);
2276
+ if (process.platform === "linux") return installSystemdTimer(env);
2277
+ return { ok: false, detail: "scheduler registration is available on Windows Task Scheduler, macOS launchd, and Linux systemd user timers only" };
2278
+ }
2279
+ function uninstallWindowsTask(env) {
2067
2280
  const del = schtasks(["/delete", "/tn", TASK_NAME, "/f"]);
2068
2281
  if (del.status !== 0) {
2069
2282
  const query = schtasks(["/query", "/tn", TASK_NAME]);
2070
2283
  if (query.status !== 0 && !/access is denied/i.test(del.stderr + del.stdout)) {
2071
- dropLauncher(env);
2284
+ dropLauncher(env, LAUNCHER_FILE);
2072
2285
  return { ok: true, detail: `automatic updates already off \u2014 "${TASK_NAME}" is not registered; installed tooling was kept` };
2073
2286
  }
2074
2287
  const denied = /access is denied/i.test(del.stderr + del.stdout);
@@ -2079,12 +2292,47 @@ function uninstallTask(env = process.env) {
2079
2292
  return { ok: false, detail: `elevated deletion did not take: "${TASK_NAME}" is still registered` };
2080
2293
  }
2081
2294
  }
2082
- dropLauncher(env);
2295
+ dropLauncher(env, LAUNCHER_FILE);
2083
2296
  return { ok: true, detail: `automatic updates off \u2014 removed "${TASK_NAME}"; installed tooling was kept` };
2084
2297
  }
2085
- function dropLauncher(env) {
2298
+ function uninstallLaunchd(env) {
2299
+ const plistPath = (0, import_node_path12.join)((0, import_node_os5.homedir)(), "Library", "LaunchAgents", LAUNCHD_PLIST_NAME);
2300
+ const boot = launchctl(["bootout", `gui/${uid()}/${LAUNCHD_LABEL}`]);
2301
+ if (boot.status !== 0 && (0, import_node_fs14.existsSync)(plistPath)) {
2302
+ return { ok: false, detail: `launchctl bootout failed: ${(boot.stderr || boot.stdout).trim() || "unknown error"}` };
2303
+ }
2086
2304
  try {
2087
- (0, import_node_fs14.rmSync)((0, import_node_path12.join)(statePaths(env).root, LAUNCHER_FILE), { force: true });
2305
+ (0, import_node_fs14.rmSync)(plistPath, { force: true });
2306
+ } catch {
2307
+ }
2308
+ dropLauncher(env, POSIX_LAUNCHER_FILE);
2309
+ return { ok: true, detail: `automatic updates off \u2014 removed "${LAUNCHD_LABEL}"; installed tooling was kept` };
2310
+ }
2311
+ function uninstallSystemdTimer(env) {
2312
+ const unitDir = (0, import_node_path12.join)((0, import_node_os5.homedir)(), ".config", "systemd", "user");
2313
+ const timerPath = (0, import_node_path12.join)(unitDir, SYSTEMD_TIMER_NAME);
2314
+ const disable = systemctlUser(["disable", "--now", SYSTEMD_TIMER_NAME]);
2315
+ if (disable.status !== 0 && (0, import_node_fs14.existsSync)(timerPath)) {
2316
+ return { ok: false, detail: `systemctl --user disable --now ${SYSTEMD_TIMER_NAME} failed: ${(disable.stderr || disable.stdout).trim() || "unknown error"}` };
2317
+ }
2318
+ try {
2319
+ (0, import_node_fs14.rmSync)(timerPath, { force: true });
2320
+ (0, import_node_fs14.rmSync)((0, import_node_path12.join)(unitDir, SYSTEMD_SERVICE_NAME), { force: true });
2321
+ } catch {
2322
+ }
2323
+ systemctlUser(["daemon-reload"]);
2324
+ dropLauncher(env, POSIX_LAUNCHER_FILE);
2325
+ return { ok: true, detail: `automatic updates off \u2014 removed "${SYSTEMD_TIMER_NAME}"; installed tooling was kept` };
2326
+ }
2327
+ function uninstallTask(env = process.env) {
2328
+ if (process.platform === "win32") return uninstallWindowsTask(env);
2329
+ if (process.platform === "darwin") return uninstallLaunchd(env);
2330
+ if (process.platform === "linux") return uninstallSystemdTimer(env);
2331
+ return { ok: false, detail: "scheduler registration is available on Windows Task Scheduler, macOS launchd, and Linux systemd user timers only" };
2332
+ }
2333
+ function dropLauncher(env, file) {
2334
+ try {
2335
+ (0, import_node_fs14.rmSync)((0, import_node_path12.join)(statePaths(env).root, file), { force: true });
2088
2336
  } catch {
2089
2337
  }
2090
2338
  }
@@ -2149,7 +2397,7 @@ function hubStatus(hubVersion, env = process.env, invokedPath = process.argv[1])
2149
2397
  const failures = [];
2150
2398
  if (error) failures.push(`${error} \u2014 inspect permissions for ${paths.journalPath}`);
2151
2399
  if (!events.length && !error) failures.push("no maintenance journal yet \u2014 run `mmi-hub install`");
2152
- if (!schedule.ok) failures.push(schedule.detail);
2400
+ if (!schedule.ok && schedule.supported) failures.push(schedule.detail);
2153
2401
  for (const [surface, row] of Object.entries(installed)) {
2154
2402
  if (!row.installed) failures.push(`${surface}: installed version is unreadable at ${row.location ?? "an unknown location"} \u2014 run \`mmi-hub update\``);
2155
2403
  if (row.state === "behind") failures.push(`${surface}: installed ${row.installed} is behind expected ${row.expected} \u2014 run \`mmi-hub update\``);
@@ -2157,7 +2405,8 @@ function hubStatus(hubVersion, env = process.env, invokedPath = process.argv[1])
2157
2405
  for (const event of latestRunEvents) {
2158
2406
  if ((event.kind === "arm" || event.kind === "reconcile") && (event.verdict === "fail" || event.verdict === "defer")) {
2159
2407
  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\``);
2408
+ const repeat = event.kind === "arm" && event.verdict === "fail" && event.surface && journalSurfaceRepeatedFail(paths.journalPath, event.surface, latestEvent.run);
2409
+ 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
2410
  }
2162
2411
  }
2163
2412
  return {
@@ -2218,9 +2467,10 @@ var RETRY_HINTS = [
2218
2467
  [/elevat|access is denied/, "run from elevated terminal"]
2219
2468
  ];
2220
2469
  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"]
2470
+ [/rollback failed|did not restore/, "rollback incomplete \u2014 run doctor now"],
2471
+ [/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
2472
  ];
2473
+ var RESTORE_EVIDENCE = /\brestored\b|\brolled back to\b/i;
2224
2474
  function matchFirst(patterns, detail) {
2225
2475
  const haystack = (detail ?? "").toLowerCase();
2226
2476
  for (const [pattern, text] of patterns) if (pattern.test(haystack)) return text;
@@ -2229,13 +2479,17 @@ function matchFirst(patterns, detail) {
2229
2479
  function retryHint(detail) {
2230
2480
  return matchFirst(RETRY_HINTS, detail) ?? "retries next hourly run";
2231
2481
  }
2232
- function failConsequence(detail) {
2482
+ function failConsequence(row) {
2483
+ const detail = row.detail ?? "";
2484
+ if (RESTORE_EVIDENCE.test(detail)) {
2485
+ return `not verified \u2014 restored previous version ${row.from ?? "unknown"}, safe to use`;
2486
+ }
2233
2487
  return matchFirst(FAIL_CONSEQUENCE, detail) ?? "update failed \u2014 previous version kept";
2234
2488
  }
2235
2489
  function classify(arm) {
2236
2490
  const kind = arm.verdict === "ok" ? arm.from !== null && arm.from === arm.to ? "current" : "updated" : arm.verdict === "skip" ? "keep" : arm.verdict === "defer" ? "retry" : "failed";
2237
2491
  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 };
2492
+ return { name: arm.surface === "updater" ? "hub" : arm.surface, from: arm.from, to: arm.to, kind, note, detail: arm.detail, repeatFailure: arm.repeatFailure === true };
2239
2493
  }
2240
2494
  function statusText(kind, dryRun) {
2241
2495
  if (dryRun && kind === "updated") return "would update";
@@ -2309,7 +2563,7 @@ function collectRows(summary) {
2309
2563
  for (const arm of summary.plugins) rows.push(classify(arm));
2310
2564
  for (const surface of summary.surfaces) {
2311
2565
  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: "" });
2566
+ rows.push({ name: surface.id, from: null, to: summary.target ?? "?", kind: "pending", note: "arm not landed", detail: "", repeatFailure: false });
2313
2567
  }
2314
2568
  }
2315
2569
  if (summary.updater) rows.push(classify(summary.updater));
@@ -2360,8 +2614,8 @@ function reportFooterLines(summary, options = {}) {
2360
2614
  out.push("");
2361
2615
  for (const row of rows.filter((row2) => row2.kind === "failed")) {
2362
2616
  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));
2617
+ out.push(...noteLines(" " + mark + row.name, failConsequence(row), width));
2618
+ 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
2619
  out.push(...noteLines(" log: ", journalPath, width));
2366
2620
  }
2367
2621
  }
@@ -2596,6 +2850,8 @@ if (typeof require !== "undefined" && require.main === module) {
2596
2850
  hermesArm,
2597
2851
  hermesHome,
2598
2852
  hubStatus,
2853
+ launchdPlist,
2854
+ launcherSh,
2599
2855
  launcherVbs,
2600
2856
  localStartBoundary,
2601
2857
  main,
@@ -2606,6 +2862,8 @@ if (typeof require !== "undefined" && require.main === module) {
2606
2862
  retryHint,
2607
2863
  schedulerStatus,
2608
2864
  startSpinner,
2865
+ systemdServiceUnit,
2866
+ systemdTimerUnit,
2609
2867
  taskXml,
2610
2868
  updateHeader,
2611
2869
  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.4",
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",