@mutmutco/cli 3.89.0 → 3.91.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 +359 -46
  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) {
9107
+ async function runPluginCli(bin, args, log) {
9054
9108
  try {
9055
- await runHostBin("claude", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
9109
+ await runHostBin(bin, args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS, step: `${bin} ${args.join(" ")}` });
9056
9110
  return true;
9057
- } catch {
9058
- return false;
9059
- }
9060
- }
9061
- async function runCodexPlugin(args) {
9062
- try {
9063
- await runHostBin("codex", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
9064
- 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 {
@@ -9270,11 +9313,27 @@ function readKnownMarketplacesFile(path2) {
9270
9313
  return void 0;
9271
9314
  }
9272
9315
  }
9273
- function writeMarketplacePinsOnDisk(path2, pins, succeeded, failedVerb) {
9316
+ function claudeCodeIsRunning(env = process.env, listProcesses = defaultProcessList) {
9317
+ if (env.CLAUDE_CODE_SESSION_ID?.trim() || env.CLAUDECODE?.trim() || env.CLAUDE_PLUGIN_ROOT?.trim()) return true;
9318
+ let table;
9319
+ try {
9320
+ table = listProcesses();
9321
+ } catch {
9322
+ return true;
9323
+ }
9324
+ if (!table.trim()) return true;
9325
+ return table.split(/\r?\n/).some((line) => /(^|[/\\])claude(\.(exe|cmd|ps1))?\s/.test(`${line.trim()} `));
9326
+ }
9327
+ function defaultProcessList() {
9328
+ return isWin ? (0, import_node_child_process6.execFileSync)("powershell.exe", ["-NoProfile", "-Command", "Get-CimInstance Win32_Process | ForEach-Object { $_.CommandLine }"], { encoding: "utf8", windowsHide: true, maxBuffer: 32 * 1024 * 1024, timeout: 15e3 }) : (0, import_node_child_process6.execFileSync)("ps", ["-eo", "args="], { encoding: "utf8", windowsHide: true, timeout: 15e3 });
9329
+ }
9330
+ function writeMarketplacePinsOnDisk(path2, pins, succeeded, failedVerb, declineWhileHostLive) {
9274
9331
  if (pins.size === 0) return void 0;
9275
9332
  const after = readKnownMarketplacesFile(path2);
9276
9333
  const next = restoreMarketplacePins(after, pins);
9277
9334
  if (next === null) return void 0;
9335
+ const declined = declineWhileHostLive?.();
9336
+ if (declined) return declined;
9278
9337
  try {
9279
9338
  (0, import_node_fs15.writeFileSync)(path2, next, "utf8");
9280
9339
  } catch {
@@ -9287,16 +9346,27 @@ function writeMarketplacePinsOnDisk(path2, pins, succeeded, failedVerb) {
9287
9346
  });
9288
9347
  return failed.length ? `${failedVerb} did NOT take for ${failed.map(([n]) => n).join(", ")} \u2014 set it by hand` : succeeded(pins);
9289
9348
  }
9290
- function restoreMarketplacePinsOnDisk(path2, pins) {
9291
- return writeMarketplacePinsOnDisk(path2, pins, restoredPinsLine, "restore");
9292
- }
9293
- function applyOrgMarketplacePins(path2, names) {
9349
+ function restoreMarketplacePinsOnDisk(path2, pins, hostIsRunning = claudeCodeIsRunning) {
9294
9350
  return writeMarketplacePinsOnDisk(
9351
+ path2,
9352
+ pins,
9353
+ (restored) => hostIsRunning() ? `${restoredPinsLine(restored)}, but Claude Code is running and can rewrite this file from its own copy \u2014 restart it, then \`mmi-cli doctor --apply\` if the pins did not survive` : restoredPinsLine(restored),
9354
+ "restore"
9355
+ );
9356
+ }
9357
+ function applyOrgMarketplacePins(path2, names, hostIsRunning = claudeCodeIsRunning) {
9358
+ let landed = false;
9359
+ const detail = writeMarketplacePinsOnDisk(
9295
9360
  path2,
9296
9361
  new Map(names.map((name) => [name, ORG_MARKETPLACE_PINS])),
9297
- (pins) => `pinned ${[...pins.keys()].join(", ")} to ${ORG_MARKETPLACE_PINS.ref} with auto-update on`,
9298
- "pin"
9362
+ (pins) => {
9363
+ landed = true;
9364
+ return `pinned ${[...pins.keys()].join(", ")} to ${ORG_MARKETPLACE_PINS.ref} with auto-update on`;
9365
+ },
9366
+ "pin",
9367
+ () => hostIsRunning() ? "not pinned \u2014 Claude Code is running and rewrites this registration from its own copy; quit it, then run `mmi-cli doctor --apply`" : void 0
9299
9368
  );
9369
+ return detail === void 0 ? void 0 : { detail, wrote: landed };
9300
9370
  }
9301
9371
  function writeMarketplacePinPending(path2, names, now = Date.now()) {
9302
9372
  try {
@@ -22906,12 +22976,42 @@ var canonicalPriorityColors = { Urgent: "RED", High: "ORANGE", Medium: "YELLOW",
22906
22976
  function miscoloredOptions(options, canon) {
22907
22977
  return (options ?? []).filter((o) => canon[o.name] != null && o.color != null && o.color !== canon[o.name]).map((o) => `${o.name}=${o.color} (want ${canon[o.name]})`);
22908
22978
  }
22979
+ function missingBoardViews(views, required) {
22980
+ return required.filter((req) => !views.some((v) => v.name === req.name && v.layout === req.layout));
22981
+ }
22982
+ function boardGroupingDrift(board, wantColumn, wantSwimlane) {
22983
+ if (!board) return ["no Board view found"];
22984
+ const problems = [];
22985
+ if (!board.verticalGroupByFields.includes(wantColumn)) {
22986
+ problems.push(`columns: ${board.verticalGroupByFields.join(", ") || "none"} (want ${wantColumn})`);
22987
+ }
22988
+ if (!board.groupByFields.includes(wantSwimlane)) {
22989
+ problems.push(`swimlanes: ${board.groupByFields.join(", ") || "none"} (want ${wantSwimlane})`);
22990
+ }
22991
+ return problems;
22992
+ }
22993
+ function boardCardFieldDrift(visibleFields, canonical) {
22994
+ const have = new Set(visibleFields);
22995
+ const want = new Set(canonical);
22996
+ return {
22997
+ missing: canonical.filter((f) => !have.has(f)),
22998
+ extra: visibleFields.filter((f) => !want.has(f))
22999
+ };
23000
+ }
22909
23001
  var requiredProjectWorkflows = [
22910
23002
  "Auto-add sub-issues to project",
22911
23003
  "Auto-archive items",
22912
23004
  "Item added to project",
22913
23005
  "Item closed"
22914
23006
  ];
23007
+ var requiredBoardViews = [
23008
+ { name: "List", layout: "TABLE_LAYOUT" },
23009
+ { name: "Board", layout: "BOARD_LAYOUT" },
23010
+ { name: "Roadmap", layout: "ROADMAP_LAYOUT" }
23011
+ ];
23012
+ var requiredBoardColumnField = "Status";
23013
+ var requiredBoardSwimlaneField = "Repository";
23014
+ var requiredBoardCardFields = ["Title", "Assignees", "Status", "Labels", "Linked pull requests", "Parent issue", "Sub-issues progress", "Priority"];
22915
23015
  var requiredOrgRulesetTypes = ["pull_request", "non_fast_forward", "deletion"];
22916
23016
  var requiredHubStatusChecks = ["cli", "infra", "docs"];
22917
23017
  var requiredProductStatusChecks = ["gate"];
@@ -23260,6 +23360,48 @@ async function verifyBootstrap(repo, repoClass, deps, releaseTrack) {
23260
23360
  label: `Project workflow enabled: ${workflowName}`
23261
23361
  });
23262
23362
  }
23363
+ const viewsQuery = `query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { views(first: 10) { nodes { name layout groupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } } verticalGroupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } } visibleFields(first: 20) { nodes { ... on ProjectV2FieldCommon { name } } } } } } } }`;
23364
+ const boardViews = await (async () => {
23365
+ try {
23366
+ const data = await deps.client.graphql(viewsQuery, {
23367
+ login: config.projectOwner,
23368
+ number: config.projectNumber
23369
+ });
23370
+ const nodes = data.organization?.projectV2?.views?.nodes ?? [];
23371
+ const names = (conn) => (conn?.nodes ?? []).filter((f) => Boolean(f?.name)).map((f) => f.name);
23372
+ return nodes.filter((v) => Boolean(v)).map((v) => ({
23373
+ name: v.name,
23374
+ layout: v.layout,
23375
+ groupByFields: names(v.groupByFields),
23376
+ verticalGroupByFields: names(v.verticalGroupByFields),
23377
+ visibleFields: names(v.visibleFields)
23378
+ }));
23379
+ } catch {
23380
+ return [];
23381
+ }
23382
+ })();
23383
+ const missingViews = missingBoardViews(boardViews, requiredBoardViews);
23384
+ checks.push({
23385
+ ok: missingViews.length === 0,
23386
+ label: "Project view triple present: List/Board/Roadmap (#4093)",
23387
+ detail: missingViews.length ? `missing: ${missingViews.map((v) => `${v.name} (${v.layout})`).join(", ")} \u2014 createProjectV2View/updateProjectV2View is API-writable (name + layout)` : void 0
23388
+ });
23389
+ const boardView = boardViews.find((v) => v.layout === "BOARD_LAYOUT");
23390
+ const groupingDrift = boardGroupingDrift(boardView, requiredBoardColumnField, requiredBoardSwimlaneField);
23391
+ checks.push({
23392
+ ok: groupingDrift.length === 0,
23393
+ label: `Board view grouped by ${requiredBoardColumnField} columns / ${requiredBoardSwimlaneField} swimlanes (#4093)`,
23394
+ detail: groupingDrift.length ? `${groupingDrift.join("; ")} \u2014 GitHub exposes no create/update mutation for view grouping (ProjectV2ViewConfigurationInput carries only visibleFieldIds); fix in the UI: Board view \u2192 \u2699 (top-right) \u2192 Group by \u2192 ${requiredBoardColumnField}, Swimlanes \u2192 ${requiredBoardSwimlaneField} \u2192 Save view` : void 0
23395
+ });
23396
+ const cardDrift = boardCardFieldDrift(boardView?.visibleFields ?? [], requiredBoardCardFields);
23397
+ checks.push({
23398
+ ok: cardDrift.missing.length === 0 && cardDrift.extra.length === 0,
23399
+ label: "Board view card fields match the org standard (#4093)",
23400
+ detail: cardDrift.missing.length || cardDrift.extra.length ? `${[
23401
+ cardDrift.missing.length ? `missing: ${cardDrift.missing.join(", ")}` : null,
23402
+ cardDrift.extra.length ? `extra: ${cardDrift.extra.join(", ")}` : null
23403
+ ].filter(Boolean).join("; ")} \u2014 API-writable via updateProjectV2View(configuration:{visibleFieldIds:[...]}); fix in the UI (Board view \u2192 Fields) or that mutation` : void 0
23404
+ });
23263
23405
  }
23264
23406
  const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && projectRegistryIncludesRepo(json.projects, repo));
23265
23407
  if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "project registry includes repo" });
@@ -24037,27 +24179,41 @@ function decideStage(inputs) {
24037
24179
  var import_node_net2 = require("node:net");
24038
24180
  var STAGE_LIVE_HUB_REPO = "mutmutco/MMI-Hub";
24039
24181
  var IP_ECHO_URL = "https://api.ipify.org";
24182
+ var IP6_ECHO_URL = "https://api6.ipify.org";
24040
24183
  var IP_DETECT_TIMEOUT_MS = 1e4;
24041
24184
  function validStageLiveIp(ip) {
24042
24185
  return (0, import_node_net2.isIP)(ip.trim()) !== 0;
24043
24186
  }
24044
24187
  async function detectPublicIp(fetchImpl = fetch) {
24188
+ return detectPublicIpFrom(IP_ECHO_URL, fetchImpl);
24189
+ }
24190
+ async function detectCallerIps(fetchImpl = fetch) {
24191
+ const [v4, v6] = await Promise.allSettled([
24192
+ detectPublicIp(fetchImpl),
24193
+ detectPublicIpFrom(IP6_ECHO_URL, fetchImpl)
24194
+ ]);
24195
+ if (v4.status === "rejected") throw v4.reason;
24196
+ const ip = v4.value;
24197
+ const candidate = v6.status === "fulfilled" ? v6.value : void 0;
24198
+ return { ip, ip6: candidate && (0, import_node_net2.isIP)(candidate) === 6 ? candidate : void 0 };
24199
+ }
24200
+ async function detectPublicIpFrom(url, fetchImpl) {
24045
24201
  let res;
24046
24202
  try {
24047
- res = await fetchImpl(IP_ECHO_URL, { signal: AbortSignal.timeout(IP_DETECT_TIMEOUT_MS) });
24203
+ res = await fetchImpl(url, { signal: AbortSignal.timeout(IP_DETECT_TIMEOUT_MS) });
24048
24204
  } catch (e) {
24049
- throw new Error(`public IP detection failed (${IP_ECHO_URL}): ${e.message}`);
24205
+ throw new Error(`public IP detection failed (${url}): ${e.message}`);
24050
24206
  }
24051
- if (!res.ok) throw new Error(`public IP detection failed: HTTP ${res.status} from ${IP_ECHO_URL}`);
24207
+ if (!res.ok) throw new Error(`public IP detection failed: HTTP ${res.status} from ${url}`);
24052
24208
  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)}"`);
24209
+ if (!validStageLiveIp(ip)) throw new Error(`public IP detection returned a non-IP body from ${url}: "${ip.slice(0, 80)}"`);
24054
24210
  return ip;
24055
24211
  }
24056
24212
  function stageLiveUpSteps(t) {
24057
24213
  return [
24058
- { label: `detect your public IP (${IP_ECHO_URL}, bounded)` },
24214
+ { label: `detect your public IPv4 and IPv6 (${IP_ECHO_URL} + ${IP6_ECHO_URL}, bounded, in parallel)` },
24059
24215
  { 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)` },
24216
+ { 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
24217
  { label: "tear down when done", command: "mmi-cli stage --live --down --apply" }
24062
24218
  ];
24063
24219
  }
@@ -24069,11 +24225,14 @@ function stageLiveDownSteps(t) {
24069
24225
  }
24070
24226
  async function runStageLiveUp(deps, t) {
24071
24227
  if (!t.ref?.trim()) throw new Error("stage --live: cannot resolve the current branch to deploy");
24072
- const ip = (await deps.detectIp()).trim();
24228
+ const detected = await deps.detectIp();
24229
+ const ip = detected.ip.trim();
24073
24230
  if (!validStageLiveIp(ip)) throw new Error(`stage --live: detected public IP is not a literal IPv4/IPv6 address: "${ip.slice(0, 80)}"`);
24231
+ const ip6 = detected.ip6?.trim() || void 0;
24074
24232
  if (!t.host?.trim()) throw new Error("stage --live: cannot resolve the dev edge host (registry edgeDomains.dev)");
24075
24233
  await deps.deployDev({ repo: t.repo, ref: t.ref });
24076
- await deps.control({ repo: t.repo, action: "cf-gate-allow", host: t.host, ip });
24234
+ await deps.control({ repo: t.repo, action: "cf-gate-allow", host: t.host, ip, ip6 });
24235
+ const allowed = ip6 ? `${ip} and your IPv6 /64 prefix (from ${ip6})` : `${ip} (IPv4 only \u2014 no public IPv6 detected)`;
24077
24236
  return {
24078
24237
  command: "stage --live",
24079
24238
  mode: "up",
@@ -24081,10 +24240,11 @@ async function runStageLiveUp(deps, t) {
24081
24240
  repo: t.repo,
24082
24241
  ref: t.ref,
24083
24242
  ip,
24243
+ ip6,
24084
24244
  dispatched: ["tenant-deploy.yml", "tenant-control.yml"],
24085
24245
  // #2656: the gate now writes a skip+block pair and self-verifies from the runner (a non-allowed vantage),
24086
24246
  // 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`
24247
+ 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
24248
  };
24089
24249
  }
24090
24250
  async function runStageLiveDown(deps, t) {
@@ -24221,13 +24381,13 @@ function registerStageCommands(program3) {
24221
24381
  }
24222
24382
  const rcDeps = registryClientDeps(await loadConfig());
24223
24383
  const deps = {
24224
- detectIp: () => detectPublicIp(),
24384
+ detectIp: () => detectCallerIps(),
24225
24385
  deployDev: async ({ repo, ref }) => {
24226
24386
  const res = await tenantDeploy({ repo, stage: "dev", ref }, rcDeps);
24227
24387
  if (!res.ok) throw new Error(`dev deploy dispatch failed: ${res.body?.error ?? res.error ?? `HTTP ${res.status}`}`);
24228
24388
  },
24229
- control: async ({ repo, action, host, ip }) => {
24230
- const res = await tenantControl({ repo, stage: "dev", action, host, ip }, rcDeps);
24389
+ control: async ({ repo, action, host, ip, ip6 }) => {
24390
+ const res = await tenantControl({ repo, stage: "dev", action, host, ip, ip6 }, rcDeps);
24231
24391
  if (!res.ok) throw new Error(`runtime tenant control ${action} dispatch failed: ${res.body?.error ?? res.error ?? `HTTP ${res.status}`}`);
24232
24392
  }
24233
24393
  };
@@ -25419,6 +25579,88 @@ async function fetchRestCorePool(gh = defaultGhApi) {
25419
25579
  }
25420
25580
  }
25421
25581
 
25582
+ // src/pr-create-docs-check.ts
25583
+ var GIT_TIMEOUT_MS2 = 15e3;
25584
+ function createPrCreateDocsIndexDeps() {
25585
+ return {
25586
+ worktreeRoot: async () => {
25587
+ try {
25588
+ const { stdout } = await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS2 });
25589
+ return stdout.trim() || void 0;
25590
+ } catch {
25591
+ return void 0;
25592
+ }
25593
+ },
25594
+ originRepo: async (root) => {
25595
+ try {
25596
+ const { stdout } = await execFileP2("git", ["-C", root, "remote", "get-url", "origin"], { timeout: GIT_TIMEOUT_MS2 });
25597
+ return repoFromRemoteUrl(stdout.trim());
25598
+ } catch {
25599
+ return void 0;
25600
+ }
25601
+ },
25602
+ refResolves: async (root, ref) => {
25603
+ try {
25604
+ await execFileP2("git", ["-C", root, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { timeout: GIT_TIMEOUT_MS2 });
25605
+ return true;
25606
+ } catch {
25607
+ return false;
25608
+ }
25609
+ },
25610
+ listDocsAtRef: async (root, ref) => {
25611
+ try {
25612
+ const { stdout } = await execFileP2("git", ["-C", root, "ls-tree", "-r", "--name-only", ref, "--", "docs"], { timeout: GIT_TIMEOUT_MS2 });
25613
+ return stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
25614
+ } catch {
25615
+ return [];
25616
+ }
25617
+ },
25618
+ readAtRef: async (root, ref, path2) => {
25619
+ try {
25620
+ const { stdout } = await execFileP2("git", ["-C", root, "show", `${ref}:${path2}`], { timeout: GIT_TIMEOUT_MS2 });
25621
+ return stdout;
25622
+ } catch {
25623
+ return void 0;
25624
+ }
25625
+ }
25626
+ };
25627
+ }
25628
+ async function checkDocsIndexAtHead(opts, deps) {
25629
+ const root = await deps.worktreeRoot();
25630
+ if (!root) return void 0;
25631
+ if (opts.repo) {
25632
+ const origin = await deps.originRepo(root);
25633
+ if (!origin || origin.toLowerCase() !== opts.repo.toLowerCase()) return void 0;
25634
+ }
25635
+ const ref = opts.head || "HEAD";
25636
+ if (!await deps.refResolves(root, ref)) return void 0;
25637
+ const paths = await deps.listDocsAtRef(root, ref);
25638
+ if (!paths.includes(DOCS_INDEX_PATH)) return void 0;
25639
+ const relDocs = paths.filter((p) => p.startsWith("docs/")).map((p) => p.slice("docs/".length)).filter(isRoutableDocsPath);
25640
+ const [indexContent, docContents] = await Promise.all([
25641
+ deps.readAtRef(root, ref, DOCS_INDEX_PATH),
25642
+ Promise.all(relDocs.map((rel) => deps.readAtRef(root, ref, `docs/${rel}`)))
25643
+ ]);
25644
+ const contentByPath = new Map(relDocs.map((rel, i) => [rel, docContents[i] ?? ""]));
25645
+ const atRefDeps = {
25646
+ listDocs: () => relDocs,
25647
+ readDoc: (rel) => contentByPath.get(rel) ?? "",
25648
+ readIndex: () => indexContent ?? null,
25649
+ writeIndex: () => {
25650
+ throw new Error("checkDocsIndexAtHead: read-only \u2014 a commit ref is never written to");
25651
+ }
25652
+ };
25653
+ const result = docsIndex(atRefDeps, { check: true });
25654
+ if (!result.drift) {
25655
+ return { ok: true, detail: `${DOCS_INDEX_PATH} matches the docs/ tree at ${ref === "HEAD" ? "HEAD" : ref}` };
25656
+ }
25657
+ return {
25658
+ ok: false,
25659
+ detail: `${DOCS_INDEX_PATH} is stale at the commit this PR would open from \u2014 it no longer matches the docs/ tree there`,
25660
+ fix: "run `mmi-cli docs index --write`, commit docs/index.md, and push again before retrying `pr create`"
25661
+ };
25662
+ }
25663
+
25422
25664
  // src/worktree-lifecycle-commands.ts
25423
25665
  var import_node_fs31 = require("node:fs");
25424
25666
  var import_promises9 = require("node:fs/promises");
@@ -28911,6 +29153,32 @@ function checkSessionPayload(probe) {
28911
29153
  verbose: evidence
28912
29154
  };
28913
29155
  }
29156
+ function checkDocsIndex(probe) {
29157
+ if (!probe) return null;
29158
+ const evidence = [
29159
+ // The WORKING TREE, said out loud. This row reads disk and never asks git what is staged or committed,
29160
+ // so it must not claim to have measured the commit: a developer who ran `--write` and has not committed
29161
+ // has a current tree and a stale HEAD, and a row that said "committed: current" there would be the one
29162
+ // failure this check exists to prevent, one layer down. Closing that gap belongs to the surface that
29163
+ // knows about commits (`pr create`, #4092), not to a local hygiene reading.
29164
+ `${DOCS_INDEX_PATH}: read from the working tree, not from HEAD`,
29165
+ `records indexed: ${probe.docCount}`,
29166
+ // Named because it is the one thing a Windows operator reproducing a CI failure needs to trust the
29167
+ // verdict: the comparison normalizes CRLF, so this row and the Linux gate agree on the same content (#3411).
29168
+ "comparison: eol-insensitive, against the index the generator renders now"
29169
+ ];
29170
+ if (!probe.drift) {
29171
+ return { ok: true, id: "docs-index", label: "docs index", detail: "current", verbose: evidence };
29172
+ }
29173
+ return {
29174
+ ok: false,
29175
+ id: "docs-index",
29176
+ label: "docs index",
29177
+ detail: "stale \u2014 docs/index.md no longer matches the docs/ tree",
29178
+ fix: "run `mmi-cli docs index --write` and commit docs/index.md",
29179
+ verbose: evidence
29180
+ };
29181
+ }
28914
29182
  function planGitignore(current) {
28915
29183
  const { content, changed } = upsertManagedGitignoreBlock(current);
28916
29184
  return changed ? { ok: false, content } : { ok: true };
@@ -29058,8 +29326,8 @@ async function runDoctorClean(opts, io, deps) {
29058
29326
  if (applyEnv) {
29059
29327
  const healed = deps.healMarketplacePins();
29060
29328
  if (healed) {
29061
- healIntent(`marketplace pins \u2014 ${healed}`);
29062
- restartPending = true;
29329
+ healIntent(`marketplace pins \u2014 ${healed.detail}`);
29330
+ if (healed.wrote) restartPending = true;
29063
29331
  }
29064
29332
  }
29065
29333
  for (const row of deps.marketplaceRows()) emitNow(row);
@@ -29078,6 +29346,28 @@ async function runDoctorClean(opts, io, deps) {
29078
29346
  });
29079
29347
  }
29080
29348
  }
29349
+ async function runDocsIndexRow() {
29350
+ let probe;
29351
+ try {
29352
+ probe = deps.docsIndexState(await deps.repoRoot());
29353
+ } catch (e) {
29354
+ const message = e instanceof Error ? e.message : String(e);
29355
+ emitNow({
29356
+ ok: false,
29357
+ id: "docs-index",
29358
+ label: "docs index",
29359
+ // The measurement goes in `detail` and the one next action in `fix`, per docs/doctor-contract.md
29360
+ // § check model — never the error text stapled to the front of the advice, and never "re-run the
29361
+ // read that just failed" as the action.
29362
+ detail: `could not be read \u2014 ${message}`,
29363
+ fix: "repair the docs/ tree this repo cannot walk, then re-run doctor",
29364
+ verbose: [`probe threw: ${message}`]
29365
+ });
29366
+ return;
29367
+ }
29368
+ const docs2 = checkDocsIndex(probe);
29369
+ if (docs2) emitNow(docs2);
29370
+ }
29081
29371
  async function runHousekeeperRows() {
29082
29372
  const repoRoot2 = await deps.repoRoot();
29083
29373
  try {
@@ -29148,6 +29438,9 @@ async function runDoctorClean(opts, io, deps) {
29148
29438
  { id: "plugin-cache", when: true, run: runPluginCacheRow },
29149
29439
  { id: "sessionstart-payload", when: true, run: runSessionPayloadRow },
29150
29440
  { id: "marketplace", when: true, run: runMarketplaceRows },
29441
+ // A `docs/` tree walk plus one batched `git check-ignore` — cheap next to the two rows below it, but a
29442
+ // disk walk and a subprocess all the same, so full lane only (#4091).
29443
+ { id: "docs-index", when: isOrgRepo && lane.full, run: runDocsIndexRow },
29151
29444
  // The two most expensive things in this file — a real `git fetch` plus train-branch fast-forward,
29152
29445
  // and a `gh`-backed gc sweep with a 20s timeout — so org repos on the full lane only (#3485).
29153
29446
  { id: "train-branches", when: isOrgRepo && lane.full, run: runTrainSyncRow },
@@ -29589,13 +29882,29 @@ function mmiDoctorDeps(opts = {}) {
29589
29882
  const home = (0, import_node_os14.homedir)();
29590
29883
  const names = [MMI_MARKETPLACE_NAME];
29591
29884
  const result = applyOrgMarketplacePins((0, import_node_path35.join)(home, ...KNOWN_MARKETPLACES_RELATIVE), names);
29592
- if (result?.startsWith("pinned ")) {
29885
+ if (result?.wrote) {
29593
29886
  writeMarketplacePinPending((0, import_node_path35.join)(home, ".claude", "plugins", ".mmi-marketplace-pin-pending.json"), names);
29594
29887
  }
29595
29888
  return result;
29596
29889
  } catch {
29597
29890
  return void 0;
29598
29891
  }
29892
+ },
29893
+ // #4091: the same `docs index --check` comparison the required CI gate runs, in-process. Rooted at the
29894
+ // repo root doctor already resolved — never `process.cwd()`, which would have `mmi-cli doctor` run from
29895
+ // `cli/` measure a `cli/docs/` tree that does not exist.
29896
+ //
29897
+ // `existsSync` on `docs/index.md` is the adoption gate, and it sits UNDER the table's org-repo gate
29898
+ // rather than replacing it: a missing index is drift by construction, so without this a repo that never
29899
+ // adopted the generated routing index (MMG-Unlive: `docs/Archive/**` only, no index, no gate.yml) would
29900
+ // get a permanent ✗ demanding an artifact it never asked for.
29901
+ docsIndexState: (root) => {
29902
+ if (!(0, import_node_fs36.existsSync)((0, import_node_path35.join)(root, DOCS_INDEX_PATH))) return void 0;
29903
+ const real = createDocsIndexDeps(root);
29904
+ let docs2;
29905
+ const listDocs = () => docs2 ??= real.listDocs();
29906
+ const drift = docsIndex({ ...real, listDocs }, { check: true }).drift;
29907
+ return { drift, docCount: listDocs().length };
29599
29908
  }
29600
29909
  };
29601
29910
  }
@@ -31440,6 +31749,10 @@ withExamples(pr.command("create").description("create a PR and print {number,url
31440
31749
  return fail(`pr create: ${e.message}`, e instanceof TextArgError ? { code: e.code, offending_flag: e.offendingFlag } : void 0);
31441
31750
  }
31442
31751
  body = normalizeClosingDirectives(body);
31752
+ const docsCheck = await checkDocsIndexAtHead({ repo: o.repo, head: o.head }, createPrCreateDocsIndexDeps());
31753
+ if (docsCheck && !docsCheck.ok) {
31754
+ return fail(`pr create: ${docsCheck.detail} \u2014 ${docsCheck.fix}`);
31755
+ }
31443
31756
  const created = await ghCreate(buildPrArgs({ title, body, base: o.base, head: o.head, repo: o.repo, draft: o.draft }));
31444
31757
  console.log(JSON.stringify(created));
31445
31758
  }), [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.89.0",
3
+ "version": "3.91.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",