@mutmutco/cli 3.88.0 → 3.90.0

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/main.cjs +136 -48
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -4040,6 +4040,56 @@ var execFileP2 = (file, args, options = {}) => (
4040
4040
  rawExecFileP2(file, args, { encoding: "utf8", windowsHide: true, timeout: DEFAULT_EXEC_TIMEOUT_MS, killSignal: "SIGTERM", ...options })
4041
4041
  );
4042
4042
  var GIT_TIMEOUT_MS = DEFAULT_EXEC_TIMEOUT_MS;
4043
+ var ExecDeadlineError = class extends Error {
4044
+ constructor(step, timeoutMs, elapsedMs, killed) {
4045
+ super(
4046
+ `\`${step}\` did not finish within ${Math.round(timeoutMs / 1e3)}s (gave up after ${Math.round(elapsedMs / 1e3)}s). It stopped responding and did not exit when asked to, so the timeout alone could not end it; ${killed ? "its process tree was force-terminated" : "its process tree could NOT be terminated and may still be running"}.`
4047
+ );
4048
+ this.step = step;
4049
+ this.timeoutMs = timeoutMs;
4050
+ this.elapsedMs = elapsedMs;
4051
+ this.killed = killed;
4052
+ this.name = "ExecDeadlineError";
4053
+ }
4054
+ step;
4055
+ timeoutMs;
4056
+ elapsedMs;
4057
+ killed;
4058
+ };
4059
+ function killProcessTree(pid) {
4060
+ try {
4061
+ if (process.platform === "win32") {
4062
+ (0, import_node_child_process3.execFileSync)("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
4063
+ } else {
4064
+ process.kill(pid, "SIGKILL");
4065
+ }
4066
+ return true;
4067
+ } catch {
4068
+ return false;
4069
+ }
4070
+ }
4071
+ function execFileHard(file, args, options) {
4072
+ const { timeout, step, ...rest } = options;
4073
+ const started = Date.now();
4074
+ return new Promise((resolve5, reject) => {
4075
+ const child2 = (0, import_node_child_process3.execFile)(file, args, { encoding: "utf8", windowsHide: true, ...rest, timeout: 0 }, (error, stdout, stderr) => {
4076
+ clearTimeout(timer);
4077
+ if (expired) return;
4078
+ if (error) reject(error);
4079
+ else resolve5({ stdout: String(stdout), stderr: String(stderr) });
4080
+ });
4081
+ let expired = false;
4082
+ const timer = setTimeout(() => {
4083
+ expired = true;
4084
+ const killed = child2.pid ? killProcessTree(child2.pid) : false;
4085
+ child2.stdout?.destroy();
4086
+ child2.stderr?.destroy();
4087
+ child2.unref();
4088
+ reject(new ExecDeadlineError(step, timeout, Date.now() - started, killed));
4089
+ }, timeout);
4090
+ timer.unref?.();
4091
+ });
4092
+ }
4043
4093
  var cachedGithubLogin;
4044
4094
  async function githubLogin() {
4045
4095
  cachedGithubLogin ??= execFileP2("gh", ["api", "user", "--jq", ".login"]).then(({ stdout }) => stdout.trim() || void 0).catch(() => void 0);
@@ -8872,7 +8922,11 @@ function hasUserInstallRecord(file, pluginId) {
8872
8922
  var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
8873
8923
  var NPM_VIEW_TIMEOUT_MS = 15e3;
8874
8924
  function runHostBin(bin, args, opts) {
8875
- return isWin ? execFileP2("cmd.exe", ["/c", bin, ...args], opts) : execFileP2(bin, args, opts);
8925
+ const { step, timeout } = opts;
8926
+ const shared = { timeout, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024 };
8927
+ const file = isWin ? "cmd.exe" : bin;
8928
+ const argv = isWin ? ["/c", bin, ...args] : args;
8929
+ return step ? execFileHard(file, argv, { ...shared, step }) : execFileP2(file, argv, shared);
8876
8930
  }
8877
8931
  function surfaceConfigRoot(surface, env = process.env, home = (0, import_node_os4.homedir)()) {
8878
8932
  if (surface === "codex") return env.CODEX_HOME?.trim() || (0, import_node_path14.join)(home, ".codex");
@@ -9050,27 +9104,14 @@ function snapshotPluginGuardInput(surface = detectSurface(process.env), isOrgRep
9050
9104
  ) : (0, import_node_fs15.existsSync)((0, import_node_path14.join)(root, "plugins", "cache", "mutmutco", "mmi"))
9051
9105
  };
9052
9106
  }
9053
- async function runClaudePlugin(args) {
9054
- try {
9055
- await runHostBin("claude", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
9056
- return true;
9057
- } catch {
9058
- return false;
9059
- }
9060
- }
9061
- async function runCodexPlugin(args) {
9107
+ async function runPluginCli(bin, args, log) {
9062
9108
  try {
9063
- await runHostBin("codex", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
9109
+ await runHostBin(bin, args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS, step: `${bin} ${args.join(" ")}` });
9064
9110
  return true;
9065
- } catch {
9066
- return false;
9067
- }
9068
- }
9069
- async function runKiloPlugin(args) {
9070
- try {
9071
- await runHostBin("kilo", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
9072
- return true;
9073
- } catch {
9111
+ } catch (error) {
9112
+ if (error instanceof ExecDeadlineError) {
9113
+ log(` \u2717 ${error.message} Recover with: mmi-cli plugin heal`);
9114
+ }
9074
9115
  return false;
9075
9116
  }
9076
9117
  }
@@ -9131,7 +9172,8 @@ async function installCursorPluginCheckout(env = process.env) {
9131
9172
  });
9132
9173
  } else {
9133
9174
  await runHostBin("gh", ["repo", "clone", "mutmutco/MMI-Hub", staged, "--", "--branch", "main", "--depth", "1"], {
9134
- timeout: CLAUDE_PLUGIN_TIMEOUT_MS
9175
+ timeout: CLAUDE_PLUGIN_TIMEOUT_MS,
9176
+ step: "gh repo clone mutmutco/MMI-Hub"
9135
9177
  });
9136
9178
  }
9137
9179
  if (!cursorPluginTreeHealthy(staged)) {
@@ -9161,7 +9203,8 @@ async function installCursorPluginCheckout(env = process.env) {
9161
9203
  async function marketplaceAddRefSupported(bin) {
9162
9204
  try {
9163
9205
  const { stdout, stderr } = await runHostBin(bin, ["plugin", "marketplace", "add", "--help"], {
9164
- timeout: CLAUDE_PLUGIN_TIMEOUT_MS
9206
+ timeout: CLAUDE_PLUGIN_TIMEOUT_MS,
9207
+ step: `${bin} plugin marketplace add --help`
9165
9208
  });
9166
9209
  return marketplaceAddSupportsRef(`${stdout}
9167
9210
  ${stderr}`);
@@ -9191,7 +9234,7 @@ async function applyPluginHeal(surface, log, opts) {
9191
9234
  if (token === "kilo") {
9192
9235
  log(" \u21BB reinstalling the MMI plugin via `kilo plugin` (install \u2192 server() provisions the skills)\u2026");
9193
9236
  for (const step of descriptor.healSteps) {
9194
- const ok = await runKiloPlugin([...step.args]);
9237
+ const ok = await runPluginCli("kilo", [...step.args], log);
9195
9238
  if (healStepAborts(step, ok)) return false;
9196
9239
  }
9197
9240
  return true;
@@ -9207,7 +9250,7 @@ async function applyPluginHeal(surface, log, opts) {
9207
9250
  const pins = token === "claude" ? captureMarketplacePins(readKnownMarketplacesFile(pinsPath), [MMI_MARKETPLACE_NAME, JERV_MARKETPLACE_NAME]) : /* @__PURE__ */ new Map();
9208
9251
  try {
9209
9252
  for (const step of steps) {
9210
- const ok = token === "claude" ? await runClaudePlugin([...step.args]) : await runCodexPlugin([...step.args]);
9253
+ const ok = await runPluginCli(token, [...step.args], log);
9211
9254
  if (healStepAborts(step, ok)) return false;
9212
9255
  }
9213
9256
  } finally {
@@ -24037,27 +24080,41 @@ function decideStage(inputs) {
24037
24080
  var import_node_net2 = require("node:net");
24038
24081
  var STAGE_LIVE_HUB_REPO = "mutmutco/MMI-Hub";
24039
24082
  var IP_ECHO_URL = "https://api.ipify.org";
24083
+ var IP6_ECHO_URL = "https://api6.ipify.org";
24040
24084
  var IP_DETECT_TIMEOUT_MS = 1e4;
24041
24085
  function validStageLiveIp(ip) {
24042
24086
  return (0, import_node_net2.isIP)(ip.trim()) !== 0;
24043
24087
  }
24044
24088
  async function detectPublicIp(fetchImpl = fetch) {
24089
+ return detectPublicIpFrom(IP_ECHO_URL, fetchImpl);
24090
+ }
24091
+ async function detectCallerIps(fetchImpl = fetch) {
24092
+ const [v4, v6] = await Promise.allSettled([
24093
+ detectPublicIp(fetchImpl),
24094
+ detectPublicIpFrom(IP6_ECHO_URL, fetchImpl)
24095
+ ]);
24096
+ if (v4.status === "rejected") throw v4.reason;
24097
+ const ip = v4.value;
24098
+ const candidate = v6.status === "fulfilled" ? v6.value : void 0;
24099
+ return { ip, ip6: candidate && (0, import_node_net2.isIP)(candidate) === 6 ? candidate : void 0 };
24100
+ }
24101
+ async function detectPublicIpFrom(url, fetchImpl) {
24045
24102
  let res;
24046
24103
  try {
24047
- res = await fetchImpl(IP_ECHO_URL, { signal: AbortSignal.timeout(IP_DETECT_TIMEOUT_MS) });
24104
+ res = await fetchImpl(url, { signal: AbortSignal.timeout(IP_DETECT_TIMEOUT_MS) });
24048
24105
  } catch (e) {
24049
- throw new Error(`public IP detection failed (${IP_ECHO_URL}): ${e.message}`);
24106
+ throw new Error(`public IP detection failed (${url}): ${e.message}`);
24050
24107
  }
24051
- if (!res.ok) throw new Error(`public IP detection failed: HTTP ${res.status} from ${IP_ECHO_URL}`);
24108
+ if (!res.ok) throw new Error(`public IP detection failed: HTTP ${res.status} from ${url}`);
24052
24109
  const ip = (await res.text()).trim();
24053
- if (!validStageLiveIp(ip)) throw new Error(`public IP detection returned a non-IP body from ${IP_ECHO_URL}: "${ip.slice(0, 80)}"`);
24110
+ if (!validStageLiveIp(ip)) throw new Error(`public IP detection returned a non-IP body from ${url}: "${ip.slice(0, 80)}"`);
24054
24111
  return ip;
24055
24112
  }
24056
24113
  function stageLiveUpSteps(t) {
24057
24114
  return [
24058
- { label: `detect your public IP (${IP_ECHO_URL}, bounded)` },
24115
+ { label: `detect your public IPv4 and IPv6 (${IP_ECHO_URL} + ${IP6_ECHO_URL}, bounded, in parallel)` },
24059
24116
  { label: `deploy ${t.ref ?? "<current branch>"} to the ${t.slug} dev stage via the Hub backend (tenant-deploy)` },
24060
- { label: `gate ${t.host} to your IP at the Cloudflare edge via the Hub backend (tenant-control cf-gate-allow)` },
24117
+ { label: `gate ${t.host} to your IPv4 and your IPv6 /64 at the Cloudflare edge via the Hub backend (tenant-control cf-gate-allow)` },
24061
24118
  { label: "tear down when done", command: "mmi-cli stage --live --down --apply" }
24062
24119
  ];
24063
24120
  }
@@ -24069,11 +24126,14 @@ function stageLiveDownSteps(t) {
24069
24126
  }
24070
24127
  async function runStageLiveUp(deps, t) {
24071
24128
  if (!t.ref?.trim()) throw new Error("stage --live: cannot resolve the current branch to deploy");
24072
- const ip = (await deps.detectIp()).trim();
24129
+ const detected = await deps.detectIp();
24130
+ const ip = detected.ip.trim();
24073
24131
  if (!validStageLiveIp(ip)) throw new Error(`stage --live: detected public IP is not a literal IPv4/IPv6 address: "${ip.slice(0, 80)}"`);
24132
+ const ip6 = detected.ip6?.trim() || void 0;
24074
24133
  if (!t.host?.trim()) throw new Error("stage --live: cannot resolve the dev edge host (registry edgeDomains.dev)");
24075
24134
  await deps.deployDev({ repo: t.repo, ref: t.ref });
24076
- await deps.control({ repo: t.repo, action: "cf-gate-allow", host: t.host, ip });
24135
+ await deps.control({ repo: t.repo, action: "cf-gate-allow", host: t.host, ip, ip6 });
24136
+ const allowed = ip6 ? `${ip} and your IPv6 /64 prefix (from ${ip6})` : `${ip} (IPv4 only \u2014 no public IPv6 detected)`;
24077
24137
  return {
24078
24138
  command: "stage --live",
24079
24139
  mode: "up",
@@ -24081,10 +24141,11 @@ async function runStageLiveUp(deps, t) {
24081
24141
  repo: t.repo,
24082
24142
  ref: t.ref,
24083
24143
  ip,
24144
+ ip6,
24084
24145
  dispatched: ["tenant-deploy.yml", "tenant-control.yml"],
24085
24146
  // #2656: the gate now writes a skip+block pair and self-verifies from the runner (a non-allowed vantage),
24086
24147
  // so a gate that failed to close fails the run RED. The stage is private only once that gate run is green.
24087
- message: `dispatched the dev deploy of ${t.ref} and the Cloudflare edge gate for ${t.host} \u2192 ${ip}; watch the runs in ${STAGE_LIVE_HUB_REPO} Actions and treat the stage as private ONLY once the cf-gate-allow run is green (it self-verifies the host is blocked) \u2014 tear down with: mmi-cli stage --live --down --apply`
24148
+ message: `dispatched the dev deploy of ${t.ref} and the Cloudflare edge gate for ${t.host} \u2192 ${allowed}; watch the runs in ${STAGE_LIVE_HUB_REPO} Actions and treat the stage as private ONLY once the cf-gate-allow run is green (it self-verifies the host is blocked) \u2014 tear down with: mmi-cli stage --live --down --apply`
24088
24149
  };
24089
24150
  }
24090
24151
  async function runStageLiveDown(deps, t) {
@@ -24221,13 +24282,13 @@ function registerStageCommands(program3) {
24221
24282
  }
24222
24283
  const rcDeps = registryClientDeps(await loadConfig());
24223
24284
  const deps = {
24224
- detectIp: () => detectPublicIp(),
24285
+ detectIp: () => detectCallerIps(),
24225
24286
  deployDev: async ({ repo, ref }) => {
24226
24287
  const res = await tenantDeploy({ repo, stage: "dev", ref }, rcDeps);
24227
24288
  if (!res.ok) throw new Error(`dev deploy dispatch failed: ${res.body?.error ?? res.error ?? `HTTP ${res.status}`}`);
24228
24289
  },
24229
- control: async ({ repo, action, host, ip }) => {
24230
- const res = await tenantControl({ repo, stage: "dev", action, host, ip }, rcDeps);
24290
+ control: async ({ repo, action, host, ip, ip6 }) => {
24291
+ const res = await tenantControl({ repo, stage: "dev", action, host, ip, ip6 }, rcDeps);
24231
24292
  if (!res.ok) throw new Error(`runtime tenant control ${action} dispatch failed: ${res.body?.error ?? res.error ?? `HTTP ${res.status}`}`);
24232
24293
  }
24233
24294
  };
@@ -27862,13 +27923,16 @@ function registerSessionReport(program3) {
27862
27923
  // src/doctor-render.ts
27863
27924
  var RESTART_LINE = "\u21BB Restart Claude to finish.";
27864
27925
  var VERBOSE_INDENT = " ";
27926
+ function checkGlyph(check) {
27927
+ if (!check.ok) return "\u2717";
27928
+ return check.verified === false ? "?" : "\u2713";
27929
+ }
27865
27930
  function renderCheckLine(check) {
27866
- if (check.ok) {
27867
- return check.detail ? `\u2713 ${check.label} \u2014 ${check.detail}` : `\u2713 ${check.label}`;
27868
- }
27869
- const head = check.detail ? `\u2717 ${check.label} \u2014 ${check.detail}` : `\u2717 ${check.label}`;
27870
- return check.fix ? `${head}
27871
- ${VERBOSE_INDENT}\u2192 ${check.fix}` : head;
27931
+ const head = check.detail ? `${checkGlyph(check)} ${check.label} \u2014 ${check.detail}` : `${checkGlyph(check)} ${check.label}`;
27932
+ const lines = [head];
27933
+ if (check.fix) lines.push(`${VERBOSE_INDENT}\u2192 ${check.fix}`);
27934
+ if (check.command) lines.push(`${VERBOSE_INDENT}${check.command}`);
27935
+ return lines.join("\n");
27872
27936
  }
27873
27937
  function renderReport(checks, opts) {
27874
27938
  const lines = checks.map(renderCheckLine);
@@ -27877,8 +27941,14 @@ function renderReport(checks, opts) {
27877
27941
  }
27878
27942
  function renderTally(checks) {
27879
27943
  const total = checks.length;
27880
- const healthy = total - checks.filter((c) => !c.ok).length;
27881
- return healthy === total ? `\u2713 all ${total} checks healthy` : `\u2713 ${healthy} of ${total} checks healthy`;
27944
+ const failed = checks.filter((c) => !c.ok).length;
27945
+ const unverified = checks.filter((c) => c.ok && c.verified === false).length;
27946
+ const healthy = total - failed - unverified;
27947
+ if (failed === 0 && unverified === 0) return `\u2713 all ${total} checks healthy`;
27948
+ const parts = [`\u2713 ${healthy} verified healthy`];
27949
+ if (unverified > 0) parts.push(`? ${unverified} unverified`);
27950
+ if (failed > 0) parts.push(`\u2717 ${failed} failed`);
27951
+ return `${parts.join(" \xB7 ")} \u2014 ${total} checks`;
27882
27952
  }
27883
27953
  function doctorReportExitCode(checks) {
27884
27954
  return checks.some((c) => !c.ok && !c.reportOnly) ? 1 : 0;
@@ -28704,6 +28774,11 @@ function checkAwsIdentity(probe) {
28704
28774
  const arn = probe.callerArn?.trim();
28705
28775
  return {
28706
28776
  ok: !arn || !arn.endsWith(":root"),
28777
+ // #4074: the row's claim is "this caller is not root", and with no ARN resolved there is no caller to
28778
+ // make it about. `awsCallerArn` returns undefined for ANY failure — expired SSO, no `aws` binary, a
28779
+ // timeout — so an empty result is "I could not look", never "I looked and it is fine". The evidence
28780
+ // line below has said exactly that since #3485; the glyph now says it too.
28781
+ verified: Boolean(arn),
28707
28782
  id: "aws-identity",
28708
28783
  label: "aws identity",
28709
28784
  fix: "use a non-root IAM user/session profile (set AWS_PROFILE or run: aws sso login), then verify `aws sts get-caller-identity` is not :root",
@@ -28738,13 +28813,25 @@ function checkCodexHookTrust(probe, displayName = "Codex") {
28738
28813
  if (probe.trusted) {
28739
28814
  return { id: "codex-hook-trust", ok: true, label, detail: "trusted", verbose: evidence };
28740
28815
  }
28816
+ if (probe.requiredCount === 0) {
28817
+ return {
28818
+ id: "codex-hook-trust",
28819
+ ok: true,
28820
+ verified: false,
28821
+ warn: true,
28822
+ label,
28823
+ detail: "no approval rows to read \u2014 the hook bundle could not be checked",
28824
+ fix: `run \`mmi-cli plugin heal\`, restart ${displayName}, then review and trust MMI under \`/hooks\``,
28825
+ verbose: evidence
28826
+ };
28827
+ }
28741
28828
  return {
28742
28829
  id: "codex-hook-trust",
28743
28830
  ok: true,
28744
28831
  warn: true,
28745
28832
  label,
28746
- detail: probe.requiredCount > 0 ? `${probe.trustedCount}/${probe.requiredCount} approval rows present \u2014 current hashes unverified` : "hook bundle could not be verified",
28747
- fix: probe.requiredCount > 0 ? `${displayName} does not allow silent hook approval; run \`/hooks\`, review the MMI commands, and trust them` : `run \`mmi-cli plugin heal\`, restart ${displayName}, then review and trust MMI under \`/hooks\``,
28833
+ detail: `${probe.trustedCount}/${probe.requiredCount} approval rows present`,
28834
+ fix: `${displayName} does not allow silent hook approval; run \`/hooks\`, review the MMI commands, and trust them`,
28748
28835
  verbose: evidence
28749
28836
  };
28750
28837
  }
@@ -28757,8 +28844,9 @@ function checkCliVersion(input, releasedNote) {
28757
28844
  if (!report.releasedVersion) {
28758
28845
  return {
28759
28846
  id: "cli-version",
28760
- ok: false,
28761
- reportOnly: true,
28847
+ ok: true,
28848
+ verified: false,
28849
+ warn: true,
28762
28850
  label: "mmi-cli",
28763
28851
  detail: `${report.currentVersion} \u2014 freshness UNKNOWN, the published version could not be read`,
28764
28852
  fix: "check it directly: `mmi-cli --version` against `npm view @mutmutco/cli version`; update with `npm install -g @mutmutco/cli@<released>`",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.88.0",
3
+ "version": "3.90.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox and shared cross-IDE engine for every registry-declared MMI coding surface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",