@mutmutco/cli 3.70.0 → 3.72.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 +455 -249
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -37,6 +37,7 @@ __export(index_exports, {
37
37
  envHealLockPath: () => envHealLockPath,
38
38
  gcPlan: () => gcPlan,
39
39
  isOrgRegisteredRepo: () => isOrgRegisteredRepo,
40
+ parseInvalidChoiceError: () => parseInvalidChoiceError,
40
41
  positionalTargetForm: () => positionalTargetForm,
41
42
  registryClientDeps: () => registryClientDeps,
42
43
  repoSlug: () => repoSlug,
@@ -4052,7 +4053,9 @@ function argvWantsMachineFailure() {
4052
4053
  }
4053
4054
  function commandFromFailMessage(msg) {
4054
4055
  const head = msg.split(":", 1)[0].trim();
4055
- return head || "mmi-cli";
4056
+ if (!head || head.startsWith("-")) return "mmi-cli";
4057
+ if (!/^[a-z][a-z0-9-]*(?: [a-z][a-z0-9-]*){0,3}$/.test(head)) return "mmi-cli";
4058
+ return head;
4056
4059
  }
4057
4060
  function fail(msg, payload) {
4058
4061
  const json = argvWantsMachineFailure();
@@ -6686,21 +6689,39 @@ function patchRulesetRequiredContexts(body, contexts) {
6686
6689
  function findProductRuleset(rulesets) {
6687
6690
  return rulesets.find((r) => r.name === PRODUCT_RULESET_NAME);
6688
6691
  }
6689
- async function activateProductRuleset(repo, rulesetBody, client) {
6690
- const want = new Set(rulesetRequiredContexts({ rules: rulesetBody.rules }));
6692
+ async function latestCompletedGateRun(repo, client, query) {
6693
+ const res = await client.rest(
6694
+ "GET",
6695
+ `repos/${repo}/actions/workflows/gate.yml/runs?status=completed&per_page=1${query}`,
6696
+ { timeoutMs: 2e4 }
6697
+ );
6698
+ return res?.workflow_runs?.[0];
6699
+ }
6700
+ async function gateIsProvenGreen(repo, client, baseBranch) {
6701
+ try {
6702
+ const onBase = await latestCompletedGateRun(repo, client, `&branch=${encodeURIComponent(baseBranch)}`);
6703
+ const latest = onBase ?? await latestCompletedGateRun(repo, client, "");
6704
+ return latest?.conclusion === "success";
6705
+ } catch {
6706
+ return false;
6707
+ }
6708
+ }
6709
+ async function activateProductRuleset(repo, rulesetBody, client, enforcement = "active") {
6710
+ const body = { ...rulesetBody, enforcement };
6711
+ const want = new Set(rulesetRequiredContexts({ rules: body.rules }));
6691
6712
  const list = await client.rest("GET", `repos/${repo}/rulesets`, { timeoutMs: 2e4 });
6692
6713
  const existing = findProductRuleset(list ?? []);
6693
6714
  if (existing?.id != null) {
6694
6715
  const detail = await client.rest("GET", `repos/${repo}/rulesets/${existing.id}`, { timeoutMs: 2e4 });
6695
6716
  const have = new Set(rulesetRequiredContexts(detail));
6696
- if (detail.enforcement === "active" && have.size === want.size && [...want].every((c) => have.has(c))) {
6697
- return { action: "skipped", detail: "active ruleset already matches required contexts" };
6717
+ if (detail.enforcement === enforcement && have.size === want.size && [...want].every((c) => have.has(c))) {
6718
+ return { action: "skipped", enforcement, detail: `${enforcement} ruleset already matches required contexts` };
6698
6719
  }
6699
- await client.rest("PUT", `repos/${repo}/rulesets/${existing.id}`, { body: rulesetBody, timeoutMs: 2e4 });
6700
- return { action: "updated", detail: `ruleset ${existing.id}` };
6720
+ await client.rest("PUT", `repos/${repo}/rulesets/${existing.id}`, { body, timeoutMs: 2e4 });
6721
+ return { action: "updated", enforcement, detail: `ruleset ${existing.id}` };
6701
6722
  }
6702
- await client.rest("POST", `repos/${repo}/rulesets`, { body: rulesetBody, timeoutMs: 2e4 });
6703
- return { action: "created" };
6723
+ await client.rest("POST", `repos/${repo}/rulesets`, { body, timeoutMs: 2e4 });
6724
+ return { action: "created", enforcement };
6704
6725
  }
6705
6726
 
6706
6727
  // src/workflow-context.ts
@@ -8109,6 +8130,32 @@ async function seedRulesetRefIfMissing(repo, deps, derivedVars, baseBranch, resu
8109
8130
  result.errors.push(`ruleset reference seed failed: ${e.message}`);
8110
8131
  }
8111
8132
  }
8133
+ async function parkProductRuleset(repo, deps) {
8134
+ const result = { repo, applied: [], skipped: [], errors: [] };
8135
+ try {
8136
+ const list = await deps.client.rest("GET", `repos/${repo}/rulesets`, { timeoutMs: 2e4 });
8137
+ const existing = findProductRuleset(list ?? []);
8138
+ if (existing?.id == null) {
8139
+ result.skipped.push(`no ${PRODUCT_RULESET_NAME} ruleset on ${repo} \u2014 nothing to park`);
8140
+ return result;
8141
+ }
8142
+ const detail = await deps.client.rest("GET", `repos/${repo}/rulesets/${existing.id}`, { timeoutMs: 2e4 });
8143
+ if (detail.enforcement !== "active") {
8144
+ result.skipped.push(`${PRODUCT_RULESET_NAME} is already ${detail.enforcement ?? "non-enforcing"}`);
8145
+ return result;
8146
+ }
8147
+ await deps.client.rest("PUT", `repos/${repo}/rulesets/${existing.id}`, {
8148
+ body: { ...detail, enforcement: "disabled" },
8149
+ timeoutMs: 2e4
8150
+ });
8151
+ result.applied.push(
8152
+ `${PRODUCT_RULESET_NAME} parked (disabled; contexts [${rulesetRequiredContexts(detail).join(", ") || "none"}] kept)`
8153
+ );
8154
+ } catch (e) {
8155
+ result.errors.push(`park failed: ${e.message}`);
8156
+ }
8157
+ return result;
8158
+ }
8112
8159
  async function applyCiReconcileRepo(repo, deps) {
8113
8160
  const merge = await applyCiReconcileMergeSettings(repo, deps);
8114
8161
  const meta = await deps.getProjectMeta(slugFromRepo(repo));
@@ -8126,6 +8173,12 @@ async function applyCiReconcileRepo(repo, deps) {
8126
8173
  merge.errors.push(`missing ${PRODUCT_RULESET_REF} on development \u2014 run bootstrap apply first`);
8127
8174
  return merge;
8128
8175
  }
8176
+ if (!await gateIsProvenGreen(repo, deps.client, "development")) {
8177
+ merge.skipped.push(
8178
+ "product ruleset left non-enforcing \u2014 the gate is not green on development; activating it would block every PR (#3694)"
8179
+ );
8180
+ return merge;
8181
+ }
8129
8182
  try {
8130
8183
  let body = stripRulesetComment(raw);
8131
8184
  if (!driftCheck?.ok) {
@@ -9359,7 +9412,7 @@ function boardConfigFromProject(meta, floor = {}) {
9359
9412
 
9360
9413
  // src/cli-doctor-shared.ts
9361
9414
  var import_node_fs15 = require("node:fs");
9362
- var import_node_path13 = require("node:path");
9415
+ var import_node_path14 = require("node:path");
9363
9416
  var import_node_fs16 = require("node:fs");
9364
9417
 
9365
9418
  // src/readiness-audit.ts
@@ -9477,6 +9530,193 @@ ${lines.join("\n")}`;
9477
9530
 
9478
9531
  // src/secrets.ts
9479
9532
  var import_node_child_process6 = require("node:child_process");
9533
+
9534
+ // src/gh-create.ts
9535
+ var import_promises = require("node:fs/promises");
9536
+ var import_node_os3 = require("node:os");
9537
+ var import_node_path13 = require("node:path");
9538
+ var import_node_crypto3 = require("node:crypto");
9539
+ var ISSUE_TYPES = ["bug", "feature", "task"];
9540
+ var GH_MUTATION_TIMEOUT_MS = 12e4;
9541
+ function timeoutKillNote(err, timeoutMs) {
9542
+ if (typeof err !== "object" || err === null || !err.killed) return void 0;
9543
+ return `killed at the ${timeoutMs}ms timeout \u2014 the write may have completed server-side; verify before retrying`;
9544
+ }
9545
+ function normalizePriority(priority) {
9546
+ const p = priority.trim().toLowerCase().replace(/[\s_-]+/g, "");
9547
+ if (!CLI_PRIORITIES.includes(p)) {
9548
+ throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${CLI_PRIORITIES.join(", ")}`);
9549
+ }
9550
+ return p;
9551
+ }
9552
+ function parseCreatedUrl(stdout) {
9553
+ const re = /https:\/\/github\.com\/[^\s]+\/(?:issues|pull)\/(\d+)/g;
9554
+ let match;
9555
+ let last;
9556
+ while ((match = re.exec(stdout)) !== null) {
9557
+ last = { number: Number(match[1]), url: match[0] };
9558
+ }
9559
+ if (!last) throw new Error(`could not find a github issue/PR URL in gh output:
9560
+ ${stdout.trim() || "(empty)"}`);
9561
+ return last;
9562
+ }
9563
+ function buildIssueArgs({ type, title, body, priority, repo, labels }) {
9564
+ if (!ISSUE_TYPES.includes(type)) throw new Error(`unknown issue type "${type}" \u2014 expected one of: ${ISSUE_TYPES.join(", ")}`);
9565
+ normalizePriority(priority);
9566
+ const args = ["issue", "create"];
9567
+ if (repo) args.push("--repo", repo);
9568
+ args.push("--title", title, "--body", body, "--label", type);
9569
+ for (const label of labels ?? []) args.push("--label", label);
9570
+ return args;
9571
+ }
9572
+ async function ensureLabelsExist(labels, repo, deps = {}) {
9573
+ const run = deps.run ?? execFileP2;
9574
+ for (const label of new Set(labels)) {
9575
+ const args = ["label", "create", label, "--color", "ededed"];
9576
+ if (repo) args.push("--repo", repo);
9577
+ try {
9578
+ await run("gh", args, { timeout: GH_MUTATION_TIMEOUT_MS });
9579
+ } catch {
9580
+ }
9581
+ }
9582
+ }
9583
+ async function bodyArgsViaFile(args, deps = {}) {
9584
+ const i = args.indexOf("--body");
9585
+ if (i === -1 || i + 1 >= args.length) return { args, cleanup: async () => {
9586
+ } };
9587
+ const write = deps.write ?? import_promises.writeFile;
9588
+ const remove2 = deps.remove ?? import_promises.unlink;
9589
+ const ensureDir = deps.ensureDir ?? import_promises.mkdir;
9590
+ const dir = deps.dir ?? (0, import_node_os3.tmpdir)();
9591
+ const file = (0, import_node_path13.join)(dir, `mmi-gh-body-${process.pid}-${(0, import_node_crypto3.randomBytes)(4).toString("hex")}.md`);
9592
+ await ensureDir((0, import_node_path13.dirname)(file), { recursive: true }).catch(() => {
9593
+ });
9594
+ await write(file, args[i + 1], "utf8");
9595
+ return {
9596
+ args: [...args.slice(0, i), "--body-file", file, ...args.slice(i + 2)],
9597
+ cleanup: async () => {
9598
+ try {
9599
+ await remove2(file);
9600
+ } catch {
9601
+ }
9602
+ }
9603
+ };
9604
+ }
9605
+ function buildAddToProjectArgs(projectId, contentId) {
9606
+ if (!projectId) throw new Error("addToProject: projectId is required");
9607
+ if (!contentId) throw new Error("addToProject: contentId is required");
9608
+ return [
9609
+ "api",
9610
+ "graphql",
9611
+ "-f",
9612
+ "query=mutation($p:ID!,$c:ID!){addProjectV2ItemById(input:{projectId:$p,contentId:$c}){item{id}}}",
9613
+ "-f",
9614
+ `p=${projectId}`,
9615
+ "-f",
9616
+ `c=${contentId}`
9617
+ ];
9618
+ }
9619
+ function parseAddedItemId(stdout) {
9620
+ try {
9621
+ return JSON.parse(stdout)?.data?.addProjectV2ItemById?.item?.id || void 0;
9622
+ } catch {
9623
+ return void 0;
9624
+ }
9625
+ }
9626
+ function isAlreadyOnBoardError(stderr) {
9627
+ return /already exists in (?:this|the) project/i.test(stderr);
9628
+ }
9629
+ var CLOSING_KEYWORD = "clos(?:e|es|ed)|fix(?:es|ed)?|resolve(?:s|d)?";
9630
+ var CLOSING_CHAIN = new RegExp(
9631
+ `\\b(${CLOSING_KEYWORD})(\\s+)#(\\d+)((?:\\s*(?:,|,?\\s+and)\\s*#\\d+)+)`,
9632
+ "gi"
9633
+ );
9634
+ function normalizeClosingDirectives(body) {
9635
+ if (!body.includes("#")) return body;
9636
+ const segments = body.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/g);
9637
+ return segments.map(
9638
+ (seg, i) => i % 2 === 1 ? seg : seg.replace(
9639
+ CLOSING_CHAIN,
9640
+ (_m, kw, ws, first, tail) => `${kw}${ws}#${first}${tail.replace(/#(\d+)/g, "closes #$1")}`
9641
+ )
9642
+ ).join("");
9643
+ }
9644
+ function buildPrArgs({ title, body, base, head, repo, draft }) {
9645
+ const args = ["pr", "create"];
9646
+ if (repo) args.push("--repo", repo);
9647
+ args.push("--title", title, "--body", body);
9648
+ if (base) args.push("--base", base);
9649
+ if (head) args.push("--head", head);
9650
+ if (draft) args.push("--draft");
9651
+ return args;
9652
+ }
9653
+ function parseExistingPr(stderr) {
9654
+ if (!/already exists/i.test(stderr)) return void 0;
9655
+ const match = /https:\/\/\S*\/pull\/(\d+)/.exec(stderr);
9656
+ if (!match) return void 0;
9657
+ return { number: Number(match[1]), url: match[0] };
9658
+ }
9659
+ var GH_CREATE_UPSTREAM_RETRIES = 3;
9660
+ var GH_CREATE_RETRY_BACKOFF_MS = [2e3, 5e3];
9661
+ function httpStatusCodes(stderr) {
9662
+ const out = [];
9663
+ for (const m of stderr.matchAll(/\bHTTP\/?\d*(?:\.\d)?\s+(\d{3})\b/g)) out.push(Number(m[1]));
9664
+ for (const m of stderr.matchAll(/\b(\d{3})\s+Internal Server Error\b/g)) out.push(Number(m[1]));
9665
+ return out;
9666
+ }
9667
+ function isUpstreamGitHubFault(stderr) {
9668
+ const codes = httpStatusCodes(stderr);
9669
+ if (codes.some((c) => c >= 400 && c < 500)) return false;
9670
+ if (codes.some((c) => c >= 500 && c < 600)) return true;
9671
+ return /Something went wrong while executing your query/.test(stderr) || /^\s*unexpected end of JSON input\s*$/m.test(stderr);
9672
+ }
9673
+ function mayRetryCreate(verb) {
9674
+ return verb === "pr";
9675
+ }
9676
+ function upstreamFaultMessage(verb, stderr) {
9677
+ const requestId = /\b([0-9A-F]{4}:[0-9A-F]{4,6}:[0-9A-F]+:[0-9A-F]+:[0-9A-F]+)\b/.exec(stderr)?.[1];
9678
+ const advice = mayRetryCreate(verb) ? "Retrying is the right action" : "The write may have completed server-side before the error \u2014 VERIFY before retrying, or a retry will duplicate it";
9679
+ return `gh ${verb} create failed: GitHub's API is failing (server-side 5xx) \u2014 this is NOT a problem with your diff or arguments. GitHub request ID: ${requestId ?? "unavailable"}. ${advice}; note githubstatus.com may still read green while this is happening.`;
9680
+ }
9681
+ async function ghCreate(args, deps = {}) {
9682
+ const exec = deps.exec ?? execFileP2;
9683
+ const sleep3 = deps.sleep ?? ((ms) => new Promise((resolve5) => setTimeout(resolve5, ms)));
9684
+ const swapped = await bodyArgsViaFile(args);
9685
+ try {
9686
+ for (let attempt = 1; attempt <= GH_CREATE_UPSTREAM_RETRIES; attempt++) {
9687
+ try {
9688
+ const { stdout } = await exec("gh", swapped.args, { timeout: GH_MUTATION_TIMEOUT_MS });
9689
+ return parseCreatedUrl(stdout);
9690
+ } catch (e) {
9691
+ const err = e;
9692
+ const errText = `${err.stderr ?? ""}
9693
+ ${err.message ?? ""}`;
9694
+ const faultText = (err.stderr ?? "").trim() ? err.stderr : err.message ?? "";
9695
+ const existing = args[0] === "pr" ? parseExistingPr(errText) : void 0;
9696
+ if (existing) {
9697
+ await swapped.cleanup();
9698
+ console.warn(`${args[0]} create: a PR already exists for this branch \u2014 #${existing.number} (nothing to do)`);
9699
+ return { ...existing, existing: true };
9700
+ }
9701
+ const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
9702
+ if (isUpstreamGitHubFault(faultText) && mayRetryCreate(args[0]) && attempt < GH_CREATE_UPSTREAM_RETRIES) {
9703
+ const waitMs = GH_CREATE_RETRY_BACKOFF_MS[attempt - 1];
9704
+ console.warn(`gh ${args[0]} create: GitHub API server fault (attempt ${attempt}/${GH_CREATE_UPSTREAM_RETRIES}) \u2014 retrying in ${waitMs}ms`);
9705
+ await sleep3(waitMs);
9706
+ continue;
9707
+ }
9708
+ await swapped.cleanup();
9709
+ if (isUpstreamGitHubFault(faultText)) return fail(upstreamFaultMessage(args[0], faultText));
9710
+ return fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
9711
+ }
9712
+ }
9713
+ throw new Error("ghCreate: retry loop exhausted without a verdict");
9714
+ } finally {
9715
+ await swapped.cleanup();
9716
+ }
9717
+ }
9718
+
9719
+ // src/secrets.ts
9480
9720
  var OWNER = "mutmutco";
9481
9721
  var SSM_ROOT = "/mmi-future";
9482
9722
  var PROJECT_TIER_SEGMENT = "dev";
@@ -10105,6 +10345,15 @@ async function secretsRequest(deps, key, opts) {
10105
10345
  deps.err(`invalid secret key ${JSON.stringify(key)}`);
10106
10346
  return false;
10107
10347
  }
10348
+ let priority = "medium";
10349
+ if (opts.priority !== void 0) {
10350
+ try {
10351
+ priority = normalizePriority(opts.priority);
10352
+ } catch (e) {
10353
+ deps.err(`secrets request: ${e.message}`);
10354
+ return false;
10355
+ }
10356
+ }
10108
10357
  const repo = await targetRepo(deps, opts);
10109
10358
  const requester = await probeCapabilities(deps, repo);
10110
10359
  if (requester?.role === "master") {
@@ -10122,7 +10371,7 @@ async function secretsRequest(deps, key, opts) {
10122
10371
  body: JSON.stringify({
10123
10372
  repo,
10124
10373
  key,
10125
- priority: opts.priority ?? "medium",
10374
+ priority,
10126
10375
  reason: opts.reason,
10127
10376
  context: opts.context
10128
10377
  }),
@@ -10749,7 +10998,7 @@ async function localBranchHeads() {
10749
10998
  }
10750
10999
  async function currentRepoWorktreeGitRoot(repoRoot2) {
10751
11000
  const gitCommonDir = (await execFileP2("git", ["rev-parse", "--git-common-dir"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
10752
- return gitCommonDir ? (0, import_node_path13.resolve)(repoRoot2, gitCommonDir, "worktrees") : "";
11001
+ return gitCommonDir ? (0, import_node_path14.resolve)(repoRoot2, gitCommonDir, "worktrees") : "";
10753
11002
  }
10754
11003
  async function worktreeBranches() {
10755
11004
  const { stdout } = await execFileP2("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
@@ -10769,7 +11018,7 @@ function resolveGitdirForWorktreeFile(worktreePath, content) {
10769
11018
  const match = /^gitdir:\s*(.+)\s*$/im.exec(content);
10770
11019
  if (!match?.[1]) return void 0;
10771
11020
  const raw = match[1].trim();
10772
- return (0, import_node_path13.isAbsolute)(raw) ? raw : (0, import_node_path13.resolve)(worktreePath, raw);
11021
+ return (0, import_node_path14.isAbsolute)(raw) ? raw : (0, import_node_path14.resolve)(worktreePath, raw);
10773
11022
  }
10774
11023
  function metadataOwnsMissingWorktreeDir(worktreePath, worktreeGitRoot) {
10775
11024
  if (!worktreeGitRoot) return false;
@@ -10778,9 +11027,9 @@ function metadataOwnsMissingWorktreeDir(worktreePath, worktreeGitRoot) {
10778
11027
  for (const ent of entries) {
10779
11028
  if (!ent.isDirectory()) continue;
10780
11029
  try {
10781
- const gitdirPath = (0, import_node_fs15.readFileSync)((0, import_node_path13.join)(worktreeGitRoot, ent.name, "gitdir"), "utf8").trim();
10782
- const resolvedGitdir = (0, import_node_path13.isAbsolute)(gitdirPath) ? gitdirPath : (0, import_node_path13.resolve)(worktreeGitRoot, ent.name, gitdirPath);
10783
- if (sameWorktreeMetadataPath((0, import_node_path13.dirname)(resolvedGitdir), worktreePath)) return true;
11030
+ const gitdirPath = (0, import_node_fs15.readFileSync)((0, import_node_path14.join)(worktreeGitRoot, ent.name, "gitdir"), "utf8").trim();
11031
+ const resolvedGitdir = (0, import_node_path14.isAbsolute)(gitdirPath) ? gitdirPath : (0, import_node_path14.resolve)(worktreeGitRoot, ent.name, gitdirPath);
11032
+ if (sameWorktreeMetadataPath((0, import_node_path14.dirname)(resolvedGitdir), worktreePath)) return true;
10784
11033
  } catch {
10785
11034
  }
10786
11035
  }
@@ -10799,7 +11048,7 @@ function pathExistsKnown(path2) {
10799
11048
  }
10800
11049
  }
10801
11050
  function inspectSiblingWorktreeDir(path2, worktreeGitRoot) {
10802
- const gitPath = (0, import_node_path13.join)(path2, ".git");
11051
+ const gitPath = (0, import_node_path14.join)(path2, ".git");
10803
11052
  let st;
10804
11053
  try {
10805
11054
  st = (0, import_node_fs16.lstatSync)(gitPath);
@@ -10853,7 +11102,7 @@ async function preservedBranches() {
10853
11102
  async function siblingWorktreeDirs(explicitRoot) {
10854
11103
  const repoRoot2 = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
10855
11104
  const worktreeGitRoot = await currentRepoWorktreeGitRoot(repoRoot2);
10856
- const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path13.dirname)((0, import_node_path13.dirname)(worktreeGitRoot)) : repoRoot2;
11105
+ const primaryRepoRoot = worktreeGitRoot ? (0, import_node_path14.dirname)((0, import_node_path14.dirname)(worktreeGitRoot)) : repoRoot2;
10857
11106
  try {
10858
11107
  const dirs = explicitRoot ? listDirsIn(resolveExplicitScanRoot(explicitRoot, primaryRepoRoot)) : worktreeScanDirs(siblingMmiWorktreesRoot(primaryRepoRoot), primaryRepoRoot, listDirsIn, isRepoCheckoutDir);
10859
11108
  return dirs.map((dir) => inspectSiblingWorktreeDir(dir, worktreeGitRoot)).filter((entry) => Boolean(entry));
@@ -10863,13 +11112,13 @@ async function siblingWorktreeDirs(explicitRoot) {
10863
11112
  }
10864
11113
  function listDirsIn(dir) {
10865
11114
  try {
10866
- return (0, import_node_fs16.readdirSync)(dir, { withFileTypes: true }).filter((ent) => ent.isDirectory()).map((ent) => (0, import_node_path13.join)(dir, ent.name));
11115
+ return (0, import_node_fs16.readdirSync)(dir, { withFileTypes: true }).filter((ent) => ent.isDirectory()).map((ent) => (0, import_node_path14.join)(dir, ent.name));
10867
11116
  } catch {
10868
11117
  return [];
10869
11118
  }
10870
11119
  }
10871
11120
  function isRepoCheckoutDir(dir) {
10872
- return (0, import_node_fs16.existsSync)((0, import_node_path13.join)(dir, ".git"));
11121
+ return (0, import_node_fs16.existsSync)((0, import_node_path14.join)(dir, ".git"));
10873
11122
  }
10874
11123
  function resolveExplicitScanRoot(explicitRoot, repoRoot2) {
10875
11124
  let rootDirs;
@@ -10940,7 +11189,7 @@ function measureWorktreeRoot(root, deadline) {
10940
11189
  continue;
10941
11190
  }
10942
11191
  for (const ent of entries) {
10943
- const child2 = (0, import_node_path13.join)(current, ent.name);
11192
+ const child2 = (0, import_node_path14.join)(current, ent.name);
10944
11193
  if (ent.isDirectory()) {
10945
11194
  if (depth0) dirs++;
10946
11195
  let isLink = false;
@@ -10963,7 +11212,7 @@ function measureWorktreeRoot(root, deadline) {
10963
11212
  }
10964
11213
  function worktreeRootsProbe(repoRoot2) {
10965
11214
  const authoritative = siblingMmiWorktreesRoot(repoRoot2);
10966
- const container = (0, import_node_path13.dirname)(authoritative);
11215
+ const container = (0, import_node_path14.dirname)(authoritative);
10967
11216
  let names;
10968
11217
  try {
10969
11218
  names = (0, import_node_fs16.readdirSync)(container, { withFileTypes: true }).filter((ent) => ent.isDirectory()).map((ent) => ent.name);
@@ -12094,191 +12343,6 @@ async function resolveAutoAddBoardAttach(client, cfg, selector, priority, warn =
12094
12343
  return { projectItemId };
12095
12344
  }
12096
12345
 
12097
- // src/gh-create.ts
12098
- var import_promises = require("node:fs/promises");
12099
- var import_node_os3 = require("node:os");
12100
- var import_node_path14 = require("node:path");
12101
- var import_node_crypto3 = require("node:crypto");
12102
- var ISSUE_TYPES = ["bug", "feature", "task"];
12103
- var GH_MUTATION_TIMEOUT_MS = 12e4;
12104
- function timeoutKillNote(err, timeoutMs) {
12105
- if (typeof err !== "object" || err === null || !err.killed) return void 0;
12106
- return `killed at the ${timeoutMs}ms timeout \u2014 the write may have completed server-side; verify before retrying`;
12107
- }
12108
- function normalizePriority(priority) {
12109
- const p = priority.trim().toLowerCase().replace(/[\s_-]+/g, "");
12110
- if (!CLI_PRIORITIES.includes(p)) {
12111
- throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${CLI_PRIORITIES.join(", ")}`);
12112
- }
12113
- return p;
12114
- }
12115
- function parseCreatedUrl(stdout) {
12116
- const re = /https:\/\/github\.com\/[^\s]+\/(?:issues|pull)\/(\d+)/g;
12117
- let match;
12118
- let last;
12119
- while ((match = re.exec(stdout)) !== null) {
12120
- last = { number: Number(match[1]), url: match[0] };
12121
- }
12122
- if (!last) throw new Error(`could not find a github issue/PR URL in gh output:
12123
- ${stdout.trim() || "(empty)"}`);
12124
- return last;
12125
- }
12126
- function buildIssueArgs({ type, title, body, priority, repo, labels }) {
12127
- if (!ISSUE_TYPES.includes(type)) throw new Error(`unknown issue type "${type}" \u2014 expected one of: ${ISSUE_TYPES.join(", ")}`);
12128
- normalizePriority(priority);
12129
- const args = ["issue", "create"];
12130
- if (repo) args.push("--repo", repo);
12131
- args.push("--title", title, "--body", body, "--label", type);
12132
- for (const label of labels ?? []) args.push("--label", label);
12133
- return args;
12134
- }
12135
- async function ensureLabelsExist(labels, repo, deps = {}) {
12136
- const run = deps.run ?? execFileP2;
12137
- for (const label of new Set(labels)) {
12138
- const args = ["label", "create", label, "--color", "ededed"];
12139
- if (repo) args.push("--repo", repo);
12140
- try {
12141
- await run("gh", args, { timeout: GH_MUTATION_TIMEOUT_MS });
12142
- } catch {
12143
- }
12144
- }
12145
- }
12146
- async function bodyArgsViaFile(args, deps = {}) {
12147
- const i = args.indexOf("--body");
12148
- if (i === -1 || i + 1 >= args.length) return { args, cleanup: async () => {
12149
- } };
12150
- const write = deps.write ?? import_promises.writeFile;
12151
- const remove2 = deps.remove ?? import_promises.unlink;
12152
- const ensureDir = deps.ensureDir ?? import_promises.mkdir;
12153
- const dir = deps.dir ?? (0, import_node_os3.tmpdir)();
12154
- const file = (0, import_node_path14.join)(dir, `mmi-gh-body-${process.pid}-${(0, import_node_crypto3.randomBytes)(4).toString("hex")}.md`);
12155
- await ensureDir((0, import_node_path14.dirname)(file), { recursive: true }).catch(() => {
12156
- });
12157
- await write(file, args[i + 1], "utf8");
12158
- return {
12159
- args: [...args.slice(0, i), "--body-file", file, ...args.slice(i + 2)],
12160
- cleanup: async () => {
12161
- try {
12162
- await remove2(file);
12163
- } catch {
12164
- }
12165
- }
12166
- };
12167
- }
12168
- function buildAddToProjectArgs(projectId, contentId) {
12169
- if (!projectId) throw new Error("addToProject: projectId is required");
12170
- if (!contentId) throw new Error("addToProject: contentId is required");
12171
- return [
12172
- "api",
12173
- "graphql",
12174
- "-f",
12175
- "query=mutation($p:ID!,$c:ID!){addProjectV2ItemById(input:{projectId:$p,contentId:$c}){item{id}}}",
12176
- "-f",
12177
- `p=${projectId}`,
12178
- "-f",
12179
- `c=${contentId}`
12180
- ];
12181
- }
12182
- function parseAddedItemId(stdout) {
12183
- try {
12184
- return JSON.parse(stdout)?.data?.addProjectV2ItemById?.item?.id || void 0;
12185
- } catch {
12186
- return void 0;
12187
- }
12188
- }
12189
- function isAlreadyOnBoardError(stderr) {
12190
- return /already exists in (?:this|the) project/i.test(stderr);
12191
- }
12192
- var CLOSING_KEYWORD = "clos(?:e|es|ed)|fix(?:es|ed)?|resolve(?:s|d)?";
12193
- var CLOSING_CHAIN = new RegExp(
12194
- `\\b(${CLOSING_KEYWORD})(\\s+)#(\\d+)((?:\\s*(?:,|,?\\s+and)\\s*#\\d+)+)`,
12195
- "gi"
12196
- );
12197
- function normalizeClosingDirectives(body) {
12198
- if (!body.includes("#")) return body;
12199
- const segments = body.split(/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]*`)/g);
12200
- return segments.map(
12201
- (seg, i) => i % 2 === 1 ? seg : seg.replace(
12202
- CLOSING_CHAIN,
12203
- (_m, kw, ws, first, tail) => `${kw}${ws}#${first}${tail.replace(/#(\d+)/g, "closes #$1")}`
12204
- )
12205
- ).join("");
12206
- }
12207
- function buildPrArgs({ title, body, base, head, repo, draft }) {
12208
- const args = ["pr", "create"];
12209
- if (repo) args.push("--repo", repo);
12210
- args.push("--title", title, "--body", body);
12211
- if (base) args.push("--base", base);
12212
- if (head) args.push("--head", head);
12213
- if (draft) args.push("--draft");
12214
- return args;
12215
- }
12216
- function parseExistingPr(stderr) {
12217
- if (!/already exists/i.test(stderr)) return void 0;
12218
- const match = /https:\/\/\S*\/pull\/(\d+)/.exec(stderr);
12219
- if (!match) return void 0;
12220
- return { number: Number(match[1]), url: match[0] };
12221
- }
12222
- var GH_CREATE_UPSTREAM_RETRIES = 3;
12223
- var GH_CREATE_RETRY_BACKOFF_MS = [2e3, 5e3];
12224
- function httpStatusCodes(stderr) {
12225
- const out = [];
12226
- for (const m of stderr.matchAll(/\bHTTP\/?\d*(?:\.\d)?\s+(\d{3})\b/g)) out.push(Number(m[1]));
12227
- for (const m of stderr.matchAll(/\b(\d{3})\s+Internal Server Error\b/g)) out.push(Number(m[1]));
12228
- return out;
12229
- }
12230
- function isUpstreamGitHubFault(stderr) {
12231
- const codes = httpStatusCodes(stderr);
12232
- if (codes.some((c) => c >= 400 && c < 500)) return false;
12233
- if (codes.some((c) => c >= 500 && c < 600)) return true;
12234
- return /Something went wrong while executing your query/.test(stderr) || /^\s*unexpected end of JSON input\s*$/m.test(stderr);
12235
- }
12236
- function mayRetryCreate(verb) {
12237
- return verb === "pr";
12238
- }
12239
- function upstreamFaultMessage(verb, stderr) {
12240
- const requestId = /\b([0-9A-F]{4}:[0-9A-F]{4,6}:[0-9A-F]+:[0-9A-F]+:[0-9A-F]+)\b/.exec(stderr)?.[1];
12241
- const advice = mayRetryCreate(verb) ? "Retrying is the right action" : "The write may have completed server-side before the error \u2014 VERIFY before retrying, or a retry will duplicate it";
12242
- return `gh ${verb} create failed: GitHub's API is failing (server-side 5xx) \u2014 this is NOT a problem with your diff or arguments. GitHub request ID: ${requestId ?? "unavailable"}. ${advice}; note githubstatus.com may still read green while this is happening.`;
12243
- }
12244
- async function ghCreate(args, deps = {}) {
12245
- const exec = deps.exec ?? execFileP2;
12246
- const sleep3 = deps.sleep ?? ((ms) => new Promise((resolve5) => setTimeout(resolve5, ms)));
12247
- const swapped = await bodyArgsViaFile(args);
12248
- try {
12249
- for (let attempt = 1; attempt <= GH_CREATE_UPSTREAM_RETRIES; attempt++) {
12250
- try {
12251
- const { stdout } = await exec("gh", swapped.args, { timeout: GH_MUTATION_TIMEOUT_MS });
12252
- return parseCreatedUrl(stdout);
12253
- } catch (e) {
12254
- const err = e;
12255
- const errText = `${err.stderr ?? ""}
12256
- ${err.message ?? ""}`;
12257
- const faultText = (err.stderr ?? "").trim() ? err.stderr : err.message ?? "";
12258
- const existing = args[0] === "pr" ? parseExistingPr(errText) : void 0;
12259
- if (existing) {
12260
- await swapped.cleanup();
12261
- console.warn(`${args[0]} create: a PR already exists for this branch \u2014 #${existing.number} (nothing to do)`);
12262
- return { ...existing, existing: true };
12263
- }
12264
- const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
12265
- if (isUpstreamGitHubFault(faultText) && mayRetryCreate(args[0]) && attempt < GH_CREATE_UPSTREAM_RETRIES) {
12266
- const waitMs = GH_CREATE_RETRY_BACKOFF_MS[attempt - 1];
12267
- console.warn(`gh ${args[0]} create: GitHub API server fault (attempt ${attempt}/${GH_CREATE_UPSTREAM_RETRIES}) \u2014 retrying in ${waitMs}ms`);
12268
- await sleep3(waitMs);
12269
- continue;
12270
- }
12271
- await swapped.cleanup();
12272
- if (isUpstreamGitHubFault(faultText)) return fail(upstreamFaultMessage(args[0], faultText));
12273
- return fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
12274
- }
12275
- }
12276
- throw new Error("ghCreate: retry loop exhausted without a verdict");
12277
- } finally {
12278
- await swapped.cleanup();
12279
- }
12280
- }
12281
-
12282
12346
  // src/issue-body.ts
12283
12347
  var import_node_os4 = require("node:os");
12284
12348
  var TextArgError = class extends Error {
@@ -12684,6 +12748,32 @@ function withExamples(cmd, examples, note) {
12684
12748
  cmd.addHelpText("after", lines.join("\n") + "\n");
12685
12749
  return cmd;
12686
12750
  }
12751
+ var DECLARED_OPTIONS = /* @__PURE__ */ Symbol.for("mmi.commandManifest.declaredOptions");
12752
+ function withDeclaredOptions(cmd, options, footer) {
12753
+ cmd[DECLARED_OPTIONS] = options;
12754
+ const width = Math.max(...options.map((o) => o.flags.length));
12755
+ const lines = ["", "Options:", ...options.map((o) => ` ${o.flags.padEnd(width)} ${o.description}`)];
12756
+ if (footer?.length) lines.push("", ...footer);
12757
+ cmd.addHelpText("after", lines.join("\n") + "\n");
12758
+ return cmd;
12759
+ }
12760
+ function readDeclaredOptions(cmd) {
12761
+ const declared = cmd[DECLARED_OPTIONS];
12762
+ if (!Array.isArray(declared)) return [];
12763
+ return declared.map((o) => {
12764
+ const takesValue = /[<[]/.test(o.flags);
12765
+ return {
12766
+ flags: o.flags,
12767
+ description: o.description,
12768
+ mandatory: false,
12769
+ valueRequired: /</.test(o.flags),
12770
+ optional: /\[/.test(o.flags),
12771
+ takesValue,
12772
+ variadic: o.flags.includes("..."),
12773
+ negate: o.flags.includes("--no-")
12774
+ };
12775
+ });
12776
+ }
12687
12777
  function readExamples(cmd) {
12688
12778
  const ex = cmd[EXAMPLES];
12689
12779
  return Array.isArray(ex) && ex.length ? ex : void 0;
@@ -12723,12 +12813,14 @@ function buildCommand(cmd, path2) {
12723
12813
  name: cmd.name(),
12724
12814
  path: path2,
12725
12815
  arguments: cmd.registeredArguments.map(buildArgument),
12726
- options: cmd.options.map(buildOption),
12816
+ // A hand-parsed command registers no Commander options, so its declared set is merged in (#3682).
12817
+ options: [...cmd.options.map(buildOption), ...readDeclaredOptions(cmd)],
12727
12818
  subcommands: cmd.commands.map(
12728
12819
  (child2) => buildCommand(child2, path2 ? `${path2} ${child2.name()}` : child2.name())
12729
12820
  ),
12730
12821
  ...metadata
12731
12822
  };
12823
+ if (cmd._allowUnknownOption) out.parses_own_argv = true;
12732
12824
  const description = cmd.description();
12733
12825
  if (description) out.description = description;
12734
12826
  const examples = readExamples(cmd);
@@ -16021,6 +16113,22 @@ async function auditTrainBranch(repo, branch, owners, deps, projectAdmins = /* @
16021
16113
  restrictions = null;
16022
16114
  }
16023
16115
  if (!restrictions) {
16116
+ let branchExists = true;
16117
+ try {
16118
+ branchExists = Boolean(await deps.client.rest("GET", `repos/${repo}/branches/${branch}`));
16119
+ } catch {
16120
+ branchExists = false;
16121
+ }
16122
+ if (!branchExists) {
16123
+ return [{
16124
+ repo,
16125
+ branch,
16126
+ kind: "train-branch-missing",
16127
+ severity: "medium",
16128
+ detail: `${branch} is in this repo's locked train set but does not exist \u2014 either the branch was never created, or the repo's releaseTrack does not actually include it`,
16129
+ remediation: `create it: gh api -X POST repos/${repo}/git/refs -f ref=refs/heads/${branch} -f sha=<development sha>, then initialize the lock (docs/Guides/repo-access.md). If the repo has no ${branch} stage, set its real track instead: mmi-cli org project set --var releaseTrack=<direct|trunk>`
16130
+ }];
16131
+ }
16024
16132
  return [{
16025
16133
  repo,
16026
16134
  branch,
@@ -18124,17 +18232,7 @@ function registerSecretsCommands(program3) {
18124
18232
  if (!ok) process.exitCode = 1;
18125
18233
  }));
18126
18234
  secrets.command("rm <key>").description("remove a secret from your own full project vault; org-infra requires master or an exact grant. --slug _org removes an org-infra <provider>/<KEY> value (#3315)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--slug <slug>", "org-infra namespace (e.g. _org) for the value removal \u2014 master or an exact grant (#3315)").action((key, o) => withSecrets((d) => secretsRemove(d, key, o)));
18127
- secrets.command("use").usage("<key> | --key <key> [--key <key>\u2026] [-- <command>] [--repo <owner/repo>] [--slug <slug>] [--name <ENVVAR>]").argument("[args...]", "one secret key (or use --key), then `--` and the command to run").description("consume secrets KEYLESS: own-project full tree or exactly granted org-infra keys; injects each into the command env and never prints one. ONE positional key, or several via repeated `--key` (#3561). The wrapped command keeps its OWN flags (`-- node -e \u2026`, `-- curl -H \u2026`) with or without the `--`, because npm's PowerShell shim swallows `--` before the CLI sees it (#3436)").allowUnknownOption().helpOption(false).addHelpText("after", [
18128
- "",
18129
- "Options:",
18130
- " --key <key> a secret key; repeat for several. The only form no shell can misread \u2014",
18131
- " `npm` and `slack/token` are BOTH valid key names and plausible commands.",
18132
- " --repo <owner/repo> target repo (defaults to the current repo)",
18133
- " --slug <slug> org-infra namespace (e.g. _org) for a granted org secret",
18134
- " --name <ENVVAR> env var to inject under, single key only (default: the key leaf, UPPER_SNAKE)",
18135
- "",
18136
- "These belong BEFORE the `--`. Everything after it is the wrapped command, untouched."
18137
- ].join("\n")).action(function() {
18235
+ secrets.command("use").usage("<key> | --key <key> [--key <key>\u2026] [-- <command>] [--repo <owner/repo>] [--slug <slug>] [--name <ENVVAR>]").argument("[args...]", "one secret key (or use --key), then `--` and the command to run").description("consume secrets KEYLESS: own-project full tree or exactly granted org-infra keys; injects each into the command env and never prints one. ONE positional key, or several via repeated `--key` (#3561). The wrapped command keeps its OWN flags (`-- node -e \u2026`, `-- curl -H \u2026`) with or without the `--`, because npm's PowerShell shim swallows `--` before the CLI sees it (#3436)").allowUnknownOption().helpOption(false).action(function() {
18138
18236
  const parsed = parseSecretsUseArgv(secretsUseTail(process.argv));
18139
18237
  if (parsed.kind === "help") return this.outputHelp();
18140
18238
  return withSecrets(async (d) => {
@@ -18148,6 +18246,16 @@ function registerSecretsCommands(program3) {
18148
18246
  if (ok === false) process.exitCode = 1;
18149
18247
  });
18150
18248
  });
18249
+ withDeclaredOptions(
18250
+ secrets.commands.find((c) => c.name() === "use"),
18251
+ [
18252
+ { flags: "--key <key>", description: "a secret key; repeat for several. The only form no shell can misread \u2014 `npm` and `slack/token` are BOTH valid key names and plausible commands." },
18253
+ { flags: "--repo <owner/repo>", description: "target repo (defaults to the current repo)" },
18254
+ { flags: "--slug <slug>", description: "org-infra namespace (e.g. _org) for a granted org secret" },
18255
+ { flags: "--name <ENVVAR>", description: "env var to inject under, single key only (default: the key leaf, UPPER_SNAKE)" }
18256
+ ],
18257
+ ["These belong BEFORE the `--`. Everything after it is the wrapped command, untouched."]
18258
+ );
18151
18259
  secrets.command("grant <repo> <login> <key>").description("MASTER-ONLY: grant a project-admin standing access to an org-infra secret. Default is read/write on one exact key; --read grants keyless USE only, and only --read accepts a wildcard key (`*` = the whole namespace, `<provider>/*` = one provider group) (#3652)").option("--read", "read-only: permits keyless `secrets use`, never `set`/`rm`. Required for a wildcard key.").action((repo, login, key, o) => withSecrets((d) => secretsGrant(d, repo, login, key, { read: o.read })));
18152
18260
  secrets.command("revoke <repo> <login> <key>").description("MASTER-ONLY: withdraw a previously granted org-infra secret access (pass the grant key exactly as granted, wildcard included)").action((repo, login, key) => withSecrets((d) => secretsRevoke(d, repo, login, key, {})));
18153
18261
  }
@@ -19901,7 +20009,7 @@ function registerQueryCommands(program3) {
19901
20009
  const pr2 = program3.commands.find((c) => c.name() === "pr");
19902
20010
  if (!issue2 || !pr2) return;
19903
20011
  const deps = queryDeps();
19904
- issue2.command("list").description("bounded issue read \u2014 filter by label/state/assignee (NO free-text search); always prints JSON").option("--label <label>", "filter by label").option("--state <state>", "open | closed | all", "open").option("--assignee <login>", "filter by assignee login").option("--limit <n>", "max issues to return (capped at 100)", (v) => Number(v), DEFAULT_LIMIT).option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output (already the default \u2014 accepted for contract uniformity)").action(async (o) => {
20012
+ issue2.command("list").description("bounded issue read \u2014 filter by label/state/assignee (NO free-text search); always prints JSON").option("--label <label>", "filter by label").addOption(new Option("--state <state>", "open | closed | all").default("open").choices(ISSUE_STATES)).option("--assignee <login>", "filter by assignee login").option("--limit <n>", "max issues to return (capped at 100)", (v) => Number(v), DEFAULT_LIMIT).option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output (already the default \u2014 accepted for contract uniformity)").action(async (o) => {
19905
20013
  try {
19906
20014
  const state = validateEnum("--state", ISSUE_STATES, o.state, "issue list");
19907
20015
  const rows = await runIssueList(deps, { label: o.label, state, assignee: o.assignee, limit: o.limit, repo: o.repo });
@@ -19920,7 +20028,7 @@ function registerQueryCommands(program3) {
19920
20028
  queryFail("issue children", e);
19921
20029
  }
19922
20030
  });
19923
- pr2.command("list").description("bounded PR read \u2014 --mine scopes to the viewer's own PRs (resolves the viewer via gh); always prints JSON").option("--mine", "only the caller's own PRs (resolves the viewer via `gh api user`)").option("--state <state>", "open | all", "open").option("--limit <n>", "max PRs to return (capped at 100)", (v) => Number(v), DEFAULT_LIMIT).option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output (already the default \u2014 accepted for contract uniformity)").action(async (o) => {
20031
+ pr2.command("list").description("bounded PR read \u2014 --mine scopes to the viewer's own PRs (resolves the viewer via gh); always prints JSON").option("--mine", "only the caller's own PRs (resolves the viewer via `gh api user`)").addOption(new Option("--state <state>", "open | all").default("open").choices(PR_STATES)).option("--limit <n>", "max PRs to return (capped at 100)", (v) => Number(v), DEFAULT_LIMIT).option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output (already the default \u2014 accepted for contract uniformity)").action(async (o) => {
19924
20032
  try {
19925
20033
  const state = validateEnum("--state", PR_STATES, o.state, "pr list");
19926
20034
  const rows = await runPrList(deps, { mine: o.mine, state, limit: o.limit, repo: o.repo });
@@ -20071,6 +20179,21 @@ async function rulesetDetails2(deps, repo, list) {
20071
20179
  }
20072
20180
  return details;
20073
20181
  }
20182
+ async function orgRulesetExclusion(deps, repo) {
20183
+ const [owner, name] = repo.split("/");
20184
+ if (!owner || !name) return null;
20185
+ const list = await restJson3(deps, `orgs/${owner}/rulesets`, []);
20186
+ const excluding = [];
20187
+ for (const ruleset of list) {
20188
+ if (ruleset.id == null || ruleset.target !== "branch" || ruleset.enforcement !== "active") continue;
20189
+ const full = await restJson3(deps, `orgs/${owner}/rulesets/${ruleset.id}`, null);
20190
+ const excluded = full?.conditions?.repository_name?.exclude ?? [];
20191
+ if (excluded.some((entry) => entry.toLowerCase() === name.toLowerCase())) {
20192
+ excluding.push(full?.name ?? String(ruleset.id));
20193
+ }
20194
+ }
20195
+ return excluding.length ? excluding.join(", ") : null;
20196
+ }
20074
20197
  function repoRefsMatch(a, b) {
20075
20198
  return a.toLowerCase() === b.toLowerCase();
20076
20199
  }
@@ -20134,11 +20257,12 @@ async function verifyBootstrap(repo, repoClass, deps, releaseTrack) {
20134
20257
  });
20135
20258
  try {
20136
20259
  const owners = new Set(await resolveOwners(deps));
20137
- const overgrants = await auditRepoCollaborators(repo, owners, deps);
20260
+ const sanctioned = new Set(deps.sanctionedAdmins ?? []);
20261
+ const overgrants = (await auditRepoCollaborators(repo, owners, deps, /* @__PURE__ */ new Set(), sanctioned)).filter((f) => f.kind === "collaborator-overgrant");
20138
20262
  checks.push({
20139
20263
  ok: overgrants.length === 0,
20140
20264
  label: "collaborator roles are master-only (no admin/maintain over-grant)",
20141
- detail: overgrants.length ? `over-granted: ${overgrants.map((f) => f.actor).join(", ")}` : void 0
20265
+ detail: overgrants.length ? `over-granted: ${overgrants.map((f) => f.actor).join(", ")}` : sanctioned.size ? `sanctioned admin: ${[...sanctioned].join(", ")} (access-matrix.json, an owner decision)` : void 0
20142
20266
  });
20143
20267
  } catch {
20144
20268
  }
@@ -20314,10 +20438,11 @@ async function verifyBootstrap(repo, repoClass, deps, releaseTrack) {
20314
20438
  );
20315
20439
  const orgRuleset = activeOrgRulesets.find((ruleset) => missingRuleTypes(ruleset, requiredOrgRulesetTypes).length === 0);
20316
20440
  const missingOrgRuleTypes = activeOrgRulesets.length === 0 ? requiredOrgRulesetTypes : missingRuleTypes(activeOrgRulesets[0], requiredOrgRulesetTypes);
20441
+ const excludedByOrgRuleset = orgRuleset ? null : await orgRulesetExclusion(deps, repo);
20317
20442
  checks.push({
20318
- ok: Boolean(orgRuleset),
20443
+ ok: Boolean(orgRuleset) || Boolean(excludedByOrgRuleset),
20319
20444
  label: "covered by an active org ruleset",
20320
- detail: orgRuleset ? void 0 : activeOrgRulesets.length === 0 ? "no active Organization-sourced branch ruleset targets this repo" : `missing rule types: ${missingOrgRuleTypes.join(", ")}`
20445
+ detail: orgRuleset ? void 0 : excludedByOrgRuleset ? `deliberately excluded by name from ${excludedByOrgRuleset} \u2014 an owner decision, not drift (org-architecture.md \xA74)` : activeOrgRulesets.length === 0 ? "no active Organization-sourced branch ruleset targets this repo" : `missing rule types: ${missingOrgRuleTypes.join(", ")}`
20321
20446
  });
20322
20447
  if (repo === HUB_REPO4) {
20323
20448
  const statusChecks = rulesetStatusChecks2(rulesets.filter((r) => r.target === "branch" && r.enforcement === "active"));
@@ -20466,13 +20591,13 @@ async function fetchOrgNoAgentFilesRuleset(client, org = ORG_LOGIN) {
20466
20591
 
20467
20592
  // src/bootstrap-commands.ts
20468
20593
  function registerBootstrapCommands(program3) {
20469
- const bootstrap = program3.command("bootstrap").description("plan repo bootstrap operations; mutations require master-admin approval").option("--repo <owner/repo>", "target repo").option("--class <class>", "deployable | content", "deployable").option("--json", "machine-readable output").action((o) => {
20594
+ const bootstrap = program3.command("bootstrap").description("plan repo bootstrap operations; mutations require master-admin approval").option("--repo <owner/repo>", "target repo").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).option("--json", "machine-readable output").action((o) => {
20470
20595
  if (!o.repo) return fail("bootstrap: required option --repo <owner/repo> not specified");
20471
20596
  if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap: --class must be deployable or content");
20472
20597
  const steps = bootstrapPlan(o.repo, o.class);
20473
20598
  console.log(o.json ? JSON.stringify({ command: "bootstrap", repo: o.repo, class: o.class, steps }, null, 2) : renderSteps(`mmi-cli bootstrap: dry-run plan for ${o.repo}`, steps));
20474
20599
  });
20475
- bootstrap.command("verify <repo>").description("audit whether an existing repo is bootstrapped correctly; no mutations").option("--class <class>", "deployable | content", "deployable").option("--json", "machine-readable output").action(async (repo) => {
20600
+ bootstrap.command("verify <repo>").description("audit whether an existing repo is bootstrapped correctly; no mutations").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).option("--json", "machine-readable output").action(async (repo) => {
20476
20601
  const o = { class: rawValue("--class", "deployable"), json: rawFlag("--json") };
20477
20602
  if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
20478
20603
  const cfg = await loadConfig();
@@ -20487,6 +20612,10 @@ function registerBootstrapCommands(program3) {
20487
20612
  readLocalFile: (path2) => path2 === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs22.existsSync)(path2) ? (0, import_node_fs22.readFileSync)(path2, "utf8") : null,
20488
20613
  // requiredGcpApis is stored as an array by a JSON write, but `org project set --var KEY=VALUE` stores a raw
20489
20614
  // comma-string — accept either so the seeded value verifies regardless of how it was written.
20615
+ // #3689: the same committed map the org access audit reads (#3664), so a sanctioned admin is not a
20616
+ // permanent bootstrap failure on one surface and an intended state on the other. Absent file → no
20617
+ // sanction, which is the pre-#3664 behaviour.
20618
+ sanctionedAdmins: (0, import_node_fs22.existsSync)("access-matrix.json") ? entriesValueByCanonicalRepo(loadSanctionedAdmins((0, import_node_fs22.readFileSync)("access-matrix.json", "utf8")), repo) : void 0,
20490
20619
  requiredGcpApis: (() => {
20491
20620
  const v = meta?.requiredGcpApis;
20492
20621
  if (Array.isArray(v)) return v;
@@ -20521,7 +20650,7 @@ function registerBootstrapCommands(program3) {
20521
20650
  else console.log(renderOrgRulesetDriftReport(plan));
20522
20651
  if (plan.action !== "noop") process.exitCode = 1;
20523
20652
  });
20524
- bootstrap.command("apply <repo>").description("run from the MMI-Hub repo root: idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").option("--class <class>", "deployable | content", "deployable").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path)`).option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo, cmdOpts) => {
20653
+ bootstrap.command("apply <repo>").description("run from the MMI-Hub repo root: idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).addOption(new Option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (capability shape)`).choices([...PROJECT_TYPES])).addOption(new Option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path)`).choices([...DEPLOY_MODELS])).addOption(new Option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).choices([...RELEASE_TRACKS])).option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo, cmdOpts) => {
20525
20654
  const o = {
20526
20655
  class: rawValue("--class", "deployable"),
20527
20656
  projectType: rawValue("--project-type", ""),
@@ -20531,6 +20660,9 @@ function registerBootstrapCommands(program3) {
20531
20660
  json: rawFlag("--json")
20532
20661
  };
20533
20662
  if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap apply: --class must be deployable or content");
20663
+ if (o.releaseTrack && !isReleaseTrack(o.releaseTrack)) {
20664
+ return fail(`bootstrap apply: --release-track must be one of: ${RELEASE_TRACKS.join(", ")}`);
20665
+ }
20534
20666
  const bootstrapReleaseTrack = resolveBootstrapReleaseTrack(o.class, o.releaseTrack || void 0);
20535
20667
  let parsedRepo;
20536
20668
  try {
@@ -20718,8 +20850,17 @@ function registerBootstrapCommands(program3) {
20718
20850
  const rulesetContent = resolveSeedContent({ ...rulesetSeed, target: rulesetSeed.target.replace("{{REPO_SLUG}}", slug) }, vars, readFile7);
20719
20851
  if (rulesetContent) {
20720
20852
  try {
20721
- const activation = await activateProductRuleset(repo, stripRulesetComment(rulesetContent), defaultGitHubClient());
20722
- applied.push(`product ruleset: ${activation.action}${activation.detail ? ` (${activation.detail})` : ""}`);
20853
+ const client = defaultGitHubClient();
20854
+ const proven = await gateIsProvenGreen(repo, client, baseBranch);
20855
+ const activation = await activateProductRuleset(
20856
+ repo,
20857
+ stripRulesetComment(rulesetContent),
20858
+ client,
20859
+ proven ? "active" : "disabled"
20860
+ );
20861
+ applied.push(
20862
+ `product ruleset: ${activation.action} (${activation.enforcement}${activation.detail ? `; ${activation.detail}` : ""})` + (proven ? "" : ` \u2014 the gate is not green on ${baseBranch}, so it is NOT enforcing. Activate once it is: mmi-cli ci reconcile --apply --repo ${repo}`)
20863
+ );
20723
20864
  } catch (e) {
20724
20865
  return failGraceful(`bootstrap apply: product ruleset activation failed: ${e.message}`);
20725
20866
  }
@@ -24330,7 +24471,7 @@ function findCommandInManifest(manifest, commandPath3) {
24330
24471
  return visit(manifest.tree);
24331
24472
  }
24332
24473
  function registerExplainCommand(program3) {
24333
- program3.command("explain").description("print a command's purpose, flags, and examples from the live schema \u2014 one grounding call replaces trial-and-error").argument("[command...]", 'the command path to explain (e.g. "issue create")').option("--loop <name>", "print the canonical command sequence: agent | start-work | ship-pr | hotfix").action((commandArgs, opts) => {
24474
+ program3.command("explain").description("print a command's purpose, flags, and examples from the live schema \u2014 one grounding call replaces trial-and-error").argument("[command...]", 'the command path to explain (e.g. "issue create")').addOption(new Option("--loop <name>", "print the canonical command sequence: agent | start-work | ship-pr | hotfix").choices(Object.keys(LOOP_PLAYBOOKS))).action((commandArgs, opts) => {
24334
24475
  if (opts.loop) {
24335
24476
  if (!LOOP_PLAYBOOKS[opts.loop]) {
24336
24477
  const valid = Object.keys(LOOP_PLAYBOOKS).join(", ");
@@ -25069,7 +25210,17 @@ function checkClaudePlugin(probe) {
25069
25210
  verbose: evidence
25070
25211
  };
25071
25212
  }
25072
- if (!released) return null;
25213
+ if (!released) {
25214
+ return {
25215
+ id: "claude-plugin",
25216
+ ok: false,
25217
+ reportOnly: true,
25218
+ label: "Claude plugin",
25219
+ detail: `${installed ?? "installed"} \u2014 freshness UNKNOWN, the published version could not be read`,
25220
+ fix: "check it directly: `npm view @mutmutco/cli version`, then `mmi-cli plugin heal` if behind",
25221
+ verbose: evidence
25222
+ };
25223
+ }
25073
25224
  return { id: "claude-plugin", ok: true, label: "Claude plugin", ...installed ? { detail: installed } : {}, verbose: evidence };
25074
25225
  }
25075
25226
  function checkCliVersion(input, releasedNote) {
@@ -25078,7 +25229,17 @@ function checkCliVersion(input, releasedNote) {
25078
25229
  `running: ${report.currentVersion}`,
25079
25230
  `published: ${report.releasedVersion ?? "(not checked \u2014 offline or --fast)"}${report.releasedVersion && releasedNote ? ` ${releasedNote}` : ""}`
25080
25231
  ];
25081
- if (!report.releasedVersion) return null;
25232
+ if (!report.releasedVersion) {
25233
+ return {
25234
+ id: "cli-version",
25235
+ ok: false,
25236
+ reportOnly: true,
25237
+ label: "mmi-cli",
25238
+ detail: `${report.currentVersion} \u2014 freshness UNKNOWN, the published version could not be read`,
25239
+ fix: "check it directly: `mmi-cli --version` against `npm view @mutmutco/cli version`; update with `npm install -g @mutmutco/cli@<released>`",
25240
+ verbose: evidence
25241
+ };
25242
+ }
25082
25243
  if (report.ok) return { id: "cli-version", ok: true, label: "mmi-cli", detail: report.currentVersion, verbose: evidence };
25083
25244
  return {
25084
25245
  id: "cli-version",
@@ -25916,11 +26077,20 @@ var lastParseErrorKind = "other";
25916
26077
  var lastUnknownCommand;
25917
26078
  function classifyParseError(plain) {
25918
26079
  if (/unknown command/i.test(plain)) return "unknown-command";
25919
- if (/unknown option|missing required argument|too many arguments|argument missing/i.test(plain)) {
26080
+ if (/unknown option|missing required argument|too many arguments|argument missing|is invalid\. Allowed choices are/i.test(plain)) {
25920
26081
  return "bad-arguments";
25921
26082
  }
25922
26083
  return "other";
25923
26084
  }
26085
+ function parseInvalidChoiceError(plain) {
26086
+ const m = /option '([^']+)' argument '([^']*)' is invalid\. Allowed choices are ([^.]+)\./.exec(plain);
26087
+ if (!m) return void 0;
26088
+ return {
26089
+ flag: m[1].split(/[ ,|]/)[0],
26090
+ value: m[2],
26091
+ expected: m[3].split(",").map((s) => s.trim()).filter(Boolean)
26092
+ };
26093
+ }
25924
26094
  function resolveCommandFromArgv(root, argv) {
25925
26095
  let current = root;
25926
26096
  for (const token of argv) {
@@ -25982,6 +26152,22 @@ function envelopeAwareWriteErr(str) {
25982
26152
  }
25983
26153
  lastParseErrorKind = classifyParseError(plain);
25984
26154
  lastUnknownCommand = /unknown command '?([\w:-]+)'?/.exec(plain)?.[1];
26155
+ const badChoice = parseInvalidChoiceError(plain);
26156
+ if (badChoice) {
26157
+ if (!argvWantsJson2()) {
26158
+ process.stderr.write(str);
26159
+ return;
26160
+ }
26161
+ process.stderr.write(
26162
+ formatErrorEnvelope(`${badChoice.flag}: unknown value "${badChoice.value}"`, {
26163
+ code: ERROR_CODES.ERR_BAD_ENUM,
26164
+ offending_flag: badChoice.flag,
26165
+ expected: badChoice.expected
26166
+ }) + "\n"
26167
+ );
26168
+ unknownFlagJsonHandled = true;
26169
+ return;
26170
+ }
25985
26171
  const match = /unknown option '([^']+)'/.exec(plain);
25986
26172
  if (match) {
25987
26173
  const flag = match[1];
@@ -26568,7 +26754,7 @@ tests.command("policy").description("enforce this repo's test-policy.json agains
26568
26754
  }
26569
26755
  });
26570
26756
  var docsAudit = program2.command("docs-audit").description("the docs janitor verdict ledger \u2014 record a dated run verdict, or read the dead-man status back");
26571
- docsAudit.command("record").description("write a dated janitor verdict for one repo to the registry ledger (master-only server-side)").option("--repo <owner/name>", "the repo the verdict is for (default: the current repo)").option("--date <YYYY-MM-DD>", "the ISO day the run examined (default: today)").requiredOption("--sha-range <a..b>", "the git range the janitor read (e.g. <lastVerdictSha>..HEAD)").requiredOption("--outcome <kind>", "clean | refreshed | failed").option("--count <n>", "docs refreshed (required when --outcome refreshed)").option("--reason <text>", "why the run failed (required when --outcome failed)").requiredOption("--checker-vendor <vendor>", "which vendor's model actually ran the check").action(async (o) => {
26757
+ docsAudit.command("record").description("write a dated janitor verdict for one repo to the registry ledger (master-only server-side)").option("--repo <owner/name>", "the repo the verdict is for (default: the current repo)").option("--date <YYYY-MM-DD>", "the ISO day the run examined (default: today)").requiredOption("--sha-range <a..b>", "the git range the janitor read (e.g. <lastVerdictSha>..HEAD)").addOption(new Option("--outcome <kind>", "clean | refreshed | failed").makeOptionMandatory().choices(["clean", "refreshed", "failed"])).option("--count <n>", "docs refreshed (required when --outcome refreshed)").option("--reason <text>", "why the run failed (required when --outcome failed)").requiredOption("--checker-vendor <vendor>", "which vendor's model actually ran the check").action(async (o) => {
26572
26758
  try {
26573
26759
  const repo = o.repo ?? await currentRepoFullName();
26574
26760
  const date = o.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
@@ -26751,7 +26937,7 @@ deploys run centrally (tenant-deploy.yml); product repos carry no deploy files.
26751
26937
  }
26752
26938
  });
26753
26939
  var projectDeploy = project.command("deploy").description("read nonsecret DEPLOY# facts (domain, port, deploy path, substrate, host presence)");
26754
- projectDeploy.command("get [owner/repo]").description("read nonsecret DEPLOY# facts for one project; defaults to the current repo").option("--stage <stage>", "dev | rc | main").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
26940
+ projectDeploy.command("get [owner/repo]").description("read nonsecret DEPLOY# facts for one project; defaults to the current repo").addOption(new Option("--stage <stage>", "dev | rc | main").choices(["dev", "rc", "main"])).option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
26755
26941
  const cfg = await loadConfig();
26756
26942
  let target;
26757
26943
  try {
@@ -26766,7 +26952,7 @@ projectDeploy.command("get [owner/repo]").description("read nonsecret DEPLOY# fa
26766
26952
  const payload = stage ? { slug: out.slug, stage, deploy: out.stages[stage] ?? null } : out;
26767
26953
  console.log(JSON.stringify(payload));
26768
26954
  });
26769
- project.command("set [owner/repo]").description("upsert project META (idempotent merge; master-gated)").option("--class <class>", "deployable | content").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).option("--var <KEY=VALUE...>", settableVarHelp()).option("--set <KEY=VALUE...>", "alias of --var (one KEY=VALUE per flag; repeat the flag to set several)").option("--secrets-file <path>", 'read the #2244 secrets catalog map (JSON) from a file and merge it per entry into the existing catalog (clear all with --unset secrets). SHAPE: a JSON object keyed by env name, each entry {"key":"UPPER_SNAKE" (repeat the env name), "purpose":"<non-empty>", "group":"<e.g. auth|database>", "owner":"<github login>", "stages":[] (empty = the one stageless shared value; else any of dev/rc/main), "consumers":["runtime"|"box"|\u2026], "provider":"<optional>"}').option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, requiredBuildSecrets, secrets, edgeDomains, requiredGcpApis, publishRequired").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
26955
+ project.command("set [owner/repo]").description("upsert project META (idempotent merge; master-gated)").addOption(new Option("--class <class>", "deployable | content").choices(["deployable", "content"])).addOption(new Option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (capability shape)`).choices([...PROJECT_TYPES])).addOption(new Option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).choices([...DEPLOY_MODELS])).addOption(new Option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).choices([...RELEASE_TRACKS])).option("--var <KEY=VALUE...>", settableVarHelp()).option("--set <KEY=VALUE...>", "alias of --var (one KEY=VALUE per flag; repeat the flag to set several)").option("--secrets-file <path>", 'read the #2244 secrets catalog map (JSON) from a file and merge it per entry into the existing catalog (clear all with --unset secrets). SHAPE: a JSON object keyed by env name, each entry {"key":"UPPER_SNAKE" (repeat the env name), "purpose":"<non-empty>", "group":"<e.g. auth|database>", "owner":"<github login>", "stages":[] (empty = the one stageless shared value; else any of dev/rc/main), "consumers":["runtime"|"box"|\u2026], "provider":"<optional>"}').option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, requiredBuildSecrets, secrets, edgeDomains, requiredGcpApis, publishRequired").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
26770
26956
  const cfg = await loadConfig();
26771
26957
  let target;
26772
26958
  try {
@@ -26880,7 +27066,7 @@ fullTrack.command("readiness <owner/repo>").description("aggregate branch topolo
26880
27066
  console.log(JSON.stringify(report, null, 2));
26881
27067
  if (!report.rcand.canApply) process.exitCode = 1;
26882
27068
  });
26883
- project.command("set-deploy [owner/repo]").description("patch a tenant DEPLOY row \u2014 project-admin may set only --no-env-file on their own existing dev/rc row; master may seed/change all coords and main; defaults to the current repo").requiredOption("--stage <stage>", "dev | rc | main").option("--ssh-host <host>", "the box address the deploy ssh-es into; omit to keep the stored value (required only for a NEW hetzner-ssh row \u2014 `mmi-cli runtime box list` finds it)").option("--ssh-user <user>", "ssh user; omit to leave the row unchanged (default root on a new row)").option("--port <port>", "loopback port the container binds / Caddy upstream (1..65535); omit to leave the row unchanged").option("--substrate <substrate>", "hetzner-ssh; omit to leave the row unchanged").option("--deploy-path <path>", "on-box per-stage release root; omit to leave the row unchanged (default /opt/mmi/<slug>/<stage> on a new row)").option("--service <name>", "systemd/compose service name; omit to leave the row unchanged (default the slug on a new row)").option("--domain <domain>", "canonical serving host; omit to leave the row unchanged").option("--alias <domain...>", "extra serving hostname the box Caddy answers (repeatable); omit to leave the stored aliases unchanged").option("--clear-aliases", "remove EVERY serving alias from the row (#2986) \u2014 omitting --alias preserves them, so clearing needs saying out loud").option("--no-env-file <bool>", "set the DEPLOY# fileless flag (true|false) \u2014 own-repo project-admin dev/rc; master main; true = env passthrough with no .env symlink").option("--force", "explicit recovery override: skip fileless-compose verification only after independent proof").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
27069
+ project.command("set-deploy [owner/repo]").description("patch a tenant DEPLOY row \u2014 project-admin may set only --no-env-file on their own existing dev/rc row; master may seed/change all coords and main; defaults to the current repo").addOption(new Option("--stage <stage>", "dev | rc | main").makeOptionMandatory().choices(["dev", "rc", "main"])).option("--ssh-host <host>", "the box address the deploy ssh-es into; omit to keep the stored value (required only for a NEW hetzner-ssh row \u2014 `mmi-cli runtime box list` finds it)").option("--ssh-user <user>", "ssh user; omit to leave the row unchanged (default root on a new row)").option("--port <port>", "loopback port the container binds / Caddy upstream (1..65535); omit to leave the row unchanged").option("--substrate <substrate>", "hetzner-ssh; omit to leave the row unchanged").option("--deploy-path <path>", "on-box per-stage release root; omit to leave the row unchanged (default /opt/mmi/<slug>/<stage> on a new row)").option("--service <name>", "systemd/compose service name; omit to leave the row unchanged (default the slug on a new row)").option("--domain <domain>", "canonical serving host; omit to leave the row unchanged").option("--alias <domain...>", "extra serving hostname the box Caddy answers (repeatable); omit to leave the stored aliases unchanged").option("--clear-aliases", "remove EVERY serving alias from the row (#2986) \u2014 omitting --alias preserves them, so clearing needs saying out loud").option("--no-env-file <bool>", "set the DEPLOY# fileless flag (true|false) \u2014 own-repo project-admin dev/rc; master main; true = env passthrough with no .env symlink").option("--force", "explicit recovery override: skip fileless-compose verification only after independent proof").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
26884
27070
  const cfg = await loadConfig();
26885
27071
  let target;
26886
27072
  try {
@@ -27077,7 +27263,7 @@ function resolveCreateType(raw, command, labels) {
27077
27263
  }
27078
27264
  var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
27079
27265
  withExamples(mutating(
27080
- issue.command("create").description("create an issue (type \u2192 label) and print {number,url,label} JSON").option("--type <type>", "bug | feature | task (sets the matching label; required unless --batch)").option("--title <title>", "issue title").option("--title-file <path|->", "read the issue title from a UTF-8 file, or from stdin with -").option("--body <body>", "issue body (markdown)").option("--body-file <path|->", "read issue body from a UTF-8 file. `-` (stdin) needs a heredoc, which the agent inline-body guard denies (#1473/#2125) \u2014 prefer a real path; a title with backticks or newlines needs --title-file for the same reason (#3381)").option("--priority <priority>", "urgent | high | medium | low (defaults to medium; sets the board Priority field only \u2014 never a priority:* label, #416)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--label <label...>", "extra label(s) to attach (repeatable; auto-created if missing)").option("--parent <ref>", "file as a native sub-issue of this parent (#123, owner/repo#123, or URL)").option("--no-related", "skip the auto related-issues comment"),
27266
+ issue.command("create").description("create an issue (type \u2192 label) and print {number,url,label} JSON").addOption(new Option("--type <type>", "bug | feature | task (sets the matching label; required unless --batch)").choices([...ISSUE_TYPES])).option("--title <title>", "issue title").option("--title-file <path|->", "read the issue title from a UTF-8 file, or from stdin with -").option("--body <body>", "issue body (markdown)").option("--body-file <path|->", "read issue body from a UTF-8 file. `-` (stdin) needs a heredoc, which the agent inline-body guard denies (#1473/#2125) \u2014 prefer a real path; a title with backticks or newlines needs --title-file for the same reason (#3381)").option("--priority <priority>", "urgent | high | medium | low (defaults to medium; sets the board Priority field only \u2014 never a priority:* label, #416)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--label <label...>", "extra label(s) to attach (repeatable; auto-created if missing)").option("--parent <ref>", "file as a native sub-issue of this parent (#123, owner/repo#123, or URL)").option("--no-related", "skip the auto related-issues comment"),
27081
27267
  // --dry-run/--validate-only plan: validate --type + --priority (mirrors the action — a bad enum fails
27082
27268
  // ERR_BAD_ENUM, a missing priority defaults to medium) then echo the resolved create intent. Refs
27083
27269
  // (`--parent`) and title-source are validated by the action on a real run.
@@ -27294,7 +27480,7 @@ ${list}`);
27294
27480
  }
27295
27481
  console.log(JSON.stringify({ number: parsed.number, repo, item: result.item.text, checked, changed: true }));
27296
27482
  });
27297
- program2.command("report").description("file a friction report on the Hub board (Hub session auth, dedups open reports) and print {number,url} JSON").option("--title <title>", "one-line friction summary").option("--title-file <path|->", "read the friction summary from a UTF-8 file, or from stdin with -").option("--body <body>", "report body (markdown)").option("--body-file <path|->", "read report body from a UTF-8 file, or from stdin with -").option("--type <type>", "bug | feature | task (sets the matching label)", "task").option("--priority <priority>", "urgent | high | medium | low (defaults to medium; sets the board Priority field only, #416)").option("--repo <owner/repo>", 'attribute the report to a different source repo than the current checkout for the "Filed via..." footer (rare \u2014 usually auto-detected; every report always lands on the org Hub, never an alternate target, #263)').option("--force", "file a new issue even when an open report looks like a duplicate").option("--json", "machine-readable output (already the default \u2014 report always prints JSON; #682)").action(async (o) => {
27483
+ program2.command("report").description("file a friction report on the Hub board (Hub session auth, dedups open reports) and print {number,url} JSON").option("--title <title>", "one-line friction summary").option("--title-file <path|->", "read the friction summary from a UTF-8 file, or from stdin with -").option("--body <body>", "report body (markdown)").option("--body-file <path|->", "read report body from a UTF-8 file, or from stdin with -").addOption(new Option("--type <type>", "bug | feature | task (sets the matching label)").default("task").choices([...ISSUE_TYPES])).option("--priority <priority>", "urgent | high | medium | low (defaults to medium; sets the board Priority field only, #416)").option("--repo <owner/repo>", 'attribute the report to a different source repo than the current checkout for the "Filed via..." footer (rare \u2014 usually auto-detected; every report always lands on the org Hub, never an alternate target, #263)').option("--force", "file a new issue even when an open report looks like a duplicate").option("--json", "machine-readable output (already the default \u2014 report always prints JSON; #682)").action(async (o) => {
27298
27484
  let body;
27299
27485
  let priority;
27300
27486
  let title;
@@ -27338,7 +27524,7 @@ async function resolvePluginSha() {
27338
27524
  return void 0;
27339
27525
  }
27340
27526
  }
27341
- program2.command("skill-lesson").description("file a skill-lesson on the Hub board (GitHub auth, dedups open lessons) and print {number,url} JSON").requiredOption("--skill <name>", `which skill misfired (${SKILL_NAMES.join(" | ")})`).option("--title <title>", "one-line summary of what misfired").option("--title-file <path|->", "read the one-line summary from a UTF-8 file, or from stdin with -").option("--body <body>", "lesson body: what misfired, the evidence, and the proposed amendment (markdown)").option("--body-file <path|->", "read the lesson body from a UTF-8 file, or from stdin with -").option("--priority <priority>", "urgent | high | medium | low (defaults to medium; sets the board Priority field only, #416)").option("--repo <owner/repo>", `target repo (defaults to the org Hub: ${HUB_REPO2})`).option("--force", "file a new issue even when an open lesson looks like a duplicate").option("--json", "machine-readable output (already the default \u2014 skill-lesson always prints JSON)").action(async (o) => {
27527
+ program2.command("skill-lesson").description("file a skill-lesson on the Hub board (GitHub auth, dedups open lessons) and print {number,url} JSON").addOption(new Option("--skill <name>", `which skill misfired (${SKILL_NAMES.join(" | ")})`).makeOptionMandatory().choices([...SKILL_NAMES])).option("--title <title>", "one-line summary of what misfired").option("--title-file <path|->", "read the one-line summary from a UTF-8 file, or from stdin with -").option("--body <body>", "lesson body: what misfired, the evidence, and the proposed amendment (markdown)").option("--body-file <path|->", "read the lesson body from a UTF-8 file, or from stdin with -").option("--priority <priority>", "urgent | high | medium | low (defaults to medium; sets the board Priority field only, #416)").option("--repo <owner/repo>", `target repo (defaults to the org Hub: ${HUB_REPO2})`).option("--force", "file a new issue even when an open lesson looks like a duplicate").option("--json", "machine-readable output (already the default \u2014 skill-lesson always prints JSON)").action(async (o) => {
27342
27528
  const targetRepo2 = o.repo ?? HUB_REPO2;
27343
27529
  const sourceRepo = await resolveRepo(void 0);
27344
27530
  const pluginSha = await resolvePluginSha();
@@ -27940,17 +28126,33 @@ async function resolveRcandPlanTargets() {
27940
28126
  }
27941
28127
  }
27942
28128
  for (const commandName of ["rcand", "release"]) {
27943
- program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the deploy/publish workflow runs and report their outcomes").option("--apply", "execute the guarded master-only train after explicit approval").option("--announce-summary-file <path>", "release only: agent-curated summary lines for the Hub Slack announcement (#883)").option("--ack <shas>", "release only: comma-separated dev shas a human verified are in the candidate, overriding the hotfix-coverage guard for a conflicted port whose -x trailer was lost (#958)").option("--dev", "release only: full-track repos release development -> main directly, skipping rc (refuses if rc carries content not in development; no-op on direct-track repos) (#1062)").option("--repo <owner/repo>", "dry-run plan for a target repo without relying on the current checkout; --apply still uses the current checkout").action(async (o) => {
28129
+ const trainCmd = program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the deploy/publish workflow runs and report their outcomes").option("--apply", "execute the guarded master-only train after explicit approval");
28130
+ const RELEASE_ONLY_FLAGS = [
28131
+ { flags: "--announce-summary-file <path>", description: "agent-curated summary lines for the Hub Slack announcement (#883)" },
28132
+ { flags: "--ack <shas>", description: "comma-separated dev shas a human verified are in the candidate, overriding the hotfix-coverage guard for a conflicted port whose -x trailer was lost (#958)" },
28133
+ { flags: "--dev", description: "full-track repos release development -> main directly, skipping rc (refuses if rc carries content not in development; no-op on direct-track repos) (#1062)" }
28134
+ ];
28135
+ for (const f of RELEASE_ONLY_FLAGS) {
28136
+ if (commandName === "release") {
28137
+ trainCmd.option(f.flags, f.description);
28138
+ } else {
28139
+ trainCmd.addOption(new Option(f.flags, f.description).hideHelp());
28140
+ }
28141
+ }
28142
+ trainCmd.option("--repo <owner/repo>", "dry-run plan for a target repo without relying on the current checkout; --apply still uses the current checkout").action(async (o) => {
27944
28143
  try {
27945
28144
  await requireFreshTrainCli(commandName);
27946
28145
  } catch (e) {
27947
28146
  return fail(`${commandName}: ${e.message}`);
27948
28147
  }
27949
28148
  if (o.ack && commandName !== "release") {
27950
- return fail("--ack applies only to release: it overrides the rc -> main hotfix-coverage guard, which rcand does not run");
28149
+ return fail(`${commandName}: --ack applies only to release \u2014 it overrides the rc -> main hotfix-coverage guard, which rcand does not run. Run: mmi-cli release --ack <shas>`);
27951
28150
  }
27952
28151
  if (o.dev && commandName !== "release") {
27953
- return fail("--dev applies only to release: it ships development -> main skipping rc, which rcand cannot do");
28152
+ return fail(`${commandName}: --dev applies only to release \u2014 it ships development -> main skipping rc, which rcand cannot do. Run: mmi-cli release --dev`);
28153
+ }
28154
+ if (o.announceSummaryFile && commandName !== "release") {
28155
+ return fail(`${commandName}: --announce-summary-file applies only to release \u2014 rcand posts no Hub Slack announcement. Run: mmi-cli release --announce-summary-file <path>`);
27954
28156
  }
27955
28157
  if (o.apply && o.repo) {
27956
28158
  const rerun = `mmi-cli ${commandName} --apply${o.watch ? " --watch" : ""}${o.dev ? " --dev" : ""}${o.json ? " --json" : ""}`;
@@ -28039,21 +28241,24 @@ ci.command("audit").description("read-only fleet scan: gate workflow, ruleset co
28039
28241
  else console.log(renderCiAuditText(report));
28040
28242
  if (!report.ok) process.exitCode = 1;
28041
28243
  });
28042
- ci.command("reconcile").description("audit + optionally apply merge settings and product ruleset activation (master-admin)").option("--json", "machine-readable output").option("--repo <owner/repo>", "reconcile one repo instead of the full registry").option("--apply", "PATCH merge settings + activate product ruleset when missing (master role required)").action(async (o) => {
28043
- if (o.apply) {
28244
+ ci.command("reconcile").description("audit + optionally apply merge settings and product ruleset activation (master-admin)").option("--json", "machine-readable output").option("--repo <owner/repo>", "reconcile one repo instead of the full registry").option("--apply", "PATCH merge settings + activate product ruleset when missing (master role required); activation is skipped while the repo's gate has never passed (#3694)").option("--park-ruleset", "stop the product ruleset enforcing without deleting it \u2014 keeps its required contexts (master role required)").action(async (o) => {
28245
+ if (o.apply && o.parkRuleset) {
28246
+ return fail("ci reconcile: --apply and --park-ruleset ask for opposite things; pass one");
28247
+ }
28248
+ if (o.apply || o.parkRuleset) {
28044
28249
  const verdict = await fetchTrainAuthority(HUB_REPO2, registryClientDeps(await loadConfig()));
28045
28250
  if (!verdict.ok || verdict.authority.role !== "master") {
28046
- return fail("ci reconcile --apply: master-admin required");
28251
+ return fail(`ci reconcile ${o.apply ? "--apply" : "--park-ruleset"}: master-admin required`);
28047
28252
  }
28048
28253
  }
28049
28254
  const deps = ciAuditDeps();
28050
28255
  const audit = await auditOrgCi(deps, o.repo);
28051
- const applyResults = o.apply ? await Promise.all(audit.repos.map((r) => applyCiReconcileRepo(r.repo, deps))) : [];
28256
+ const applyResults = o.apply ? await Promise.all(audit.repos.map((r) => applyCiReconcileRepo(r.repo, deps))) : o.parkRuleset ? await Promise.all(audit.repos.map((r) => parkProductRuleset(r.repo, deps))) : [];
28052
28257
  const payload = { audit, apply: applyResults };
28053
28258
  if (o.json) console.log(JSON.stringify(payload, null, 2));
28054
28259
  else {
28055
28260
  console.log(renderCiAuditText(audit));
28056
- if (o.apply) {
28261
+ if (applyResults.length) {
28057
28262
  for (const r of applyResults) {
28058
28263
  console.log(`
28059
28264
  ${r.repo}: applied=[${r.applied.join("; ")}] skipped=[${r.skipped.join("; ")}]${r.errors.length ? ` errors=[${r.errors.join("; ")}]` : ""}`);
@@ -28304,6 +28509,7 @@ program2.parseAsync(process.argv).then(() => finishCliRun()).catch((e) => failGr
28304
28509
  envHealLockPath,
28305
28510
  gcPlan,
28306
28511
  isOrgRegisteredRepo,
28512
+ parseInvalidChoiceError,
28307
28513
  positionalTargetForm,
28308
28514
  registryClientDeps,
28309
28515
  repoSlug,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.70.0",
3
+ "version": "3.72.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",