@mutmutco/cli 3.17.0 → 3.18.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 +212 -83
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -12252,6 +12252,7 @@ async function executeMergeToMain(deps, sourceRef, mergeLabel, tolerated, predic
12252
12252
  await deps.run("git", ["checkout", "main"]);
12253
12253
  await ffOnlyPull(deps, "main");
12254
12254
  preFold.mainSha = clean2(await deps.run("git", ["rev-parse", "main"]));
12255
+ preFold.isTrueMerge = !await deps.run("git", ["merge-base", "--is-ancestor", preFold.mainSha, sourceRef]).then(() => true).catch(() => false);
12255
12256
  if (predicted.length === 0) {
12256
12257
  await deps.run("git", ["merge", sourceRef, "--no-edit"]);
12257
12258
  } else {
@@ -12411,12 +12412,69 @@ async function verifyPublishDryRun(deps, ctx, meta, deployModel) {
12411
12412
  );
12412
12413
  }
12413
12414
  }
12415
+ var TRUE_MERGE_GATE_BRANCH_PREFIX = "train/check/";
12416
+ function trueMergeGateBranchName(tag, sha) {
12417
+ return `${TRUE_MERGE_GATE_BRANCH_PREFIX}${tag.replace(/^v/, "")}-${sha.slice(0, 8)}`;
12418
+ }
12419
+ async function gateTrueMergeCommit(deps, ctx, tag, releaseSha, requiredChecks) {
12420
+ if (requiredChecks.length === 0) {
12421
+ return "true merge commit: target branch requires no status checks \u2014 throwaway-PR gate skipped";
12422
+ }
12423
+ const branch = trueMergeGateBranchName(tag, releaseSha);
12424
+ await deps.run("git", ["push", "origin", "--delete", branch]).catch(() => void 0);
12425
+ await deps.run("git", ["push", "origin", `${releaseSha}:refs/heads/${branch}`]);
12426
+ let prNumber;
12427
+ try {
12428
+ const url = clean2(await deps.run("gh", [
12429
+ "pr",
12430
+ "create",
12431
+ "--repo",
12432
+ ctx.repo,
12433
+ "--base",
12434
+ "main",
12435
+ "--head",
12436
+ branch,
12437
+ "--draft",
12438
+ "--title",
12439
+ `chore(train): gate ${tag} merge commit (auto, #2790)`,
12440
+ "--body",
12441
+ `Throwaway PR opened by the release train to earn required status checks (${requiredChecks.join(", ")}) on a true (non-fast-forward) merge commit before its tag is pushed (#2790). Auto-closed once the checks resolve \u2014 never merge this PR; the train pushes \`main\` directly once the gate is satisfied.`
12442
+ ]));
12443
+ prNumber = parsePrNumber(url);
12444
+ const checksNote = await waitForRequiredTrainChecks(deps, ctx, releaseSha, requiredChecks);
12445
+ return `true merge commit: gated via throwaway PR ${url || "(url unavailable)"} \u2014 ${checksNote}`;
12446
+ } finally {
12447
+ if (prNumber !== void 0) {
12448
+ await deps.run("gh", ["pr", "close", String(prNumber), "--repo", ctx.repo, "--delete-branch"]).catch((e) => {
12449
+ deps.warn?.(`throwaway train-gate PR #${prNumber} close/branch-delete failed (${e instanceof Error ? e.message : String(e)}) \u2014 clean up '${branch}' manually`);
12450
+ });
12451
+ } else {
12452
+ await deps.run("git", ["push", "origin", "--delete", branch]).catch((e) => {
12453
+ deps.warn?.(`throwaway train-gate branch '${branch}' delete failed (${e instanceof Error ? e.message : String(e)}) \u2014 clean up manually`);
12454
+ });
12455
+ }
12456
+ }
12457
+ }
12414
12458
  async function completeMainRelease(deps, ctx, meta, deployModel, watch, options, tag, releaseSha, startBranch, preFold, tagProbe) {
12415
12459
  try {
12416
12460
  await verifyPublishDryRun(deps, ctx, meta, deployModel);
12417
12461
  } catch (e) {
12418
12462
  throw await recoverFailedFold(deps, e, startBranch, preFold.mainSha);
12419
12463
  }
12464
+ let requiredChecks;
12465
+ try {
12466
+ requiredChecks = await discoverRequiredCheckContexts(deps, ctx, "main");
12467
+ } catch (e) {
12468
+ throw await recoverFailedFold(deps, e, startBranch, preFold.mainSha);
12469
+ }
12470
+ let trueMergeGateNote;
12471
+ if (preFold.isTrueMerge) {
12472
+ try {
12473
+ trueMergeGateNote = await gateTrueMergeCommit(deps, ctx, tag, releaseSha, requiredChecks);
12474
+ } catch (e) {
12475
+ throw await recoverFailedFold(deps, e, startBranch, preFold.mainSha);
12476
+ }
12477
+ }
12420
12478
  let tagPush;
12421
12479
  try {
12422
12480
  tagPush = await ensureTagPushed(deps, tag, releaseSha, tagProbe, ctx.repo);
@@ -12424,13 +12482,13 @@ async function completeMainRelease(deps, ctx, meta, deployModel, watch, options,
12424
12482
  throw await recoverFailedFold(deps, e, startBranch, preFold.mainSha);
12425
12483
  }
12426
12484
  const tagPushSince = (deps.now ?? Date.now)();
12427
- const requiredChecks = await discoverRequiredCheckContexts(deps, ctx, "main");
12428
12485
  let checks;
12429
12486
  try {
12430
12487
  checks = await waitForRequiredTrainChecks(deps, ctx, releaseSha, requiredChecks, tagPush.pushed ? tagPushSince : void 0);
12431
12488
  } catch (e) {
12432
12489
  throw partialTrainRecoveryError(e, { repo: ctx.repo, tag, stage: "main" });
12433
12490
  }
12491
+ if (trueMergeGateNote) checks = `${trueMergeGateNote}; ${checks}`;
12434
12492
  await runGitPush(deps, ["push", "origin", "main"]);
12435
12493
  const releaseUrl = clean2(await deps.run("gh", ["release", "create", tag, "--target", "main", "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
12436
12494
  await verifyPublishedRelease(deps, ctx.repo, tag, "main", releaseSha);
@@ -13688,6 +13746,7 @@ async function ensurePortRangeAtomic(repo, path2, allocate, opts = {}) {
13688
13746
  // src/access.ts
13689
13747
  var OWNER2 = "mutmutco";
13690
13748
  var LOCKED_APP = "mmi-github-app";
13749
+ var PLUGIN_READ_REPO = "mutmutco/MMI-Hub";
13691
13750
  var OVERGRANT_ROLES = /* @__PURE__ */ new Set(["admin", "maintain"]);
13692
13751
  var REQUIRED_DATA_ACCESS = {};
13693
13752
  function lockedBranches(repoClass, releaseTrack) {
@@ -13842,9 +13901,32 @@ async function auditOrgBasePermission(deps) {
13842
13901
  }
13843
13902
  return [];
13844
13903
  }
13904
+ async function auditPluginReadAccess(owners, projectAdmins, deps) {
13905
+ const findings = [];
13906
+ for (const login of projectAdmins) {
13907
+ if (owners.has(login)) continue;
13908
+ const perm = (await restJson2(deps, `repos/${PLUGIN_READ_REPO}/collaborators/${login}/permission`, {})).permission;
13909
+ if (!perm || perm === "none") {
13910
+ findings.push({
13911
+ repo: PLUGIN_READ_REPO,
13912
+ kind: "plugin-read-missing",
13913
+ severity: "medium",
13914
+ actor: login,
13915
+ detail: `project-admin @${login} lacks read on ${PLUGIN_READ_REPO}; the MMI plugin marketplace add will 404 for them until granted`,
13916
+ remediation: `gh api -X PUT repos/${PLUGIN_READ_REPO}/collaborators/${login} -f permission=pull`
13917
+ });
13918
+ }
13919
+ }
13920
+ return findings;
13921
+ }
13845
13922
  async function auditOrgAccess(targets, deps, matrix = {}, dataAccess) {
13846
13923
  const owners = new Set(await resolveOwners(deps));
13847
13924
  const orgFindings = await auditOrgBasePermission(deps);
13925
+ const allProjectAdmins = /* @__PURE__ */ new Set();
13926
+ for (const target of targets) {
13927
+ for (const login of entriesValueByCanonicalRepo(matrix, target.repo) ?? []) allProjectAdmins.add(login);
13928
+ }
13929
+ orgFindings.push(...await auditPluginReadAccess(owners, allProjectAdmins, deps));
13848
13930
  const repos = [];
13849
13931
  for (const target of targets) {
13850
13932
  repos.push(await auditRepoAccess(target.repo, target.class, owners, deps, new Set(entriesValueByCanonicalRepo(matrix, target.repo) ?? []), dataAccess, target.releaseTrack));
@@ -15929,6 +16011,62 @@ function requireProjectTarget(commandName, explicitTarget, currentRepo) {
15929
16011
  return target;
15930
16012
  }
15931
16013
 
16014
+ // src/deploy-guards.ts
16015
+ var GIT_SHOW_TIMEOUT_MS = 1e4;
16016
+ async function readStageBranchCompose(branch) {
16017
+ for (const ref of [`origin/${branch}`, branch]) {
16018
+ try {
16019
+ return (await execFileP2("git", ["show", `${ref}:docker-compose.yml`], { timeout: GIT_SHOW_TIMEOUT_MS })).stdout;
16020
+ } catch {
16021
+ }
16022
+ }
16023
+ return null;
16024
+ }
16025
+ function composeMarksRuntimeEnvTrue(composeText) {
16026
+ return composeText.split(/\r?\n/).some((raw) => {
16027
+ const line = raw.replace(/(^|\s)#.*$/, "$1");
16028
+ if (/(^|[\s"'])mmi\.runtime-env\s*:\s*["']?true["']?\s*$/.test(line)) return true;
16029
+ if (/mmi\.runtime-env\s*=\s*["']?true["']?\s*$/.test(line)) return true;
16030
+ return false;
16031
+ });
16032
+ }
16033
+ function promotionSourceBranch(stage2, releaseTrack) {
16034
+ if (stage2 === "main") return releaseTrack === "direct" ? "development" : "rc";
16035
+ return "development";
16036
+ }
16037
+ function evaluatePromotionFilelessGuard(input) {
16038
+ const { branch, stage: stage2 } = input;
16039
+ if (input.force) {
16040
+ return { ok: true, warn: `--skip-compose-guard: skipping the fileless-compose \u2194 DEPLOY#${stage2}.noEnvFile guard (#2813)` };
16041
+ }
16042
+ if (!input.sameRepo) {
16043
+ return { ok: true, warn: `fileless-compose guard skipped \u2014 run from the target repo checkout to verify the ${branch} compose against DEPLOY#${stage2}.noEnvFile (#2813).` };
16044
+ }
16045
+ if (input.composeText === null) {
16046
+ return { ok: true, warn: `could not read docker-compose.yml on ${branch} \u2014 skipping the fileless-compose guard (#2813).` };
16047
+ }
16048
+ if (input.fact === null || input.fact.present === false) {
16049
+ return { ok: true };
16050
+ }
16051
+ const noEnvFile = input.fact.noEnvFile === true;
16052
+ if (composeMarksRuntimeEnvTrue(input.composeText) && !noEnvFile) {
16053
+ return {
16054
+ ok: false,
16055
+ reason: `the ${branch} docker-compose.yml marks a service fileless (mmi.runtime-env: "true") but DEPLOY#${stage2}.noEnvFile is not true \u2014 the ${stage2} deploy would fail exit-78 ("compose is fileless but DEPLOY#${stage2} noEnvFile is not true"). Fix BEFORE promoting, then re-run:
16056
+ mmi-cli project set-deploy --stage ${stage2} --no-env-file true # register the fileless flag
16057
+ gh workflow run tenant-reconcile.yml --repo mutmutco/MMI-Hub # re-render the box control script (#1056)
16058
+ (or pass --skip-compose-guard to override this preflight)`
16059
+ };
16060
+ }
16061
+ if (noEnvFile && composeDeclaresEnvFile(input.composeText)) {
16062
+ return {
16063
+ ok: false,
16064
+ reason: `DEPLOY#${stage2}.noEnvFile is true but the ${branch} docker-compose.yml still declares env_file: \u2014 the box writes no .env, so docker compose dies on the missing file. Land the fileless compose (drop env_file:, add the mmi.runtime-env label) on ${branch}, or set-deploy --no-env-file false (or --skip-compose-guard to override).`
16065
+ };
16066
+ }
16067
+ return { ok: true };
16068
+ }
16069
+
15932
16070
  // src/project-retire.ts
15933
16071
  function retireSlugOf(repoOrSlug) {
15934
16072
  return (repoOrSlug.includes("/") ? repoOrSlug.split("/").pop() : repoOrSlug).toLowerCase();
@@ -16215,7 +16353,7 @@ function registerSecretsCommands(program3) {
16215
16353
  }
16216
16354
  if (!await secretsOrgCatalogSet(d, body)) process.exitCode = 1;
16217
16355
  }));
16218
- secrets.command("preflight").description("check required stage secret names for a deploy/train without reading values").requiredOption("--stage <dev|rc|main>", "stage to check").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--required <KEY...>", "required keys; bare keys are scoped under --stage").option("--json", "machine-readable output").action(async (o) => {
16356
+ secrets.command("preflight").description("check required stage secret names for a deploy/train without reading values").requiredOption("--stage <dev|rc|main>", "stage to check").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--required <KEY...>", "required keys; bare keys are scoped under --stage").option("--skip-compose-guard", "skip the #2813 fileless-compose \u2194 DEPLOY#.noEnvFile promotion guard").option("--json", "machine-readable output").action(async (o) => {
16219
16357
  if (!["dev", "rc", "main"].includes(o.stage)) {
16220
16358
  return fail("secrets preflight: --stage must be dev, rc, or main");
16221
16359
  }
@@ -16236,8 +16374,26 @@ function registerSecretsCommands(program3) {
16236
16374
  if (!o.required?.length && centralContainer && meta && !hasRuntimeSecretContract(meta.requiredRuntimeSecrets)) {
16237
16375
  d.err('secrets preflight: requiredRuntimeSecrets is unset: treating as an empty contract (no required keys). Declare an explicit {"dev":[],"rc":[],"main":[]} in registry META to silence this warning.');
16238
16376
  }
16377
+ let filelessOk = true;
16378
+ if (meta && centralContainer && !o.skipComposeGuard) {
16379
+ const stage2 = o.stage;
16380
+ const branch = promotionSourceBranch(stage2, resolveReleaseTrack(meta, void 0, repo));
16381
+ const cwdRepo = repoFromRemoteUrl((await execFileP2("git", ["remote", "get-url", "origin"]).catch(() => ({ stdout: "" }))).stdout);
16382
+ const targetRepo2 = o.repo ? o.repo.includes("/") ? o.repo : `mutmutco/${o.repo}` : cwdRepo;
16383
+ const sameRepo = !o.repo || Boolean(cwdRepo) && Boolean(targetRepo2) && cwdRepo.toLowerCase() === targetRepo2.toLowerCase();
16384
+ const facts = await fetchDeployFactsBySlug(slug, regDeps);
16385
+ const fact = facts?.stages?.[stage2] ?? null;
16386
+ const composeText = sameRepo ? await readStageBranchCompose(branch) : null;
16387
+ const verdict = evaluatePromotionFilelessGuard({ composeText, branch, stage: stage2, fact, sameRepo: Boolean(sameRepo), force: false });
16388
+ if (!verdict.ok) {
16389
+ d.err(`secrets preflight: ${verdict.reason}`);
16390
+ filelessOk = false;
16391
+ } else if (verdict.warn) {
16392
+ d.err(`secrets preflight: ${verdict.warn}`);
16393
+ }
16394
+ }
16239
16395
  const ok = await secretsPreflight(d, { repo: o.repo, stage: o.stage, required, json: o.json, declaredSecrets: meta?.secrets });
16240
- if (!ok) process.exitCode = 1;
16396
+ if (!ok || !filelessOk) process.exitCode = 1;
16241
16397
  });
16242
16398
  secrets.command("get <key>").description("print one secret value, RAW \u2014 MASTER-ONLY (WS6 #2184); non-master: `secrets use` to consume keyless or `secrets verify` to validate").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--slug <slug>", "vault slug override for org-infra namespaces (cloudflare, shared, \u2026) \u2014 master-gated").action((key, o) => withSecrets(async (d) => {
16243
16399
  const ok = await secretsGet(d, key, o);
@@ -16674,7 +16830,7 @@ async function runIssueList(deps, opts) {
16674
16830
  return shapeIssueList(rows);
16675
16831
  }
16676
16832
  function childrenGraphqlArgs(owner, name, number) {
16677
- const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){issue(number:" + number + "){number subIssues(first:100){nodes{number title state url assignees(first:10){nodes{login}} repository{nameWithOwner} projectItems(first:5){nodes{project{id name} fieldValues(first:20){nodes{... on ProjectV2ItemFieldSingleSelectValue{name field{...on ProjectV2SingleSelectField{name}}}}}}} timelineItems(first:30,itemTypes:[CROSS_REFERENCED_EVENT]){nodes{...on CrossReferencedEvent{source{...on PullRequest{number title state url}}}}}}}}}}";
16833
+ const query = "query($owner:String!,$name:String!){repository(owner:$owner,name:$name){issue(number:" + number + "){number subIssues(first:100){nodes{number title state url assignees(first:10){nodes{login}} repository{nameWithOwner} projectItems(first:5){nodes{project{id} fieldValues(first:20){nodes{... on ProjectV2ItemFieldSingleSelectValue{name field{...on ProjectV2SingleSelectField{name}}}}}}} timelineItems(first:30,itemTypes:[CROSS_REFERENCED_EVENT]){nodes{...on CrossReferencedEvent{source{...on PullRequest{number title state url}}}}}}}}}}";
16678
16834
  return ["api", "graphql", "-f", `query=${query}`, "-f", `owner=${owner}`, "-f", `name=${name}`];
16679
16835
  }
16680
16836
  function shapeChildNode(node, depth, boardProjectId) {
@@ -17940,10 +18096,6 @@ function reloadAction(surface) {
17940
18096
  }
17941
18097
  }
17942
18098
  var CLAUDE_RECOVERY = `claude plugin marketplace remove ${LEGACY_MMI_MARKETPLACE} && claude plugin marketplace remove mutmutco && claude plugin marketplace add mutmutco/MMI-Hub --ref main && claude plugin install mmi@mutmutco`;
17943
- var CODEX_RECOVERY = `codex plugin marketplace remove ${LEGACY_MMI_MARKETPLACE} && codex plugin marketplace remove mutmutco && codex plugin marketplace add mutmutco/MMI-Hub --ref main && codex plugin add mmi@mutmutco`;
17944
- var CURSOR_RECOVERY = "in Cursor Dashboard \u2192 Settings \u2192 Plugins, click Update next to the MMI Team Marketplace";
17945
- var OPENCODE_PLUGIN_INSTALL_COMMAND = "mmi-cli doctor --apply";
17946
- var OPENCODE_RECOVERY = `${OPENCODE_PLUGIN_INSTALL_COMMAND} # then restart OpenCode to load MMI commands`;
17947
18099
  var PLUGIN_SURFACE_HEAL = {
17948
18100
  claude: {
17949
18101
  delivery: "plugin-cli",
@@ -17955,43 +18107,15 @@ var PLUGIN_SURFACE_HEAL = {
17955
18107
  { args: ["plugin", "install", "mmi@mutmutco"], gated: true },
17956
18108
  { args: ["plugin", "enable", "mmi@mutmutco"], gated: false }
17957
18109
  ],
17958
- pluginRunner: "claude",
17959
18110
  fix: (surface) => `${CLAUDE_RECOVERY} # then ${reloadAction(surface)} to reload MMI commands`,
17960
18111
  updateRecipe: [CLAUDE_RECOVERY]
17961
- },
17962
- codex: {
17963
- delivery: "plugin-cli",
17964
- recovery: CODEX_RECOVERY,
17965
- healSteps: [
17966
- { args: ["plugin", "marketplace", "remove", LEGACY_MMI_MARKETPLACE], gated: false },
17967
- { args: ["plugin", "marketplace", "remove", "mutmutco"], gated: false },
17968
- { args: ["plugin", "marketplace", "add", "mutmutco/MMI-Hub", "--ref", "main"], gated: true },
17969
- { args: ["plugin", "add", "mmi@mutmutco"], gated: true }
17970
- ],
17971
- pluginRunner: "codex",
17972
- fix: (surface) => `${CODEX_RECOVERY} # then ${reloadAction(surface)}`,
17973
- updateRecipe: [CODEX_RECOVERY, "codex plugin list # verify mmi@mutmutco shows the released version"],
17974
- guardRecovery: "$env:MMI_AGENT_SURFACE='codex'; mmi-cli plugin-heal"
17975
- },
17976
- cursor: {
17977
- delivery: "cursor-cache",
17978
- recovery: CURSOR_RECOVERY,
17979
- healSteps: null,
17980
- fix: (surface) => `${CURSOR_RECOVERY}; then ${reloadAction(surface)} to reload MMI skills + hooks`,
17981
- updateRecipe: [CURSOR_RECOVERY]
17982
- },
17983
- opencode: {
17984
- delivery: "npm",
17985
- recovery: OPENCODE_RECOVERY,
17986
- healSteps: null,
17987
- fix: () => OPENCODE_RECOVERY,
17988
- updateRecipe: [OPENCODE_RECOVERY, "restart OpenCode, then run mmi-cli doctor to verify OpenCode plugin reports the released version"]
17989
18112
  }
17990
18113
  };
17991
18114
  var CLAUDE_PLUGIN_RECOVERY = PLUGIN_SURFACE_HEAL.claude.recovery;
17992
18115
  var CLAUDE_PLUGIN_HEAL_STEPS = PLUGIN_SURFACE_HEAL.claude.healSteps;
17993
- var CODEX_PLUGIN_RECOVERY = PLUGIN_SURFACE_HEAL.codex.recovery;
17994
- var CODEX_PLUGIN_HEAL_STEPS = PLUGIN_SURFACE_HEAL.codex.healSteps;
18116
+ function nonClaudeSurfaceHealMessage() {
18117
+ return "No Hub-shipped MMI plugin on this host \u2014 the Hub is Claude-only since #2741.\n Update the CLI instead: npm i -g @mutmutco/cli (org rules ride an AGENTS.md authored outside the Hub)";
18118
+ }
17995
18119
  function healStepAborts(step, ok) {
17996
18120
  return !ok && step.gated;
17997
18121
  }
@@ -18070,6 +18194,9 @@ function snapshotPluginGuardInput(surface = detectSurface(process.env), isOrgRep
18070
18194
  pluginCachePresent: (0, import_node_fs21.existsSync)((0, import_node_path20.join)((0, import_node_os6.homedir)(), homeDir, "plugins", "cache", "mutmutco", "mmi"))
18071
18195
  };
18072
18196
  }
18197
+ function claudePluginGuardState(isOrgRepo) {
18198
+ return buildPluginGuardDecision(snapshotPluginGuardInput(detectSurface(process.env), isOrgRepo)).state;
18199
+ }
18073
18200
  async function runClaudePlugin(args) {
18074
18201
  try {
18075
18202
  await runHostBin("claude", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
@@ -18078,14 +18205,6 @@ async function runClaudePlugin(args) {
18078
18205
  return false;
18079
18206
  }
18080
18207
  }
18081
- async function runCodexPlugin(args) {
18082
- try {
18083
- await runHostBin("codex", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
18084
- return true;
18085
- } catch {
18086
- return false;
18087
- }
18088
- }
18089
18208
  async function marketplaceAddRefSupported(bin) {
18090
18209
  try {
18091
18210
  const { stdout, stderr } = await runHostBin(bin, ["plugin", "marketplace", "add", "--help"], {
@@ -18105,33 +18224,36 @@ function refAbsenceNote(bin, refSupported) {
18105
18224
  return refSupported ? "" : `
18106
18225
  Note: \`${bin}\` has no \`--ref\` option; the marketplace manifest pins the released branch, so none is needed (#2516).`;
18107
18226
  }
18108
- async function applyPluginHeal(token, surface, log, opts) {
18109
- if (!opts?.force && surfaceToken(surface) !== token) return false;
18110
- const descriptor = PLUGIN_SURFACE_HEAL[token];
18111
- const tableSteps = descriptor.healSteps;
18227
+ var PLUGIN_READ_REPO2 = "mutmutco/MMI-Hub";
18228
+ function pluginReadGrantNote(login = "<your-github-login>") {
18229
+ return `
18230
+ If the reinstall 404s on ${PLUGIN_READ_REPO2}, you lack read on it. An org owner must grant it (idempotent):
18231
+ gh api -X PUT repos/${PLUGIN_READ_REPO2}/collaborators/${login} -f permission=pull`;
18232
+ }
18233
+ async function applyPluginHeal(surface, log, opts) {
18234
+ if (!opts?.force && surfaceToken(surface) !== "claude") return false;
18235
+ const tableSteps = PLUGIN_SURFACE_HEAL.claude.healSteps;
18112
18236
  if (!tableSteps) return false;
18113
- const bin = descriptor.pluginRunner === "codex" ? "codex" : "claude";
18114
- const runner = descriptor.pluginRunner === "codex" ? runCodexPlugin : runClaudePlugin;
18115
- const refSupported = await marketplaceAddRefSupported(bin);
18237
+ const refSupported = await marketplaceAddRefSupported("claude");
18116
18238
  const { steps } = adaptHealStepsForRefSupport(tableSteps, refSupported);
18117
- log(healBannerLine(bin, token, refSupported));
18239
+ log(healBannerLine("claude", "claude", refSupported));
18118
18240
  for (const step of steps) {
18119
- if (healStepAborts(step, await runner([...step.args]))) return false;
18241
+ if (healStepAborts(step, await runClaudePlugin([...step.args]))) return false;
18120
18242
  }
18121
18243
  return true;
18122
18244
  }
18123
- function guardRecoveryCommand(surface) {
18124
- const token = surfaceToken(surface);
18125
- return token ? PLUGIN_SURFACE_HEAL[token]?.guardRecovery : void 0;
18126
- }
18127
18245
  async function runGuard(opts = {}, readOrigin) {
18128
18246
  void opts;
18129
18247
  try {
18130
18248
  const surface = detectSurface(process.env);
18249
+ if (surfaceToken(surface) !== "claude") {
18250
+ process.exitCode = 0;
18251
+ return;
18252
+ }
18131
18253
  const isOrgRepo = readOrigin ? await isOrgRepoRoot(readOrigin) : await isOrgRepoRoot();
18132
18254
  const input = snapshotPluginGuardInput(surface, isOrgRepo);
18133
18255
  const { state } = buildPluginGuardDecision(input);
18134
- const { line, exitCode } = buildGuardSessionStartLine(state, { recovery: guardRecoveryCommand(surface) });
18256
+ const { line, exitCode } = buildGuardSessionStartLine(state);
18135
18257
  if (line) console.error(line);
18136
18258
  process.exitCode = exitCode;
18137
18259
  } catch {
@@ -18139,24 +18261,20 @@ async function runGuard(opts = {}, readOrigin) {
18139
18261
  }
18140
18262
  }
18141
18263
  async function runPluginHeal(surface = detectSurface(process.env)) {
18142
- const token = surface === "codex" ? "codex" : "claude";
18143
- const tableKey = surfaceToken(surface) ?? "claude";
18144
- const descriptor = PLUGIN_SURFACE_HEAL[tableKey];
18145
- if (!descriptor.healSteps) {
18146
- console.log(descriptor.recovery);
18147
- console.log(`Then: ${reloadAction(surface)}`);
18264
+ if (surfaceToken(surface) !== "claude") {
18265
+ console.log(nonClaudeSurfaceHealMessage());
18148
18266
  return;
18149
18267
  }
18150
- const healed = await applyPluginHeal(token, surface, console.log, { force: true });
18268
+ const descriptor = PLUGIN_SURFACE_HEAL.claude;
18269
+ const healed = await applyPluginHeal(surface, console.log, { force: true });
18151
18270
  if (healed) {
18152
18271
  console.log(` \u2713 MMI plugin reinstalled \u2014 ${reloadAction(surface)} to load MMI commands`);
18153
18272
  } else {
18154
- const bin = descriptor.pluginRunner === "codex" ? "codex" : "claude";
18155
- const refSupported = await marketplaceAddRefSupported(bin);
18273
+ const refSupported = await marketplaceAddRefSupported("claude");
18156
18274
  const recovery = refSupported ? descriptor.recovery : recoveryWithoutRef(descriptor.recovery);
18157
- const note = refAbsenceNote(bin, refSupported);
18275
+ const note = refAbsenceNote("claude", refSupported);
18158
18276
  console.log(` \u2717 Auto-heal failed or was skipped. Run manually:
18159
- ${recovery}${note}`);
18277
+ ${recovery}${note}${pluginReadGrantNote()}`);
18160
18278
  }
18161
18279
  }
18162
18280
  var USER_SCOPE_GUARD_MARKER = "mmi-guard:v1";
@@ -19229,7 +19347,14 @@ function checkRepoWorktrees(probe) {
19229
19347
  };
19230
19348
  }
19231
19349
  function checkClaudePlugin(probe) {
19232
- const { installed, released } = probe;
19350
+ const { installed, released, guardState } = probe;
19351
+ if (guardState === "no-install" || guardState === "unresolved") {
19352
+ return {
19353
+ ok: false,
19354
+ label: guardState === "no-install" ? "Claude plugin \u2014 not installed" : "Claude plugin \u2014 unresolved (marketplace/cache missing)",
19355
+ fix: "run `mmi-cli plugin-heal` to reinstall the MMI marketplace + plugin, then restart Claude"
19356
+ };
19357
+ }
19233
19358
  const behind = Boolean(installed && released) && compareVersions(installed, released) < 0;
19234
19359
  if (behind) {
19235
19360
  return {
@@ -19240,6 +19365,16 @@ function checkClaudePlugin(probe) {
19240
19365
  }
19241
19366
  return { ok: true, label: installed ? `Claude plugin ${installed}` : "Claude plugin" };
19242
19367
  }
19368
+ function checkCliVersion(input) {
19369
+ const report = buildVersionLagReport(input);
19370
+ if (!report.releasedVersion) return { ok: true, label: `mmi-cli ${report.currentVersion}` };
19371
+ if (report.ok) return { ok: true, label: `mmi-cli ${report.currentVersion}` };
19372
+ return {
19373
+ ok: false,
19374
+ label: `mmi-cli ${report.currentVersion} \u2192 ${report.releasedVersion}`,
19375
+ fix: report.fix
19376
+ };
19377
+ }
19243
19378
  function checkTrainSync(result) {
19244
19379
  const problems = result.branches.filter((b) => b.status === "skipped" && (b.reason === "diverged" || b.reason === "ff-failed"));
19245
19380
  if (problems.length) {
@@ -19286,9 +19421,10 @@ async function runDoctorClean(opts, io, deps) {
19286
19421
  checks.push({ ok: false, label: "gitignore block", fix: "run `mmi-cli doctor --apply` to write the org-managed .gitignore block" });
19287
19422
  }
19288
19423
  }
19289
- const plugin = checkClaudePlugin({ installed, released });
19424
+ const plugin = checkClaudePlugin({ installed, released, guardState: deps.pluginGuardState(isOrgRepo) });
19290
19425
  checks.push(plugin);
19291
19426
  if (!plugin.ok) restartPending = true;
19427
+ checks.push(checkCliVersion({ currentVersion: deps.currentCliVersion(), releasedVersion: released }));
19292
19428
  if (isOrgRepo && !opts.fast && !opts.banner) {
19293
19429
  try {
19294
19430
  checks.push(checkTrainSync(await deps.syncTrain()));
@@ -19566,7 +19702,9 @@ function mmiDoctorDeps() {
19566
19702
  awsCallerArn,
19567
19703
  isOrgRepo: () => isOrgRepoRoot(),
19568
19704
  installedPluginVersion: installedClaudePluginVersion,
19705
+ pluginGuardState: claudePluginGuardState,
19569
19706
  releasedVersion: fetchNpmReleasedVersion,
19707
+ currentCliVersion: resolveClientVersion,
19570
19708
  readGitignore,
19571
19709
  writeGitignore,
19572
19710
  hasRepoLocalWorktrees,
@@ -20449,15 +20587,6 @@ fullTrack.command("readiness <owner/repo>").description("aggregate branch topolo
20449
20587
  console.log(JSON.stringify(report, null, 2));
20450
20588
  if (!report.rcand.canApply) process.exitCode = 1;
20451
20589
  });
20452
- async function readStageBranchCompose(branch) {
20453
- for (const ref of [`origin/${branch}`, branch]) {
20454
- try {
20455
- return (await execFileP2("git", ["show", `${ref}:docker-compose.yml`], { timeout: GIT_TIMEOUT_MS })).stdout;
20456
- } catch {
20457
- }
20458
- }
20459
- return null;
20460
- }
20461
20590
  project.command("set-deploy [owner/repo]").description("write the DEPLOY#<stage> Hetzner deploy coords for a tenant (master-only) \u2014 the explicit-coords path that seeds a freshly-bootstrapped tenant; defaults to the current repo").requiredOption("--stage <stage>", "dev | rc | main").option("--ssh-host <host>", "the box address the deploy ssh-es into (required for hetzner-ssh)").option("--ssh-user <user>", "ssh user (default root)").option("--port <port>", "loopback port the container binds / Caddy upstream (1..65535)").option("--substrate <substrate>", "hetzner-ssh (default)").option("--deploy-path <path>", "on-box per-stage release root (default /opt/mmi/<slug>/<stage>)").option("--service <name>", "systemd/compose service name (default the slug)").option("--domain <domain>", "canonical serving host (default the project edgeDomains[stage])").option("--alias <domain...>", "extra serving hostname the box Caddy answers (repeatable)").option("--no-env-file <bool>", "set the DEPLOY# fileless flag (true|false) \u2014 true = #2662 env passthrough, no .env symlink; omit to leave the row unchanged").option("--force", "skip the #2748 fileless-compose guard (flip noEnvFile=true even if the stage branch compose still declares env_file:)").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
20462
20591
  const cfg = await loadConfig();
20463
20592
  let target;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.17.0",
3
+ "version": "3.18.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox (board, registry, keyless secrets, release train, bootstrap, doctor) and the cross-IDE engine the plugin's session-start hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",