@mutmutco/cli 4.5.83 → 4.5.84

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 +590 -89
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -3967,7 +3967,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
3967
3967
  const negativeNumberArg = (arg) => {
3968
3968
  if (!/^-(\d+|\d*\.\d+)(e[+-]?\d+)?$/.test(arg)) return false;
3969
3969
  return !this._getCommandAndAncestors().some(
3970
- (cmd) => cmd.options.map((opt) => opt.short).some((short) => /^-\d$/.test(short))
3970
+ (cmd) => cmd.options.map((opt) => opt.short).some((short3) => /^-\d$/.test(short3))
3971
3971
  );
3972
3972
  };
3973
3973
  let activeVariadicOption = null;
@@ -17268,10 +17268,10 @@ var rollout_plan_default = {
17268
17268
  note: "The v4.0.0 stamp happens at cut time (D6e #4463); until then the candidate is the origin/development head artifacts (built cli/dist + npm pack), identity proven by dist content hash (D6a)."
17269
17269
  },
17270
17270
  baseline: {
17271
- version: "4.5.83",
17272
- tag: "v4.5.83",
17273
- commit: "d092dda5d5e6",
17274
- npm: "@mutmutco/cli@4.5.83"
17271
+ version: "4.5.84",
17272
+ tag: "v4.5.84",
17273
+ commit: "86b566fd9857",
17274
+ npm: "@mutmutco/cli@4.5.84"
17275
17275
  },
17276
17276
  exitCriterion: "fleet-n-of-n",
17277
17277
  hubOnlyShortcut: "forbidden",
@@ -17288,14 +17288,14 @@ var rollout_plan_default = {
17288
17288
  repo: "mutmutco/mmi-hub",
17289
17289
  role: "canary",
17290
17290
  schedule: "train",
17291
- v3Target: "v4.5.83"
17291
+ v3Target: "v4.5.84"
17292
17292
  }
17293
17293
  ],
17294
17294
  rollbackTrigger: "Any red inside the post-contract soak window: `devops train gate` FAIL attributable to the v4 doors, Hub endpoint health probe failure, a pre-v4 client admitted instead of receiving actionable HTTP 426, or npm consumer install/doctor failure on the v4-only dist.",
17295
17295
  rollback: {
17296
17296
  independent: true,
17297
- mechanism: "npm dist-tag latest -> 4.5.83 and redeploy the Hub Lambda from tag v4.5.83 (d092dda5d5e6); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
17298
- v3Target: "v4.5.83 (@mutmutco/cli@4.5.83, tag commit d092dda5d5e6 \u2014 last known-good release carrying the repo-index v4-only contract)"
17297
+ mechanism: "npm dist-tag latest -> 4.5.84 and redeploy the Hub Lambda from tag v4.5.84 (86b566fd9857); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
17298
+ v3Target: "v4.5.84 (@mutmutco/cli@4.5.84, tag commit 86b566fd9857 \u2014 last known-good release carrying the repo-index v4-only contract)"
17299
17299
  }
17300
17300
  },
17301
17301
  {
@@ -20287,6 +20287,10 @@ var SUITE_CUT_TOKEN = /* @__PURE__ */ Symbol("suite-cut");
20287
20287
  function suiteCutContext(repo, lead) {
20288
20288
  return { repo, lead, token: SUITE_CUT_TOKEN };
20289
20289
  }
20290
+ var SUITE_LEAD_TOKEN = /* @__PURE__ */ Symbol("suite-lead");
20291
+ function suiteLeadContext(lead) {
20292
+ return { repo: lead, lead, token: SUITE_LEAD_TOKEN };
20293
+ }
20290
20294
  function validSuiteCutContext(repo, context) {
20291
20295
  return !!context && context.token === SUITE_CUT_TOKEN && context.repo.toLowerCase() === repo.toLowerCase();
20292
20296
  }
@@ -20300,7 +20304,12 @@ function isBundleOnlySuiteMember(repo, suite) {
20300
20304
  }
20301
20305
  function suiteMemberRefusal(repo, meta, lane, solo = false, cut) {
20302
20306
  const suite = meta?.suite;
20303
- if (!suite || !isBundleOnlySuiteMember(repo, suite)) return null;
20307
+ if (!suite) return null;
20308
+ if (!isBundleOnlySuiteMember(repo, suite)) {
20309
+ if (solo || cut?.token === SUITE_LEAD_TOKEN && cut.repo.toLowerCase() === repo.toLowerCase()) return null;
20310
+ const route = lane === "hotfix" ? "hotfix the affected member from its own checkout, then run mmi-cli devops suite release --owner-go" : `run mmi-cli devops suite ${lane} --owner-go`;
20311
+ return `${repo} is the ${suite} suite lead; a plain ${lane} would cut it without its members: ${route}. A lead-only ${lane} needs --suite-solo --owner-go.`;
20312
+ }
20304
20313
  if (authorisedSuiteCut(repo, meta, cut)) return null;
20305
20314
  if (lane !== "rcand" && solo) return null;
20306
20315
  return `${repo} is part of the ${suite} suite; run the suite cut/release from ${suiteLeadRepo(repo, suite)} (mmi-cli devops suite rcand|release). A solo backend release needs --suite-solo --owner-go.`;
@@ -35595,6 +35604,7 @@ init_github_client();
35595
35604
  init_cli_shared();
35596
35605
 
35597
35606
  // src/suite-status.ts
35607
+ var import_node_crypto13 = require("node:crypto");
35598
35608
  var SUITE_REPO = "mutmutco/MM-Work";
35599
35609
  var REPOS = {
35600
35610
  "mmi-scout": "mutmutco/MMI-Scout",
@@ -35604,6 +35614,54 @@ var REPOS = {
35604
35614
  katip: "mutmutco/MMI-Katip"
35605
35615
  };
35606
35616
  var STAGES3 = ["dev", "rc", "main"];
35617
+ var MAPPING_MARKER = /<!-- mmi-suite-members (\{[^\n]*\}) -->/;
35618
+ function suiteMappingBlock(tag, mapping) {
35619
+ const rows = Object.entries(mapping).map(([id, v]) => `- ${id}: ${v}`).join("\n");
35620
+ return `<!-- mmi-suite-members ${JSON.stringify(mapping)} -->
35621
+ ## Suite ${tag} members
35622
+ ${rows}
35623
+ `;
35624
+ }
35625
+ function parseSuiteMapping(body) {
35626
+ const raw = MAPPING_MARKER.exec(body ?? "")?.[1];
35627
+ if (!raw) return void 0;
35628
+ try {
35629
+ const v = JSON.parse(raw);
35630
+ return Object.values(v).every((x) => typeof x === "string") ? v : void 0;
35631
+ } catch {
35632
+ return void 0;
35633
+ }
35634
+ }
35635
+ var sha256 = (b) => (0, import_node_crypto13.createHash)("sha256").update(b).digest("hex");
35636
+ async function proveBundle(url, tag, fetchUrl) {
35637
+ const get = async (u) => {
35638
+ try {
35639
+ return await fetchUrl(u);
35640
+ } catch (e) {
35641
+ return `unreachable (${e.message})`;
35642
+ }
35643
+ };
35644
+ const bundle = await get(url);
35645
+ if (typeof bundle === "string") return { state: "unprovable", detail: `bundle ${bundle}` };
35646
+ if (bundle.status !== 200) return { state: "unprovable", detail: `bundle HTTP ${bundle.status}` };
35647
+ const served = sha256(bundle.body);
35648
+ const recordUrl = new URL(url);
35649
+ recordUrl.search = "";
35650
+ recordUrl.pathname = recordUrl.pathname.replace(/[^/]*$/, "module.json");
35651
+ const rec = await get(recordUrl.toString());
35652
+ if (typeof rec === "string") return { state: "unprovable", detail: `build record ${rec}; served ${served.slice(0, 12)}` };
35653
+ if (rec.status !== 200) return { state: "unprovable", detail: `build record not served (HTTP ${rec.status}); served ${served.slice(0, 12)}` };
35654
+ let record;
35655
+ try {
35656
+ record = JSON.parse(Buffer.from(rec.body).toString("utf8"));
35657
+ } catch {
35658
+ return { state: "unprovable", detail: `build record unreadable; served ${served.slice(0, 12)}` };
35659
+ }
35660
+ if (typeof record.tag !== "string" || typeof record.sha256 !== "string") return { state: "unprovable", detail: `build record malformed; served ${served.slice(0, 12)}` };
35661
+ if (record.tag !== tag) return { state: "mismatch", detail: `host serves ${record.tag}, pinned ${tag}` };
35662
+ if (record.sha256.toLowerCase() !== served) return { state: "mismatch", detail: `served ${served.slice(0, 12)}, pinned ${record.sha256.slice(0, 12)}` };
35663
+ return { state: "proven", detail: `sha ${served.slice(0, 12)}` };
35664
+ }
35607
35665
  function suiteMembers(raw) {
35608
35666
  if (!Array.isArray(raw)) throw new Error("MM-Work seed/modules.json is unreadable");
35609
35667
  const members = [{ repo: SUITE_REPO, id: "mm-work", bundles: {} }];
@@ -35639,6 +35697,7 @@ async function stageStatus(member, stage, deps) {
35639
35697
  let served = "unknown";
35640
35698
  let source = "unknown";
35641
35699
  let commit;
35700
+ let note;
35642
35701
  let deployStatus;
35643
35702
  const pin = stage !== "dev" ? bundleTag(member.bundles[stage]) : void 0;
35644
35703
  if (pin) {
@@ -35660,47 +35719,157 @@ async function stageStatus(member, stage, deps) {
35660
35719
  else {
35661
35720
  try {
35662
35721
  commit = await deps.tagCommit(member.repo, served);
35722
+ if (!commit && stage === "main") note = `pin names no release (tag ${served} absent)`;
35663
35723
  } catch {
35664
35724
  }
35665
35725
  }
35666
35726
  }
35667
- const lag = commit && head !== "unknown" ? commit.toLowerCase() !== head.toLowerCase() : null;
35727
+ let lag = commit && head !== "unknown" ? commit.toLowerCase() !== head.toLowerCase() : null;
35728
+ let proof;
35729
+ if (pin && stage !== "dev" && deps.fetchUrl) {
35730
+ proof = await proveBundle(member.bundles[stage], pin, deps.fetchUrl);
35731
+ if (proof.state === "mismatch") lag = true;
35732
+ }
35668
35733
  return {
35669
35734
  stage,
35670
35735
  head,
35671
35736
  served,
35672
35737
  source,
35673
- lag: source === "registry-pin" && lag === false ? null : lag,
35738
+ lag: source === "registry-pin" && lag === false && proof?.state !== "proven" ? null : lag,
35739
+ ...proof ? { proof } : {},
35740
+ ...note ? { note } : {},
35674
35741
  ...deployStatus ? { deployStatus } : {}
35675
35742
  };
35676
35743
  }
35677
35744
  async function readSuiteStatus(deps) {
35678
35745
  const members = suiteMembers(await deps.registry());
35679
- return {
35680
- suite: "mm-work",
35681
- members: await Promise.all(members.map(async (member) => ({
35682
- repo: member.repo,
35683
- id: member.id,
35684
- stages: await Promise.all(STAGES3.map((stage) => stageStatus(member, stage, deps)))
35685
- })))
35686
- };
35746
+ const rows = await Promise.all(members.map(async (member) => ({
35747
+ repo: member.repo,
35748
+ id: member.id,
35749
+ stages: await Promise.all(STAGES3.map((stage) => stageStatus(member, stage, deps)))
35750
+ })));
35751
+ if (!deps.leadTags || !deps.registryAt) return { suite: "mm-work", members: rows };
35752
+ const tags = await deps.leadTags().catch(() => []);
35753
+ const latest = (rc) => tags.filter((t) => /^v\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(t) && t.includes("-rc.") === rc).sort((a, b) => compareVersions(b, a))[0];
35754
+ const leadTag = { rc: latest(true), main: latest(false) };
35755
+ const lead = rows[0].stages;
35756
+ for (const stage of ["rc", "main"]) {
35757
+ const tag = leadTag[stage];
35758
+ if (!tag) continue;
35759
+ let pins = [];
35760
+ try {
35761
+ pins = suiteMembers(await deps.registryAt(tag));
35762
+ } catch {
35763
+ }
35764
+ for (const row of rows.slice(1)) {
35765
+ const st = row.stages.find((s) => s.stage === stage);
35766
+ st.suitePin = { suite: tag, pin: bundleTag(pins.find((m) => m.id === row.id)?.bundles[stage]) };
35767
+ }
35768
+ }
35769
+ const recorded = leadTag.main && deps.leadReleaseBody ? parseSuiteMapping(await deps.leadReleaseBody(leadTag.main).catch(() => null)) : void 0;
35770
+ return { suite: "mm-work", lead: { dev: lead[0].head, rc: leadTag.rc ?? "n/a", main: leadTag.main ?? "unknown", ...recorded ? { members: recorded } : {} }, members: rows };
35771
+ }
35772
+ var short = (v) => /^[a-f0-9]{40}$/i.test(v) ? v.slice(0, 12) : v;
35773
+ var proofText = (p) => p ? p.state === "proven" ? "proven" : p.state === "mismatch" ? `mismatch (${p.detail})` : `unprovable (${p.detail})` : "";
35774
+ function formatSuiteMarkdown(report) {
35775
+ const cell = (st) => !st || st.source === "n/a" ? "n/a" : short(st.served);
35776
+ const by = (st, name) => st?.suitePin ? `${name} ${st.suitePin.suite}${st.suitePin.pin && st.suitePin.pin !== st.served ? ` (pins ${st.suitePin.pin})` : st.suitePin.pin ? "" : " (no pin)"}` : "";
35777
+ const lines2 = ["| Member | dev | rc | main | Pinned by suite |", "| --- | --- | --- | --- | --- |"];
35778
+ if (report.lead) lines2.push(`| MM-Work (suite) | ${short(report.lead.dev)} | ${report.lead.rc} | ${report.lead.main} | - |`);
35779
+ for (const m of report.members.slice(report.lead ? 1 : 0)) {
35780
+ const [dev, rc, main] = ["dev", "rc", "main"].map((s) => m.stages.find((x) => x.stage === s));
35781
+ lines2.push(`| ${m.repo.split("/")[1]} | ${cell(dev)} | ${cell(rc)} | ${cell(main)} | ${[by(rc, "rc"), by(main, "main")].filter(Boolean).join(", ") || "-"} |`);
35782
+ }
35783
+ return lines2.join("\n");
35687
35784
  }
35688
35785
  function formatSuiteStatus(report) {
35689
35786
  const lines2 = [
35690
35787
  "suite mm-work \u2014 registry pins are declarations, tenant versions require a healthy probe",
35788
+ ...report.lead ? [`suite version: dev ${short(report.lead.dev)} rc ${report.lead.rc} main ${report.lead.main}`] : [],
35789
+ ...report.lead?.members ? [`suite ${report.lead.main} = members ${Object.entries(report.lead.members).map(([id, v]) => `${id} ${v}`).join(", ")} (recorded by suite hotfix, #7738)`] : [],
35691
35790
  "member stage head served source lag"
35692
35791
  ];
35693
35792
  for (const member of report.members) for (const row of member.stages) {
35694
35793
  const served = /^[a-f0-9]{40}$/i.test(row.served) ? row.served.slice(0, 12) : row.served;
35695
- lines2.push(`${member.repo.split("/")[1].padEnd(18)} ${row.stage.padEnd(6)} ${row.head.slice(0, 12).padEnd(13)} ${served.padEnd(18)} ${row.source.padEnd(15)} ${row.source === "n/a" ? "n/a" : row.lag === null ? "?" : row.lag ? "LAG" : "ok"}`);
35794
+ lines2.push(`${member.repo.split("/")[1].padEnd(18)} ${row.stage.padEnd(6)} ${row.head.slice(0, 12).padEnd(13)} ${served.padEnd(18)} ${row.source.padEnd(15)} ${row.source === "n/a" ? "n/a" : row.lag === null ? row.note ?? "?" : row.lag ? "LAG" : "ok"}${row.proof ? ` ${proofText(row.proof)}` : ""}${row.suitePin ? ` by suite ${row.suitePin.suite}` : ""}`);
35696
35795
  }
35697
35796
  return lines2.join("\n");
35698
35797
  }
35798
+ async function setSuiteHold(deps, member, hold) {
35799
+ const wanted = member.toLowerCase();
35800
+ const found = suiteMembers(await deps.registry()).find((m) => m.repo.toLowerCase() === wanted || m.repo.split("/")[1].toLowerCase() === wanted || m.id === wanted);
35801
+ if (!found) throw new Error(`${member} is not an MM-Work suite member; nothing was written`);
35802
+ if (found.repo === SUITE_REPO) throw new Error(`${SUITE_REPO} is the suite lead and cannot be held; nothing was written`);
35803
+ const slug = found.repo.split("/")[1].toLowerCase();
35804
+ const before = await deps.read(slug);
35805
+ if (!before.ok || !before.project) throw new Error(`${found.repo}: registry META unreadable${before.ok ? "" : ` (${before.error})`}; nothing was written`);
35806
+ const track = before.project.releaseTrack;
35807
+ const res = await deps.write(slug, { suiteHold: hold ? parseSuiteHoldVar(JSON.stringify(hold)) : null, repo: found.repo, ...track ? { releaseTrack: track } : {} });
35808
+ if (!res.ok) throw new Error(`${found.repo}: registry write failed (HTTP ${res.status}${res.error ? `, ${res.error}` : ""})`);
35809
+ const after = await deps.read(slug);
35810
+ if (!after.ok || !after.project) throw new Error(`${found.repo}: write sent but read-back failed; verify with org project get`);
35811
+ const got = after.project.suiteHold ?? null;
35812
+ if (JSON.stringify(got) !== JSON.stringify(hold)) throw new Error(`${found.repo}: read-back suiteHold ${JSON.stringify(got)} does not match what was written`);
35813
+ if (track && after.project.releaseTrack !== track) throw new Error(`${found.repo}: releaseTrack changed from ${track} to ${after.project.releaseTrack ?? "unset"} by the write; restore it with org project set --release-track ${track}`);
35814
+ return hold ? `${found.repo}: HELD (${hold.reason})` : `${found.repo}: hold cleared`;
35815
+ }
35816
+
35817
+ // src/suite-dev.ts
35818
+ var WALK = 20;
35819
+ var short2 = (s) => s && /^[a-f0-9]{40}$/i.test(s) ? s.slice(0, 12) : s ?? "-";
35820
+ var same = (a, b) => !!a && !!b && a.toLowerCase() === b.toLowerCase();
35821
+ async function newestGreen(deps, repo) {
35822
+ const checks = await deps.requiredChecks(repo);
35823
+ for (const sha of await deps.commits(repo, WALK)) {
35824
+ const runs = await deps.checkRuns(repo, sha);
35825
+ if (checks.every((name) => {
35826
+ const latest = runs.filter((r) => r.name === name).sort((a, b) => b.id - a.id)[0];
35827
+ return latest?.status === "completed" && latest.conclusion === "success";
35828
+ })) return sha;
35829
+ }
35830
+ return void 0;
35831
+ }
35832
+ async function readSuiteDev(deps) {
35833
+ const report = await deps.status();
35834
+ return Promise.all(report.members.map(async (m) => {
35835
+ const st = m.stages.find((s) => s.stage === "dev");
35836
+ const row = { repo: m.repo, head: st.head, served: st.served };
35837
+ if (st.source === "n/a") return { ...row, state: "converged", note: "no dev stage" };
35838
+ if (st.lag === false) return { ...row, state: "converged", note: "dev serves the development head" };
35839
+ let green;
35840
+ try {
35841
+ green = await newestGreen(deps, m.repo);
35842
+ } catch (e) {
35843
+ return { ...row, state: "unknown", note: `checks unreadable (${e.message})` };
35844
+ }
35845
+ const last = st.deployStatus?.lastRun;
35846
+ if (st.served === "unknown") return { ...row, green, state: "unknown", note: `dev version unproven (health probe failed or no deploy record)${last?.url ? `; last deploy ${last.conclusion ?? last.status}: ${last.url}` : ""}` };
35847
+ if (same(st.served, green)) return { ...row, green, state: "converged", note: `dev serves the newest green SHA; head ${short2(st.head)} is not green yet` };
35848
+ if (!green) return { ...row, state: "blocked", note: `no green SHA in the last ${WALK} development commits` };
35849
+ if (last?.conclusion === "failure") return { ...row, green, state: "blocked", note: `last dev deploy failed: ${last.url ?? "no run url"}` };
35850
+ return { ...row, green, state: "lagging", note: `newest green ${short2(green)} not deployed yet; the dev auto-deploy sweep (every 15 min) picks it up` };
35851
+ }));
35852
+ }
35853
+ function formatSuiteDev(rows) {
35854
+ return [
35855
+ "member head served green state note",
35856
+ ...rows.map((r) => `${r.repo.split("/")[1].padEnd(18)} ${short2(r.head).padEnd(13)} ${short2(r.served).padEnd(13)} ${short2(r.green).padEnd(13)} ${r.state.padEnd(10)} ${r.note}`)
35857
+ ].join("\n");
35858
+ }
35859
+ async function waitSuiteDev(deps, opts) {
35860
+ const sleep2 = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
35861
+ for (let waited = 0; ; waited += opts.intervalMs) {
35862
+ const rows = await readSuiteDev(deps);
35863
+ opts.onTick?.(rows);
35864
+ if (!rows.some((r) => r.state === "lagging") || waited + opts.intervalMs > opts.timeoutMs) return rows;
35865
+ await sleep2(opts.intervalMs);
35866
+ }
35867
+ }
35699
35868
 
35700
35869
  // src/suite-train.ts
35701
35870
  var import_node_fs29 = require("node:fs");
35702
35871
  var import_node_path29 = require("node:path");
35703
- var import_node_crypto13 = require("node:crypto");
35872
+ var import_node_crypto14 = require("node:crypto");
35704
35873
 
35705
35874
  // src/kit-version.ts
35706
35875
  var parse = (v) => /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(typeof v === "string" ? v.trim() : "");
@@ -35791,7 +35960,7 @@ function readSuiteCut(path2) {
35791
35960
  }
35792
35961
  function writeSuiteCut(path2, record) {
35793
35962
  (0, import_node_fs29.mkdirSync)((0, import_node_path29.dirname)(path2), { recursive: true });
35794
- const temp = `${path2}.${(0, import_node_crypto13.randomBytes)(8).toString("hex")}.tmp`;
35963
+ const temp = `${path2}.${(0, import_node_crypto14.randomBytes)(8).toString("hex")}.tmp`;
35795
35964
  try {
35796
35965
  (0, import_node_fs29.writeFileSync)(temp, JSON.stringify(record, null, 2), { flag: "wx" });
35797
35966
  (0, import_node_fs29.renameSync)(temp, path2);
@@ -35836,8 +36005,41 @@ function suiteAbandon(recordPath, ownerGo = false, now = /* @__PURE__ */ new Dat
35836
36005
  (0, import_node_fs29.renameSync)(recordPath, archive);
35837
36006
  return { archive, orphanTags: record.members.filter((m) => m.tag).map((m) => `${m.repo}@${m.tag}`) };
35838
36007
  }
36008
+ async function suiteRestoreMainPins(deps) {
36009
+ const record = readSuiteCut(deps.recordPath);
36010
+ if (!record?.pinPr || record.leadReleased || record.members.some((m) => m.released)) return void 0;
36011
+ const pins = [];
36012
+ for (const m of record.members) {
36013
+ if (m.carried || !m.tag || !m.priorMain) continue;
36014
+ const planned = suiteReleaseTag(m.tag);
36015
+ if (!await deps.tagSha(m.repo, planned)) pins.push({ module: m.id, planned, prior: m.priorMain });
36016
+ }
36017
+ return pins.length ? deps.restoreMain({ cut: record.leadDevelopment, pins }) : void 0;
36018
+ }
36019
+ async function dryRunStatus(deps, m) {
36020
+ const [dev, rc] = await Promise.all([deps.head(m.repo, "development"), deps.head(m.repo, "rc")]);
36021
+ const previousRc = rc ?? await deps.head(m.repo, "main");
36022
+ if (!dev || !previousRc) return "branch heads unreadable";
36023
+ const verdict = await deps.contains(m.repo, dev, previousRc) ? "carried (no change on development since its last rc tag)" : "new candidate";
36024
+ let pending = "";
36025
+ try {
36026
+ const why = await pendingBackend(deps, m, dev);
36027
+ if (why) pending = `; pending backend release (${why})`;
36028
+ } catch (e) {
36029
+ pending = `; live backend unreadable (${e.message})`;
36030
+ }
36031
+ return verdict + pending;
36032
+ }
36033
+ var NOT_BACKEND = /^(module|docs|\.github)\/|\.md$/;
36034
+ async function pendingBackend(deps, m, dev) {
36035
+ const { version } = await deps.backend(m);
36036
+ const live = await deps.tagSha(m.repo, version);
36037
+ if (!live || await deps.contains(m.repo, dev, live)) return "";
36038
+ const files = (await deps.changedPaths(m.repo, live, dev)).filter((f) => !NOT_BACKEND.test(f));
36039
+ return files.length ? `${files.length} backend file(s) changed since live ${version}, e.g. ${files[0]}` : "";
36040
+ }
35839
36041
  async function suiteRcand(deps, opts) {
35840
- if (!opts.ownerGo) throw new Error("suite rcand requires --owner-go (rc uses live data); nothing was written");
36042
+ if (!opts.ownerGo && !opts.dryRun) throw new Error("suite rcand requires --owner-go (rc uses live data); nothing was written");
35841
36043
  const members = suiteMembers(await deps.registry());
35842
36044
  const modules = members.filter((m) => m.repo !== SUITE_REPO);
35843
36045
  const { active, held } = await holdSplit(deps, modules);
@@ -35849,7 +36051,10 @@ async function suiteRcand(deps, opts) {
35849
36051
  ];
35850
36052
  const lead = await deps.head(SUITE_REPO, "development");
35851
36053
  plan.push(await kitCheck(deps, lead, active.map((m) => ({ id: m.id, url: m.bundles.dev })), "dev bundles"));
35852
- if (opts.dryRun) return { plan };
36054
+ if (opts.dryRun) {
36055
+ plan.push(...await Promise.all(modules.map(async (m) => `${m.repo}: ${heldById.has(m.id) ? "HELD" : await dryRunStatus(deps, m)}`)));
36056
+ return { plan };
36057
+ }
35853
36058
  let record = readSuiteCut(deps.recordPath);
35854
36059
  if (record?.leadReleased && record.announcement === "pending") {
35855
36060
  const reason = await deps.announceSkipReason(record);
@@ -35872,6 +36077,12 @@ async function suiteRcand(deps, opts) {
35872
36077
  record = void 0;
35873
36078
  }
35874
36079
  if (!record) {
36080
+ const pending = (await Promise.all(active.map(async (m) => {
36081
+ const dev = await deps.head(m.repo, "development");
36082
+ const why = dev ? await pendingBackend(deps, m, dev) : "";
36083
+ return why ? `${m.repo} (${why})` : "";
36084
+ }))).filter(Boolean);
36085
+ if (pending.length) throw new Error(`member backends not live yet: ${pending.join("; ")}. Release each from its own checkout first (mmi-cli devops release --suite-solo --owner-go), or hold it (suite hold set), then re-run suite rcand; nothing was cut`);
35875
36086
  const [leadDevelopment, leadPreviousRc, snapshots] = await Promise.all([
35876
36087
  deps.head(SUITE_REPO, "development"),
35877
36088
  deps.head(SUITE_REPO, "rc"),
@@ -35972,6 +36183,11 @@ Each module must ship a live release that bind-mounts <deployPath>/data read-onl
35972
36183
  const pins = suitePins(record, bundlesOf(modules));
35973
36184
  await kitCheck(deps, record.leadDevelopment, pins.map((p) => ({ id: p.module, url: p.rc })), "rc bundles");
35974
36185
  await deps.verifyLeadCut(record.leadDevelopment);
36186
+ for (const member of record.members) {
36187
+ const prior = modules.find((m) => m.id === member.id)?.bundles.main;
36188
+ if (!member.carried && !member.priorMain && prior && pinVersion(prior) !== suiteReleaseTag(member.tag)) member.priorMain = prior;
36189
+ }
36190
+ writeSuiteCut(deps.recordPath, record);
35975
36191
  const pr = await deps.pin({ cut: record.leadDevelopment, pins, heldIds: record.heldMembers?.map((h) => h.id) });
35976
36192
  await deps.verifyLeadCut(record.leadDevelopment);
35977
36193
  if (record.pinPr && record.pinPr !== pr.pr) throw new Error(`pin PR changed from ${record.pinPr} to ${pr.pr}; stop`);
@@ -36075,6 +36291,112 @@ Each member's live release must serve <data>/main/module.js at its main pin path
36075
36291
  }
36076
36292
  return record;
36077
36293
  }
36294
+ var hotfixRecordPath = (deps) => (0, import_node_path29.join)((0, import_node_path29.dirname)(deps.recordPath), "hotfix.json");
36295
+ function readSuiteHotfix(path2) {
36296
+ let raw;
36297
+ try {
36298
+ raw = (0, import_node_fs29.readFileSync)(path2, "utf8");
36299
+ } catch (e) {
36300
+ if (e.code === "ENOENT") return void 0;
36301
+ throw e;
36302
+ }
36303
+ let r;
36304
+ try {
36305
+ r = JSON.parse(raw);
36306
+ } catch {
36307
+ throw new Error(`suite hotfix record unreadable at ${path2}; repair it, never restart`);
36308
+ }
36309
+ if (r.schema !== "mmi-suite-hotfix/v1" || !r.repo || !r.member || !r.from || r.tag !== void 0 && !RELEASE_TAG.test(r.tag) || r.released && !r.tag || r.pinMerged && !r.released || r.leadTag !== void 0 && (!RELEASE_TAG.test(r.leadTag) || !r.pinMerged) || r.leadReleased && !r.leadTag || r.mapped && !r.leadReleased)
36310
+ throw new Error(`suite hotfix record has unknown or invalid state at ${path2}; repair it, never restart`);
36311
+ return r;
36312
+ }
36313
+ function writeSuiteHotfix(path2, record) {
36314
+ writeSuiteCut(path2, record);
36315
+ }
36316
+ async function suiteHotfix(deps, opts) {
36317
+ if (!opts.ownerGo && !opts.dryRun) throw new Error("suite hotfix requires --owner-go (it ships a member and MM-Work to production); nothing was written");
36318
+ const wanted = opts.member.toLowerCase();
36319
+ const found = suiteMembers(await deps.registry()).find((m) => m.repo.toLowerCase() === wanted || m.repo.split("/")[1].toLowerCase() === wanted || m.id === wanted);
36320
+ if (!found) throw new Error(`${opts.member} is not an MM-Work suite member; nothing was written`);
36321
+ if (found.repo === SUITE_REPO) throw new Error(`${SUITE_REPO} is the suite lead; hotfix a member, this command cuts the lead; nothing was written`);
36322
+ const held = await deps.hold(found);
36323
+ if (held) throw new Error(`${found.repo} is HELD (${held.reason}); clear it with suite hold clear before a suite hotfix; nothing was written`);
36324
+ const plan = [
36325
+ `${found.repo}: hotfix start --from ${opts.from} + release (suite-solo, in-process)`,
36326
+ `${SUITE_REPO}: main pin PR for ${found.id}, wait for merge`,
36327
+ `${SUITE_REPO}: hotfix (patch) carrying only the merged pin PR onto main`,
36328
+ `${SUITE_REPO}: record suite version -> member versions on its release`
36329
+ ];
36330
+ if (opts.dryRun) return { plan };
36331
+ const cut = readSuiteCut(deps.recordPath);
36332
+ if (cut && !cut.leadReleased) throw new Error(`an unfinished suite cut (${cut.leadRcTag ?? "no lead tag"}) is in flight; finish it with suite release or retire it with suite abandon first`);
36333
+ const path2 = hotfixRecordPath(deps);
36334
+ let record = readSuiteHotfix(path2);
36335
+ if (record?.mapped) {
36336
+ (0, import_node_fs29.renameSync)(path2, `${path2}.${record.leadTag}.json`);
36337
+ record = void 0;
36338
+ }
36339
+ if (record && record.repo !== found.repo) throw new Error(`an unfinished suite hotfix for ${record.repo} is recorded; finish it (rerun with that member) first`);
36340
+ const rec = record ?? { schema: "mmi-suite-hotfix/v1", member: found.id, repo: found.repo, from: opts.from, ...opts.ack?.length ? { ack: opts.ack } : {} };
36341
+ const save = () => writeSuiteHotfix(path2, rec);
36342
+ save();
36343
+ const resume = `rerun suite hotfix ${found.id} --from ${rec.from} --owner-go to resume`;
36344
+ if (!rec.tag) {
36345
+ const started = await deps.hotfixStart(found.repo, { from: rec.from, ack: rec.ack });
36346
+ if (!RELEASE_TAG.test(started.tag)) throw new Error(`${found.repo}: hotfix start returned no vX.Y.Z tag`);
36347
+ rec.tag = started.tag;
36348
+ save();
36349
+ }
36350
+ if (!rec.released) {
36351
+ let out;
36352
+ try {
36353
+ out = await deps.hotfixRelease(found.repo, rec.tag);
36354
+ } catch (e) {
36355
+ throw new Error(`${e.message}; the hotfix ${rec.tag} is recorded: ${resume}`);
36356
+ }
36357
+ if (out.tag !== rec.tag) throw new Error(`${found.repo}: released ${out.tag}, expected ${rec.tag}; stop`);
36358
+ rec.released = true;
36359
+ save();
36360
+ }
36361
+ if (!rec.pinMerged) {
36362
+ const pin = await deps.soloPin(found.repo, rec.tag);
36363
+ if (pin.status === "superseded") throw new Error(`${found.id} pin at ${rec.tag} is superseded by ${pin.pr}; a newer ${found.id} release owns the pin; stop`);
36364
+ rec.pinPr = pin.pr;
36365
+ save();
36366
+ if (pin.status !== "merged") throw new Error(`pin PR ${pin.pr} is pending; merge it, then ${resume} (the hotfix is not repeated)`);
36367
+ rec.pinMerged = true;
36368
+ save();
36369
+ }
36370
+ if (!rec.leadTag) {
36371
+ const prNumber = /\/pull\/(\d+)$/.exec(rec.pinPr ?? "")?.[1];
36372
+ if (!prNumber) throw new Error(`pin PR ${rec.pinPr ?? "(none)"} has no PR number; MM-Work not hotfixed`);
36373
+ const started = await deps.hotfixStart(SUITE_REPO, { from: prNumber });
36374
+ if (!RELEASE_TAG.test(started.tag)) throw new Error(`${SUITE_REPO}: hotfix start returned no vX.Y.Z tag`);
36375
+ rec.leadTag = started.tag;
36376
+ save();
36377
+ }
36378
+ const leadTag = rec.leadTag;
36379
+ if (!rec.leadReleased) {
36380
+ let out;
36381
+ try {
36382
+ out = await deps.hotfixRelease(SUITE_REPO, leadTag);
36383
+ } catch (e) {
36384
+ throw new Error(`${e.message}; the MM-Work hotfix ${leadTag} is recorded: ${resume}`);
36385
+ }
36386
+ if (out.tag !== leadTag) throw new Error(`${SUITE_REPO}: released ${out.tag}, expected ${leadTag}; stop`);
36387
+ rec.leadReleased = true;
36388
+ save();
36389
+ }
36390
+ if (!rec.mapped) {
36391
+ const mapping = await deps.suiteMapping(leadTag);
36392
+ if (mapping[found.id] !== rec.tag) throw new Error(`MM-Work ${leadTag} pins ${found.id} at ${mapping[found.id] ?? "nothing"}, not the hotfix ${rec.tag}; mapping not recorded`);
36393
+ await deps.recordMapping(leadTag, mapping);
36394
+ rec.mapping = mapping;
36395
+ rec.mapped = true;
36396
+ save();
36397
+ }
36398
+ return { plan, record: rec };
36399
+ }
36078
36400
 
36079
36401
  // src/suite-rc-data.ts
36080
36402
  var RC_DATA_VAULT_REPO = "mutmutco/MM-Work";
@@ -36593,8 +36915,8 @@ async function preflightHotfixBatchPicks(deps, sources, pickTolerated) {
36593
36915
  if (sources.length === 0) return { failures: [], skipped: [] };
36594
36916
  const { mkdtemp: mkdtemp2, rm: rm2 } = await import("node:fs/promises");
36595
36917
  const { tmpdir: tmpdir11 } = await import("node:os");
36596
- const { join: join45 } = await import("node:path");
36597
- const dir = await mkdtemp2(join45(tmpdir11(), "mmi-hotfix-preflight-"));
36918
+ const { join: join46 } = await import("node:path");
36919
+ const dir = await mkdtemp2(join46(tmpdir11(), "mmi-hotfix-preflight-"));
36598
36920
  const wt = { run: (file, args) => deps.run(file, file === "git" ? ["-C", dir, ...args] : args) };
36599
36921
  const failures = [];
36600
36922
  const skipped = [];
@@ -39767,6 +40089,59 @@ async function openSuiteCutPinPr(deps, input) {
39767
40089
  if (scratch) await (0, import_promises6.rm)(scratch, { recursive: true, force: true });
39768
40090
  }
39769
40091
  }
40092
+ async function openSuiteRestorePinPr(deps, input) {
40093
+ if (!/^[a-f0-9]{40}$/.test(input.cut)) throw new Error("suite restore PR needs an anchored cut");
40094
+ const branch = `suite-pin/restore-${input.cut.slice(0, 16)}`;
40095
+ let scratch = "";
40096
+ try {
40097
+ const prs = JSON.parse(await deps.run("gh", ["pr", "list", "--repo", SUITE_REPO, "--head", branch, "--state", "all", "--json", "url,state"]));
40098
+ if (prs.some((p) => p.state === "CLOSED")) throw new Error(`${branch} was closed without landing`);
40099
+ if (prs.length) return { status: prs.some((p) => p.state === "MERGED") ? "merged" : "pending", pr: prs[0].url };
40100
+ const get = JSON.parse(await deps.run("gh", ["api", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=development`]));
40101
+ if (!get.content) throw new Error("MM-Work seed/modules.json is unreadable");
40102
+ const rows = JSON.parse(Buffer.from(get.content, "base64").toString("utf8"));
40103
+ const todo = input.pins.filter((p) => {
40104
+ try {
40105
+ return new URL(rows.find((r) => r.id === p.module)?.bundles?.main ?? "").searchParams.get("v") === p.planned;
40106
+ } catch {
40107
+ return false;
40108
+ }
40109
+ });
40110
+ if (!todo.length) return void 0;
40111
+ await (0, import_promises6.mkdir)((0, import_node_path34.join)(deps.root, "tmp"), { recursive: true });
40112
+ scratch = await (0, import_promises6.mkdtemp)((0, import_node_path34.join)(deps.root, "tmp", "suite-pin-"));
40113
+ const run = (file, args) => deps.run(file, args, scratch);
40114
+ await run("git", ["clone", "--quiet", "--branch", "development", "--single-branch", `https://github.com/${SUITE_REPO}.git`, "."]);
40115
+ await run("git", ["switch", "-c", branch]);
40116
+ const path2 = (0, import_node_path34.join)(scratch, "main-pins.json");
40117
+ await (0, import_promises6.writeFile)(path2, JSON.stringify(todo.map((p) => ({ module: p.module, url: p.prior }))));
40118
+ await run("node", ["scripts/pin-modules.mjs", "--stage", "main", "--pins", path2]);
40119
+ await run("git", ["add", "seed/modules.json"]);
40120
+ await run("git", ["commit", "-m", `Restore main pins of abandoned suite cut ${input.cut.slice(0, 12)} (#7758)`]);
40121
+ await run("git", ["push", "origin", branch]);
40122
+ const pr = (await run("gh", [
40123
+ "pr",
40124
+ "create",
40125
+ "--repo",
40126
+ SUITE_REPO,
40127
+ "--base",
40128
+ "development",
40129
+ "--head",
40130
+ branch,
40131
+ "--title",
40132
+ "Restore main pins of abandoned suite cut",
40133
+ "--body",
40134
+ `Abandoned cut ${input.cut.slice(0, 12)} pinned main to releases that never shipped (${todo.map((p) => `${p.module} ${p.planned}`).join(", ")}); restore the pre-cut pins (#7758).`
40135
+ ])).trim();
40136
+ if (!pr.includes("/pull/")) throw new Error(`gh pr create returned no PR URL: ${pr}`);
40137
+ await run("gh", ["pr", "merge", pr, "--repo", SUITE_REPO, "--auto", "--squash"]);
40138
+ return { status: "pending", pr };
40139
+ } catch (e) {
40140
+ throw new Error(`suite restore pin PR FAILED: ${e.message}; rerun suite abandon --owner-go (the record is kept until the restore PR exists)`);
40141
+ } finally {
40142
+ if (scratch) await (0, import_promises6.rm)(scratch, { recursive: true, force: true });
40143
+ }
40144
+ }
39770
40145
  async function openSuitePinPr(deps, input) {
39771
40146
  const { repo, tag, stage } = input;
39772
40147
  const remedy = `open/merge the ${SUITE_REPO} development pin PR for ${repo} ${tag}, then rerun the same train command; do not recut the tag \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#suite-pin-pr`;
@@ -39978,7 +40353,7 @@ init_github_quota();
39978
40353
 
39979
40354
  // src/issue-commands.ts
39980
40355
  var import_node_fs34 = require("node:fs");
39981
- var import_node_crypto14 = require("node:crypto");
40356
+ var import_node_crypto15 = require("node:crypto");
39982
40357
  init_cli_shared();
39983
40358
  init_clean_exit();
39984
40359
  init_error_codes();
@@ -40451,7 +40826,7 @@ function rowIdempotencyKey(batchKey, spec) {
40451
40826
  const identity = `${spec.type}
40452
40827
  ${spec.title.trim()}
40453
40828
  ${spec.body ?? ""}`;
40454
- const hash = (0, import_node_crypto14.createHash)("sha256").update(identity).digest("hex").slice(0, 16);
40829
+ const hash = (0, import_node_crypto15.createHash)("sha256").update(identity).digest("hex").slice(0, 16);
40455
40830
  return `${batchKey}:${hash}`;
40456
40831
  }
40457
40832
  var BATCH_SPEC_KEYS = /* @__PURE__ */ new Set(["type", "title", "body", "priority", "labels", "label", "parent", "repo", "surface"]);
@@ -45839,7 +46214,7 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
45839
46214
  async function pinSoloMember(repo, tag) {
45840
46215
  const read = await fetchProjectBySlugChecked(slugOf(repo), registryClientDeps(await loadConfig()));
45841
46216
  if (!read.ok || !read.project) throw new Error(`suite pin PR FAILED: registry META unreadable for ${repo} (${read.ok ? "missing" : read.error}); rerun the same train command after restoring registry access \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#suite-pin-pr`);
45842
- if (!suiteMemberRefusal(repo, read.project, "release")) return void 0;
46217
+ if (!isBundleOnlySuiteMember(repo, read.project.suite)) return void 0;
45843
46218
  const root = (await execFileP("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
45844
46219
  return openSuitePinPr({ root, run: async (file, args, cwd) => (await execFileP(file, args, { cwd, timeout: GIT_TIMEOUT_MS })).stdout }, { repo, tag, stage: "main" });
45845
46220
  }
@@ -45874,8 +46249,8 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
45874
46249
  } catch (e) {
45875
46250
  return fail(`${commandName}: ${e.message}`);
45876
46251
  }
45877
- if (o.suiteSolo !== o.ownerGo || commandName === "rcand" && (o.suiteSolo || o.ownerGo))
45878
- return fail(`${commandName}: solo suite releases require BOTH --suite-solo --owner-go on release or hotfix release; rcand must run from the suite lead`);
46252
+ if (o.suiteSolo !== o.ownerGo)
46253
+ return fail(`${commandName}: --suite-solo and --owner-go must be passed together (a solo member release/hotfix, or a lead-only cut; a member rcand is always refused)`);
45879
46254
  if (o.ack && commandName !== "release") {
45880
46255
  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 devops release --ack <shas>`);
45881
46256
  }
@@ -46377,16 +46752,18 @@ function suiteTrainIo(root, summaryFile, memberSummaryFiles = {}) {
46377
46752
  throw new Error(`${repo}: suite checkout origin mismatch at ${target}; stop`);
46378
46753
  return target;
46379
46754
  };
46380
- const inTrain = async (repo, action) => {
46755
+ const inTrain = async (repo, action, solo = false) => {
46381
46756
  const target = await checkout(repo);
46382
46757
  const before = process.cwd();
46383
46758
  process.chdir(target);
46384
46759
  try {
46385
46760
  const deps = trainApplyDeps();
46386
- if (repo !== SUITE_REPO) {
46761
+ if (solo) {
46762
+ } else if (repo !== SUITE_REPO) {
46387
46763
  deps.suiteCut = suiteCutContext(repo, SUITE_REPO);
46388
46764
  deps.announce = async () => ({ status: "skipped", note: "suite announcement belongs to MM-Work" });
46389
46765
  } else {
46766
+ deps.suiteCut = suiteLeadContext(SUITE_REPO);
46390
46767
  deps.announce = async () => ({ status: "skipped", note: "suite announcement after all members" });
46391
46768
  }
46392
46769
  return await action(deps);
@@ -46595,12 +46972,18 @@ function suiteTrainIo(root, summaryFile, memberSummaryFiles = {}) {
46595
46972
  return false;
46596
46973
  }
46597
46974
  },
46975
+ changedPaths: async (repo, base2, head) => {
46976
+ const target = await checkout(repo);
46977
+ await exec("git", ["fetch", "--quiet", "origin", "--tags"], target, WATCH_TIMEOUT);
46978
+ return (await exec("git", ["diff", "--name-only", base2, head], target)).split("\n").map((f) => f.trim()).filter(Boolean);
46979
+ },
46598
46980
  rcand: runRcand,
46599
46981
  publish,
46600
46982
  preflight: preflight2,
46601
46983
  publishMain,
46602
46984
  preflightMain,
46603
46985
  pin: (input) => openSuiteCutPinPr({ root, run: (file, args, cwd) => exec(file, args, cwd, GIT_TIMEOUT_MS) }, input),
46986
+ restoreMain: (input) => openSuiteRestorePinPr({ root, run: (file, args, cwd) => exec(file, args, cwd, GIT_TIMEOUT_MS) }, input),
46604
46987
  verifyLeadCut: async (sha) => {
46605
46988
  const current = await gh(["api", `repos/${SUITE_REPO}/git/ref/heads/development`, "--jq", ".object.sha"]);
46606
46989
  if (current.toLowerCase() === sha.toLowerCase()) return;
@@ -46632,6 +47015,39 @@ function suiteTrainIo(root, summaryFile, memberSummaryFiles = {}) {
46632
47015
  return void 0;
46633
47016
  }
46634
47017
  },
47018
+ // #7738: a member hotfixes as suite-solo; the lead hotfixes under its suite-lead capability (inTrain sets it).
47019
+ hotfixStart: (repo, o) => inTrain(repo, async (deps) => {
47020
+ const r = await runHotfixStart(deps, { from: o.from, ack: o.ack, suiteSolo: repo !== SUITE_REPO });
47021
+ return { tag: r.tag };
47022
+ }, repo !== SUITE_REPO),
47023
+ hotfixRelease: (repo, tag) => inTrain(repo, async (deps) => ({ tag: (await runHotfixRelease(deps, tag, { suiteSolo: repo !== SUITE_REPO })).tag }), repo !== SUITE_REPO),
47024
+ // #7738: the #7714 solo pin leg, polled until the auto-merge lands (30 s x 40 = 20 min), then the caller decides.
47025
+ soloPin: async (repo, tag) => {
47026
+ for (let i = 0; ; i++) {
47027
+ const pin = await openSuitePinPr({ root, run: (file, args, cwd) => exec(file, args, cwd, GIT_TIMEOUT_MS) }, { repo, tag, stage: "main" });
47028
+ if (pin.status !== "pending" || i >= 40) return pin;
47029
+ await new Promise((resolve7) => setTimeout(resolve7, 3e4));
47030
+ }
47031
+ },
47032
+ suiteMapping: async (leadTag) => {
47033
+ const rows = suiteMembers(await registry2(leadTag)).filter((m) => m.repo !== SUITE_REPO);
47034
+ return Object.fromEntries(rows.map((m) => {
47035
+ const v = bundleTag(m.bundles.main);
47036
+ if (!v) throw new Error(`${m.id}: MM-Work ${leadTag} has no versioned main pin; mapping not recorded`);
47037
+ return [m.id, v];
47038
+ }));
47039
+ },
47040
+ recordMapping: async (leadTag, mapping) => {
47041
+ const body = await gh(["release", "view", leadTag, "--repo", SUITE_REPO, "--json", "body", "--jq", ".body"]);
47042
+ if (parseSuiteMapping(body)) return;
47043
+ const dir = repoRuntimeStatePath(root, "suite");
47044
+ (0, import_node_fs43.mkdirSync)(dir, { recursive: true });
47045
+ const file = (0, import_node_path43.join)(dir, `notes-${leadTag}.md`);
47046
+ (0, import_node_fs43.writeFileSync)(file, `${body}
47047
+
47048
+ ${suiteMappingBlock(leadTag, mapping)}`);
47049
+ await gh(["release", "edit", leadTag, "--repo", SUITE_REPO, "--notes-file", file]);
47050
+ },
46635
47051
  release,
46636
47052
  shippedTag,
46637
47053
  sameContent: sameContent2,
@@ -46688,9 +47104,9 @@ function registerSuiteCommands(program3) {
46688
47104
  if (repo?.toLowerCase() !== SUITE_REPO.toLowerCase()) throw new Error(`suite commands run only from ${SUITE_REPO} (got ${repo ?? "unknown"})`);
46689
47105
  return (await execFileP("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
46690
47106
  };
46691
- suite.command("rcand").description("cut all module candidates, publish bundle-only rc, merge one two-stage pin PR, then cut MM-Work").option("--owner-go", "explicit approval: rc tests on live data").option("--dry-run", "show the roster and sequence without writing").option("--json", "structured receipt").action(async (o) => {
47107
+ suite.command("rcand").description("cut all module candidates, publish bundle-only rc, merge one two-stage pin PR, then cut MM-Work").option("--owner-go", "explicit approval: rc tests on live data (not needed for --dry-run)").option("--dry-run", "read-only: per member HELD / carried / new candidate, flagging a pending backend release; no --owner-go needed. Shows the roster and sequence without writing").option("--json", "structured receipt").action(async (o) => {
46692
47108
  try {
46693
- if (!o.ownerGo) throw new Error("suite rcand requires --owner-go (rc uses live data); nothing was written");
47109
+ if (!o.ownerGo && !o.dryRun) throw new Error("suite rcand requires --owner-go (rc uses live data); nothing was written");
46694
47110
  const root = await leadRoot();
46695
47111
  const unlock = o.dryRun ? async () => void 0 : await acquireTrainWriterLock(root, "suite rcand");
46696
47112
  let result;
@@ -46709,19 +47125,39 @@ function registerSuiteCommands(program3) {
46709
47125
  return fail(`suite rcand: ${e.message}`);
46710
47126
  }
46711
47127
  });
47128
+ const holdCmd = suite.command("hold").description("freeze or unfreeze a member live release in the suite train (registry META suiteHold, #7735)");
47129
+ const holdWrite = async (member, hold) => {
47130
+ try {
47131
+ const root = await leadRoot();
47132
+ const reg = registryClientDeps(await loadConfig());
47133
+ console.log(await setSuiteHold({
47134
+ registry: () => suiteTrainIo(root).registry(),
47135
+ read: (slug) => fetchProjectBySlugChecked(slug, reg),
47136
+ write: (slug, patch) => upsertProject(slug, patch, reg)
47137
+ }, member, hold));
47138
+ } catch (e) {
47139
+ return fail(`suite hold: ${e.message}`);
47140
+ }
47141
+ };
47142
+ holdCmd.command("set <member-repo>").description("cut the member out of the suite train until cleared").requiredOption("--reason <text>", "why the live release is frozen").option("--since <date>", "when the freeze began (e.g. 2026-09-28)").action((member, o) => holdWrite(member, { reason: o.reason, ...o.since ? { since: o.since } : {} }));
47143
+ holdCmd.command("clear <member-repo>").description("put the member back in the suite train").action((member) => holdWrite(member, null));
46712
47144
  suite.command("abandon").description("retire an unfinished cut: archive the record; member rc tags stay (#7396). A cut whose pin PR landed but released nothing needs --owner-go (#7450)").option("--owner-go", "explicit owner approval to retire a cut whose pin PR already landed").option("--json", "structured receipt").action(async (o) => {
46713
47145
  try {
46714
47146
  const root = await leadRoot();
46715
47147
  const unlock = await acquireTrainWriterLock(root, "suite abandon");
46716
47148
  let result;
47149
+ let restored;
46717
47150
  try {
46718
- result = suiteAbandon(suiteTrainIo(root).recordPath, o.ownerGo === true);
47151
+ const io = suiteTrainIo(root);
47152
+ restored = o.ownerGo === true ? await suiteRestoreMainPins(io) : void 0;
47153
+ result = suiteAbandon(io.recordPath, o.ownerGo === true);
46719
47154
  } finally {
46720
47155
  await unlock();
46721
47156
  }
46722
47157
  console.log(o.json ? JSON.stringify(result, null, 2) : [
46723
47158
  `suite abandon: archived the cut record to ${result.archive}`,
46724
47159
  ...result.orphanTags.length ? [" member rc tags left in place (immutable; the next cut reuses one still unreleased on its rc head, #7450):", ...result.orphanTags.map((t) => ` - ${t}`)] : [" no member rc tag was minted"],
47160
+ ...restored ? [` main pins the cut planned for unreleased tags are restored by ${restored.pr} (${restored.status}, #7758)`] : [],
46725
47161
  " the next suite rcand --owner-go re-snapshots every member"
46726
47162
  ].join("\n"));
46727
47163
  } catch (e) {
@@ -46753,6 +47189,26 @@ function registerSuiteCommands(program3) {
46753
47189
  return fail(`suite release: ${e.message}`);
46754
47190
  }
46755
47191
  });
47192
+ suite.command("hotfix <member>").description("member hotfix, main pin PR, MM-Work patch release, and the recorded suite version -> member versions mapping (#7738)").requiredOption("--from <pr#|sha[,pr#|sha...]>", "merged development PR(s) or SHA(s) the member hotfix carries").option("--ack <shas>", "dev shas verified as not needed on main (see hotfix start --ack)").option("--owner-go", "explicit owner approval: ships the member and MM-Work to production (not needed for --dry-run)").option("--dry-run", "read-only: print the ordered plan; writes nothing").option("--json", "structured receipt").action(async (member, o) => {
47193
+ try {
47194
+ if (!o.ownerGo && !o.dryRun) throw new Error("suite hotfix requires --owner-go (it ships to production); nothing was written. --dry-run prints the plan");
47195
+ const root = await leadRoot();
47196
+ const unlock = o.dryRun ? async () => void 0 : await acquireTrainWriterLock(root, "suite hotfix");
47197
+ let result;
47198
+ try {
47199
+ result = await suiteHotfix(suiteTrainIo(root), { member, from: o.from, ack: o.ack?.split(",").map((s) => s.trim()).filter(Boolean), ownerGo: o.ownerGo, dryRun: o.dryRun });
47200
+ } finally {
47201
+ await unlock();
47202
+ }
47203
+ console.log(o.json ? JSON.stringify(result, null, 2) : [
47204
+ `suite hotfix ${o.dryRun ? "dry-run" : "receipt"}:`,
47205
+ ...result.plan.map((line) => ` - ${line}`),
47206
+ ...result.record?.mapping ? [` - MM-Work ${result.record.leadTag} = members ${Object.entries(result.record.mapping).map(([id, v]) => `${id} ${v}`).join(", ")}`] : []
47207
+ ].join("\n"));
47208
+ } catch (e) {
47209
+ return fail(`suite hotfix: ${e.message}`);
47210
+ }
47211
+ });
46756
47212
  suite.command("rc-data").description("switch the rc Preview envs between the live and rc data sets as one bound switch (#7588)").option("--to <direction>", "switch source: live or rc").option("--apply", "write the switch (default is a dry-run plan)").option("--owner-go", "explicit approval: touches the live data key").option("--json", "structured receipt").action(async (o) => {
46757
47213
  try {
46758
47214
  if (o.to !== "live" && o.to !== "rc") throw new Error(`--to must be live or rc (got ${o.to ?? "nothing"}); nothing was written`);
@@ -46778,56 +47234,101 @@ function registerSuiteCommands(program3) {
46778
47234
  return fail(`suite rc-data: ${e.message}`);
46779
47235
  }
46780
47236
  });
46781
- suite.command("status").description("compare served versions with development, rc and main branch heads across MM-Work modules").option("--suite <name>", "suite name (mm-work)", "mm-work").option("--json", "structured output").action(async (o) => {
47237
+ suite.command("status").description("compare served versions with development, rc and main branch heads across MM-Work modules").option("--suite <name>", "suite name (mm-work)", "mm-work").option("--json", "structured output").option("--markdown", "print a markdown docs table of member versions per stage and the suite tag that pins each").action(async (o) => {
46782
47238
  if (o.suite !== "mm-work") return fail("suite status: only mm-work is supported");
46783
47239
  const github = defaultGitHubClient();
46784
47240
  const cfg = await loadConfig();
46785
47241
  const registry2 = registryClientDeps(cfg);
46786
47242
  try {
46787
- const report = await readSuiteStatus({
46788
- registry: async () => {
46789
- const file = await github.rest("GET", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=development`);
46790
- if (!file?.content) throw new Error("MM-Work module registry is unavailable");
46791
- return JSON.parse(decodeGitHubContents(file.content));
46792
- },
46793
- head: async (repo, branch) => {
46794
- try {
46795
- const ref = await github.rest("GET", `repos/${repo}/git/ref/heads/${branch}`);
46796
- return ref?.object?.sha ?? "";
46797
- } catch (e) {
46798
- if (e instanceof GitHubApiError && e.status === 404) return null;
46799
- throw e;
46800
- }
46801
- },
46802
- tagCommit: async (repo, tag) => {
46803
- const commit = await github.rest("GET", `repos/${repo}/commits/${encodeURIComponent(tag)}`);
46804
- return commit?.sha;
46805
- },
46806
- deploy: async (repo, stage) => {
46807
- const slug = repo.split("/")[1].toLowerCase();
46808
- const facts = await fetchDeployFactsBySlug(slug, registry2);
46809
- if (!facts) return void 0;
46810
- const url = publicUrlFromDeployFact(facts.stages[stage]);
46811
- const [health, lastRun] = await Promise.all([
46812
- url ? probePublicHealth(url) : Promise.resolve(void 0),
46813
- fetchLastTenantDeployRun(slug, stage)
46814
- ]);
46815
- return buildDeployStatusReport({
46816
- repo,
46817
- slug,
46818
- stage,
46819
- deployFacts: facts,
46820
- lastRun: lastRun.run,
46821
- lastRunAbsence: lastRun.absence,
46822
- health
46823
- });
46824
- }
46825
- });
46826
- console.log(o.json ? JSON.stringify(report, null, 2) : formatSuiteStatus(report));
47243
+ const report = await readSuiteStatus(suiteStatusDeps(github, registry2));
47244
+ console.log(o.json ? JSON.stringify(report, null, 2) : o.markdown ? formatSuiteMarkdown(report) : formatSuiteStatus(report));
46827
47245
  } catch (e) {
46828
47246
  return fail(`suite status: ${e.message}`);
46829
47247
  }
46830
47248
  });
47249
+ suite.command("dev").description("per member: development head, the version dev serves and the newest green SHA; --wait until none is lagging (#7739)").option("--wait", "poll every 60s until no member is lagging (blocked or unknown members end the wait)").option("--timeout <minutes>", "how long --wait polls", "30").option("--json", "structured output").action(async (o) => {
47250
+ const github = defaultGitHubClient();
47251
+ const registry2 = registryClientDeps(await loadConfig());
47252
+ const status = suiteStatusDeps(github, registry2);
47253
+ const deps = {
47254
+ status: () => readSuiteStatus(status),
47255
+ commits: async (repo, limit) => (await github.rest("GET", `repos/${repo}/commits?sha=development&per_page=${limit}`) ?? []).map((c) => c.sha),
47256
+ checkRuns: async (repo, sha) => (await github.rest("GET", `repos/${repo}/commits/${sha}/check-runs?per_page=100`))?.check_runs ?? [],
47257
+ requiredChecks: async (repo) => {
47258
+ const meta = await fetchProjectBySlugChecked(repo.split("/")[1].toLowerCase(), registry2);
47259
+ if (!meta.ok) throw new Error(`${repo} META unreadable: ${meta.error}`);
47260
+ return meta.project?.requiredChecks ?? ["gate"];
47261
+ }
47262
+ };
47263
+ try {
47264
+ const minutes = Number(o.timeout);
47265
+ if (!Number.isFinite(minutes) || minutes <= 0) return fail("suite dev: --timeout must be a positive number of minutes");
47266
+ const rows = o.wait ? await waitSuiteDev(deps, { timeoutMs: minutes * 6e4, intervalMs: 6e4, onTick: (r) => {
47267
+ if (!o.json) console.error(`suite dev: ${r.filter((x) => x.state === "lagging").length} lagging`);
47268
+ } }) : await readSuiteDev(deps);
47269
+ console.log(o.json ? JSON.stringify(rows, null, 2) : formatSuiteDev(rows));
47270
+ if (rows.some((r) => r.state !== "converged")) process.exitCode = 1;
47271
+ } catch (e) {
47272
+ return fail(`suite dev: ${e.message}`);
47273
+ }
47274
+ });
47275
+ }
47276
+ function suiteStatusDeps(github, registry2) {
47277
+ return {
47278
+ registry: async () => {
47279
+ const file = await github.rest("GET", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=development`);
47280
+ if (!file?.content) throw new Error("MM-Work module registry is unavailable");
47281
+ return JSON.parse(decodeGitHubContents(file.content));
47282
+ },
47283
+ head: async (repo, branch) => {
47284
+ try {
47285
+ const ref = await github.rest("GET", `repos/${repo}/git/ref/heads/${branch}`);
47286
+ return ref?.object?.sha ?? "";
47287
+ } catch (e) {
47288
+ if (e instanceof GitHubApiError && e.status === 404) return null;
47289
+ throw e;
47290
+ }
47291
+ },
47292
+ tagCommit: async (repo, tag) => {
47293
+ try {
47294
+ const commit = await github.rest("GET", `repos/${repo}/commits/${encodeURIComponent(tag)}`);
47295
+ return commit?.sha;
47296
+ } catch (e) {
47297
+ if (e instanceof GitHubApiError && (e.status === 404 || e.status === 422)) return void 0;
47298
+ throw e;
47299
+ }
47300
+ },
47301
+ fetchUrl: async (url) => {
47302
+ const res = await fetch(url, { signal: AbortSignal.timeout(1e4), redirect: "follow" });
47303
+ return { status: res.status, body: new Uint8Array(await res.arrayBuffer()) };
47304
+ },
47305
+ leadTags: async () => (await github.rest("GET", `repos/${SUITE_REPO}/tags?per_page=100`) ?? []).map((t) => t.name),
47306
+ leadReleaseBody: async (tag) => (await github.rest("GET", `repos/${SUITE_REPO}/releases/tags/${encodeURIComponent(tag)}`))?.body ?? null,
47307
+ registryAt: async (ref) => {
47308
+ const file = await github.rest("GET", `repos/${SUITE_REPO}/contents/seed/modules.json?ref=${encodeURIComponent(ref)}`);
47309
+ if (!file?.content) throw new Error(`MM-Work seed/modules.json at ${ref} is unavailable`);
47310
+ return JSON.parse(decodeGitHubContents(file.content));
47311
+ },
47312
+ deploy: async (repo, stage) => {
47313
+ const slug = repo.split("/")[1].toLowerCase();
47314
+ const facts = await fetchDeployFactsBySlug(slug, registry2);
47315
+ if (!facts) return void 0;
47316
+ const url = publicUrlFromDeployFact(facts.stages[stage]);
47317
+ const [health, lastRun] = await Promise.all([
47318
+ url ? probePublicHealth(url) : Promise.resolve(void 0),
47319
+ fetchLastTenantDeployRun(slug, stage)
47320
+ ]);
47321
+ return buildDeployStatusReport({
47322
+ repo,
47323
+ slug,
47324
+ stage,
47325
+ deployFacts: facts,
47326
+ lastRun: lastRun.run,
47327
+ lastRunAbsence: lastRun.absence,
47328
+ health
47329
+ });
47330
+ }
47331
+ };
46831
47332
  }
46832
47333
 
46833
47334
  // src/discovery-commands.ts
@@ -50826,14 +51327,14 @@ vercel preview (${preview.branch}): ${d.state}${d.url ? ` \u2014 ${d.url}` : ""}
50826
51327
  }
50827
51328
 
50828
51329
  // src/tenant-artifact.ts
50829
- var import_node_crypto15 = require("node:crypto");
51330
+ var import_node_crypto16 = require("node:crypto");
50830
51331
  var import_node_fs48 = require("node:fs");
50831
51332
  var import_promises11 = require("node:fs/promises");
50832
51333
  var import_node_path46 = require("node:path");
50833
51334
  var ARTIFACT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
50834
51335
  var MAX_BYTES = 5 * 1024 * 1024 * 1024;
50835
51336
  async function sha256File(path2) {
50836
- const hash = (0, import_node_crypto15.createHash)("sha256");
51337
+ const hash = (0, import_node_crypto16.createHash)("sha256");
50837
51338
  for await (const chunk of (0, import_node_fs48.createReadStream)(path2)) hash.update(chunk);
50838
51339
  return hash.digest("hex");
50839
51340
  }
@@ -50843,8 +51344,8 @@ async function putTenantArtifact(repo, stage, inputPath, deps) {
50843
51344
  const info = await (0, import_promises11.stat)(path2);
50844
51345
  if (!info.isFile()) throw new Error("tenant artifact put: input path must be a file");
50845
51346
  if (!Number.isSafeInteger(info.size) || info.size < 1 || info.size > MAX_BYTES) throw new Error(`tenant artifact put: file must be 1..${MAX_BYTES} bytes`);
50846
- const sha2562 = await sha256File(path2);
50847
- const prepared = await tenantArtifactUpload({ repo, stage, size: info.size, sha256: sha2562 }, deps);
51347
+ const sha2563 = await sha256File(path2);
51348
+ const prepared = await tenantArtifactUpload({ repo, stage, size: info.size, sha256: sha2563 }, deps);
50848
51349
  if (!prepared.ok) {
50849
51350
  const detail = prepared.body?.error ?? prepared.error ?? `HTTP ${prepared.status}`;
50850
51351
  throw new Error(`tenant artifact put: ${detail}`);
@@ -50872,8 +51373,8 @@ async function putTenantArtifact(repo, stage, inputPath, deps) {
50872
51373
  throw error;
50873
51374
  }
50874
51375
  if (!uploaded.ok) throw new Error(`tenant artifact put: object upload failed (HTTP ${uploaded.status})`);
50875
- if (body.size !== info.size || body.sha256 !== sha2562 || typeof body.expiresAt !== "string" || !Number.isFinite(Date.parse(body.expiresAt))) throw new Error("tenant artifact put: Hub returned inconsistent artifact metadata");
50876
- return { artifactId: body.artifactId, repo, stage, size: info.size, sha256: sha2562, expiresAt: body.expiresAt };
51376
+ if (body.size !== info.size || body.sha256 !== sha2563 || typeof body.expiresAt !== "string" || !Number.isFinite(Date.parse(body.expiresAt))) throw new Error("tenant artifact put: Hub returned inconsistent artifact metadata");
51377
+ return { artifactId: body.artifactId, repo, stage, size: info.size, sha256: sha2563, expiresAt: body.expiresAt };
50877
51378
  }
50878
51379
 
50879
51380
  // src/tenant-sweep.ts
@@ -51063,13 +51564,13 @@ async function executeWaveLand(plan, deps) {
51063
51564
 
51064
51565
  // src/dist-drift.ts
51065
51566
  var import_node_child_process18 = require("node:child_process");
51066
- var import_node_crypto17 = require("node:crypto");
51567
+ var import_node_crypto18 = require("node:crypto");
51067
51568
  var import_node_fs50 = require("node:fs");
51068
51569
  var import_node_os22 = require("node:os");
51069
51570
  var import_node_path48 = require("node:path");
51070
51571
 
51071
51572
  // ../scripts/distribution-digest.mjs
51072
- var import_node_crypto16 = require("node:crypto");
51573
+ var import_node_crypto17 = require("node:crypto");
51073
51574
  var import_node_fs49 = require("node:fs");
51074
51575
  var import_node_path47 = require("node:path");
51075
51576
  var slash = (value) => value.replaceAll("\\", "/");
@@ -51082,7 +51583,7 @@ function repoPath(root, declaredPath, label) {
51082
51583
  return target;
51083
51584
  }
51084
51585
  function digestFiles(files) {
51085
- const hash = (0, import_node_crypto16.createHash)("sha256");
51586
+ const hash = (0, import_node_crypto17.createHash)("sha256");
51086
51587
  for (const file of [...files].sort((a, b) => a.relative.localeCompare(b.relative))) {
51087
51588
  const content = file.stat.isSymbolicLink() ? Buffer.from((0, import_node_fs49.readlinkSync)(file.absolute), "utf8") : (0, import_node_fs49.readFileSync)(file.absolute);
51088
51589
  hash.update(file.relative, "utf8");
@@ -51112,10 +51613,10 @@ var DIST_ARTIFACTS = [
51112
51613
  ];
51113
51614
  var BOM_DIST_TREE_ID = "mmi-cli-dist";
51114
51615
  var ABSENT = "absent";
51115
- var sha256 = (bytes) => `sha256:${(0, import_node_crypto17.createHash)("sha256").update(bytes).digest("hex")}`;
51616
+ var sha2562 = (bytes) => `sha256:${(0, import_node_crypto18.createHash)("sha256").update(bytes).digest("hex")}`;
51116
51617
  function artifactDrift(path2, committedBytes, rebuiltBytes) {
51117
- const committed = committedBytes ? sha256(committedBytes) : ABSENT;
51118
- const rebuiltExpected = rebuiltBytes ? sha256(rebuiltBytes) : ABSENT;
51618
+ const committed = committedBytes ? sha2562(committedBytes) : ABSENT;
51619
+ const rebuiltExpected = rebuiltBytes ? sha2562(rebuiltBytes) : ABSENT;
51119
51620
  return { path: path2, status: committed === rebuiltExpected ? "current" : "stale", committed, rebuiltExpected };
51120
51621
  }
51121
51622
  function distTreeIdentity(labels, rebuilt, committed, digest) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.5.83",
3
+ "version": "4.5.84",
4
4
  "description": "MMI Future CLI — the org dev toolbox and shared cross-IDE engine for every registry-declared MMI coding surface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",