@mutmutco/cli 3.99.0 → 3.101.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 +316 -20
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -12168,6 +12168,16 @@ async function auditRepoCi(repo, deps) {
12168
12168
  });
12169
12169
  }
12170
12170
  }
12171
+ if (repoClass === "deployable") {
12172
+ const exemptReason = meta?.ciExemptReason?.trim();
12173
+ const declaredExempt = statusChecks.size === 0 && !!exemptReason;
12174
+ checks.push({
12175
+ ok: statusChecks.size > 0 || declaredExempt,
12176
+ label: declaredExempt ? "deployable repo declared CI-exempt, not required to gate (#4265)" : "deployable repo requires at least one status check (#4260)",
12177
+ detail: declaredExempt ? `declared-exempt: ${exemptReason}` : statusChecks.size === 0 ? "zero required contexts on an active branch ruleset \u2014 any PR, including an automation seed PR, merges on an empty gate" : void 0,
12178
+ remediation: declaredExempt || statusChecks.size > 0 ? void 0 : `Determine this repo's correct gate context from its own PR-triggered workflow, set it via mmi-cli org project set ${repo} --var requiredChecks=[...], then mmi-cli ci reconcile --repo ${repo} --apply, or, if this repo genuinely has no CI surface, declare it via mmi-cli org project set ${repo} --var ciExemptReason="<why>" (#4265)`
12179
+ });
12180
+ }
12171
12181
  if (deployableGated || repoClass === "hub") {
12172
12182
  const gate = await readDefaultBranchGate(repo, deps.client, baseBranch, Date.now(), gateWorkflowFiles(prWorkflowPaths));
12173
12183
  checks.push({
@@ -13865,6 +13875,12 @@ async function postSchedulesMode(slug, mode, deps) {
13865
13875
  async function postSchedulesRun(id, deps) {
13866
13876
  return postJson("/schedules/run", { id }, deps, "POST", { noRetry: true });
13867
13877
  }
13878
+ async function postSchedulesPark(id, reason, deps) {
13879
+ return postJson("/schedules/park", { id, reason }, deps);
13880
+ }
13881
+ async function postSchedulesUnpark(id, deps) {
13882
+ return postJson("/schedules/unpark", { id }, deps);
13883
+ }
13868
13884
  async function tenantControl(payload, deps) {
13869
13885
  return postJson("/tenant-control", payload, deps, "POST", { noRetry: true });
13870
13886
  }
@@ -20923,7 +20939,7 @@ function authorizeBodyHasMismatch(body) {
20923
20939
  }
20924
20940
 
20925
20941
  // src/project-set.ts
20926
- var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "dsManifestPath", "fofuEnabled", "consumesDesignSystem", "ci", "requiredChecks", "gate"];
20942
+ var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "dsManifestPath", "fofuEnabled", "consumesDesignSystem", "ci", "requiredChecks", "ciExemptReason", "gate", "seedCanary"];
20927
20943
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
20928
20944
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
20929
20945
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -21177,6 +21193,11 @@ function parseRuntimeVaultOnlyVar(raw) {
21177
21193
  if (raw === "false") return false;
21178
21194
  throw new Error("org project set: runtimeVaultOnly must be true or false");
21179
21195
  }
21196
+ function parseSeedCanaryVar(raw) {
21197
+ if (raw === "true") return true;
21198
+ if (raw === "false") return false;
21199
+ throw new Error("org project set: seedCanary must be true or false");
21200
+ }
21180
21201
  function parseConsumesDesignSystemVar(raw) {
21181
21202
  if (raw === "fofu") return raw;
21182
21203
  throw new Error('org project set: consumesDesignSystem must be "fofu"');
@@ -21213,6 +21234,11 @@ function parseRequiredChecksVar(raw) {
21213
21234
  }
21214
21235
  return parsed.map((c) => c.trim());
21215
21236
  }
21237
+ function parseCiExemptReasonVar(raw) {
21238
+ const trimmed = raw.trim();
21239
+ if (!trimmed) throw new Error("org project set: ciExemptReason must be a non-empty reason (or use --unset ciExemptReason to clear it)");
21240
+ return trimmed;
21241
+ }
21216
21242
  function parseGateVar(raw) {
21217
21243
  let parsed;
21218
21244
  try {
@@ -21265,8 +21291,10 @@ var SETTABLE_VAR_KEYS = [
21265
21291
  "portRange",
21266
21292
  "ci",
21267
21293
  "requiredChecks",
21294
+ "ciExemptReason",
21268
21295
  "gate",
21269
- "secrets"
21296
+ "secrets",
21297
+ "seedCanary"
21270
21298
  ];
21271
21299
  var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
21272
21300
  var SETTABLE_VAR_HINTS = {
@@ -21277,6 +21305,7 @@ var SETTABLE_VAR_HINTS = {
21277
21305
  fofuEnabled: "true|false",
21278
21306
  consumesDesignSystem: '"fofu"',
21279
21307
  runtimeVaultOnly: "true|false",
21308
+ seedCanary: "true|false",
21280
21309
  repos: 'JSON array, e.g. ["mutmutco/mm-foo"]',
21281
21310
  oauth: "JSON {subdomains,domains,callbackPath,fofuSubdomain}",
21282
21311
  requiredGcpApis: "comma-string",
@@ -21289,6 +21318,7 @@ var SETTABLE_VAR_HINTS = {
21289
21318
  portRange: "JSON {start,end} or [start,end]",
21290
21319
  ci: "none \u2014 declare intentional no-ci",
21291
21320
  requiredChecks: 'JSON array, e.g. ["gate"] or [] for no-ci',
21321
+ ciExemptReason: "non-empty string \u2014 declares this deployable repo has no CI surface (#4265)",
21292
21322
  gate: "JSON {runtime,cmd,workdir,cacheDepPath,pyVersion}"
21293
21323
  };
21294
21324
  function settableVarHelp() {
@@ -21373,6 +21403,8 @@ function buildProjectSetPatch(input) {
21373
21403
  patch[key] = parseFofuEnabledVar(raw);
21374
21404
  } else if (key === "runtimeVaultOnly") {
21375
21405
  patch[key] = parseRuntimeVaultOnlyVar(raw);
21406
+ } else if (key === "seedCanary") {
21407
+ patch[key] = parseSeedCanaryVar(raw);
21376
21408
  } else if (key === "consumesDesignSystem") {
21377
21409
  patch[key] = parseConsumesDesignSystemVar(raw);
21378
21410
  } else if (key === "publishDir") {
@@ -21384,6 +21416,8 @@ function buildProjectSetPatch(input) {
21384
21416
  patch[key] = raw;
21385
21417
  } else if (key === "requiredChecks") {
21386
21418
  patch[key] = parseRequiredChecksVar(raw);
21419
+ } else if (key === "ciExemptReason") {
21420
+ patch[key] = parseCiExemptReasonVar(raw);
21387
21421
  } else if (key === "gate") {
21388
21422
  patch[key] = parseGateVar(raw);
21389
21423
  } else {
@@ -22721,6 +22755,7 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
22721
22755
  const liveByName = new Map(liveGithub.map((e) => [e.name, e]));
22722
22756
  const registryById = new Map(registryGithub.map((r) => [r.id, r]));
22723
22757
  const drifts = [];
22758
+ const parked = [];
22724
22759
  for (const e of liveGithub) {
22725
22760
  if (!registryById.has(e.name)) {
22726
22761
  drifts.push({
@@ -22733,6 +22768,10 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
22733
22768
  }
22734
22769
  }
22735
22770
  for (const r of registryGithub) {
22771
+ if (r.parkedReason) {
22772
+ parked.push({ id: r.id, executor: r.executor, repo: r.repo, reason: r.parkedReason });
22773
+ continue;
22774
+ }
22736
22775
  const live = liveByName.get(r.id);
22737
22776
  if (live) {
22738
22777
  if (cadenceStale(r.cadence, live.cadence)) {
@@ -22754,7 +22793,7 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
22754
22793
  name: r.id,
22755
22794
  executor: r.executor || "github-actions",
22756
22795
  detail: "workflow file present but DISABLED in GitHub Actions \u2014 the dispatcher cannot run it",
22757
- remedy: `\`gh workflow enable\` it in ${r.repo} to re-arm (or, if it was retired on purpose, re-run schedules register so the replace prunes the SCHEDULE# row) \u2014 never prune a lane parked on purpose`
22796
+ remedy: `\`gh workflow enable\` it in ${r.repo} to re-arm, or if this is a deliberate hold run \`mmi-cli org schedules park ${r.id} --reason <ref>\` (#4257) so it stops reading as drift \u2014 never prune a lane parked on purpose`
22758
22797
  });
22759
22798
  }
22760
22799
  continue;
@@ -22769,7 +22808,10 @@ function reconcileGithubActions(liveGithub, registry2, readRepos, activeWorkflow
22769
22808
  });
22770
22809
  }
22771
22810
  }
22772
- return drifts.sort((a, b) => a.class.localeCompare(b.class) || a.name.localeCompare(b.name));
22811
+ return {
22812
+ drifts: drifts.sort((a, b) => a.class.localeCompare(b.class) || a.name.localeCompare(b.name)),
22813
+ parked: parked.sort((a, b) => a.id.localeCompare(b.id))
22814
+ };
22773
22815
  }
22774
22816
  function suppressSelfManagedDrifts(drifts, selfManagedRepos) {
22775
22817
  return drifts.filter((d) => {
@@ -22780,6 +22822,9 @@ function suppressSelfManagedDrifts(drifts, selfManagedRepos) {
22780
22822
  function renderDrift(d) {
22781
22823
  return `${d.class}: ${d.name} (${d.executor}) \u2014 ${d.detail}; remedy: ${d.remedy}`;
22782
22824
  }
22825
+ function renderParked(p) {
22826
+ return `parked (${p.reason}): ${p.id}`;
22827
+ }
22783
22828
  function strayCronDrift(name) {
22784
22829
  return {
22785
22830
  class: "stray-cron",
@@ -22815,12 +22860,17 @@ function unlauncheredLlmDrifts(entries) {
22815
22860
  }
22816
22861
  var HARBOUR_ARM_GRACE_MS = 30 * 60 * 1e3;
22817
22862
  function registeredButUnarmedDrifts(registry2, liveEntries, opts) {
22818
- if (!opts.schedulerRead) return [];
22863
+ if (!opts.schedulerRead) return { drifts: [], parked: [] };
22819
22864
  const now = opts.now ?? Date.now();
22820
22865
  const armed = new Set(liveEntries.map((e) => e.scheduleId).filter((id) => Boolean(id)));
22821
22866
  const drifts = [];
22867
+ const parked = [];
22822
22868
  for (const row of registry2) {
22823
22869
  if (row.executor === "github-actions") continue;
22870
+ if (row.parkedReason) {
22871
+ parked.push({ id: row.id, executor: row.executor, repo: row.repo, reason: row.parkedReason });
22872
+ continue;
22873
+ }
22824
22874
  if (armed.has(row.id)) continue;
22825
22875
  const stamped = row.updatedAt ? Date.parse(row.updatedAt) : NaN;
22826
22876
  if (Number.isFinite(stamped) && now - stamped < HARBOUR_ARM_GRACE_MS) continue;
@@ -22832,25 +22882,36 @@ function registeredButUnarmedDrifts(registry2, liveEntries, opts) {
22832
22882
  remedy: "check the fleet-clock reconciler tick; re-run `mmi-cli org schedules register` for the repo and verify with `mmi-cli org schedules`"
22833
22883
  });
22834
22884
  }
22835
- return drifts.sort((a, b) => a.name.localeCompare(b.name));
22885
+ return {
22886
+ drifts: drifts.sort((a, b) => a.name.localeCompare(b.name)),
22887
+ parked: parked.sort((a, b) => a.id.localeCompare(b.id))
22888
+ };
22836
22889
  }
22837
22890
  function assembleReconciliation(githubEntries2, readRepos, registry2, activeWorkflowNames = /* @__PURE__ */ new Set(), harbour = {}, disabledWorkflowNames = /* @__PURE__ */ new Set()) {
22838
22891
  if (registry2 === null) {
22839
22892
  return {
22840
22893
  reconciliation: [],
22841
22894
  driftLines: [],
22895
+ parked: [],
22896
+ parkedLines: [],
22842
22897
  incomplete: ["registry: could not read GET /schedules/list \u2014 reconciliation skipped (Hub API unreachable or not authenticated)"]
22843
22898
  };
22844
22899
  }
22845
22900
  const live = githubEntries2.filter((e) => e.executor === "github-actions");
22846
- const drifts = [
22847
- ...reconcileGithubActions(live, registry2, readRepos, activeWorkflowNames, disabledWorkflowNames),
22848
- ...registeredButUnarmedDrifts(registry2, harbour.awsEntries ?? [], {
22849
- schedulerRead: Boolean(harbour.schedulerRead),
22850
- now: harbour.now
22851
- })
22852
- ];
22853
- return { reconciliation: drifts, driftLines: drifts.map(renderDrift), incomplete: [] };
22901
+ const githubResult = reconcileGithubActions(live, registry2, readRepos, activeWorkflowNames, disabledWorkflowNames);
22902
+ const harbourResult = registeredButUnarmedDrifts(registry2, harbour.awsEntries ?? [], {
22903
+ schedulerRead: Boolean(harbour.schedulerRead),
22904
+ now: harbour.now
22905
+ });
22906
+ const drifts = [...githubResult.drifts, ...harbourResult.drifts];
22907
+ const parked = [...githubResult.parked, ...harbourResult.parked].sort((a, b) => a.id.localeCompare(b.id));
22908
+ return {
22909
+ reconciliation: drifts,
22910
+ driftLines: drifts.map(renderDrift),
22911
+ parked,
22912
+ parkedLines: parked.map(renderParked),
22913
+ incomplete: []
22914
+ };
22854
22915
  }
22855
22916
 
22856
22917
  // src/schedules-commands.ts
@@ -23014,7 +23075,9 @@ async function fetchNotebook(client = defaultGitHubClient(), readRegistry = defa
23014
23075
  entries: sortEntries([...gh.entries, ...aws.entries, ...DECLARED_ENTRIES]),
23015
23076
  incomplete: [...gh.incomplete, ...aws.incomplete, ...recon.incomplete],
23016
23077
  drift: driftLines,
23017
- reconciliation
23078
+ reconciliation,
23079
+ parked: recon.parked,
23080
+ parkedLines: recon.parkedLines
23018
23081
  };
23019
23082
  }
23020
23083
  async function defaultProjectsRead() {
@@ -23027,10 +23090,13 @@ function warnIncomplete2(incomplete) {
23027
23090
  function reportDrift(drift) {
23028
23091
  for (const d of drift) console.error(`org schedules: DRIFT \u2014 ${d}`);
23029
23092
  }
23093
+ function reportParked(parked) {
23094
+ for (const p of parked) console.log(`org schedules: ${p}`);
23095
+ }
23030
23096
  function registerSchedulesCommands(program3) {
23031
23097
  const schedules = program3.command("schedules").description("the org schedules notebook \u2014 every armed cron, timer, and scheduled LLM lane, resolved live (jerv side lives in jerv-cli schedules)").option("--json", "machine-readable output (consumed by jerv-cli schedules --all)").option("--doc <path>", "optional snapshot only \u2014 splice inventory between schedules:inventory markers; live `org schedules` / --json remains SSOT (Hub#4120)").action(async (o) => {
23032
23098
  try {
23033
- const { entries, incomplete, drift, reconciliation } = await fetchNotebook();
23099
+ const { entries, incomplete, drift, reconciliation, parked, parkedLines } = await fetchNotebook();
23034
23100
  if (o.doc) {
23035
23101
  if (incomplete.length) {
23036
23102
  warnIncomplete2(incomplete);
@@ -23046,9 +23112,10 @@ function registerSchedulesCommands(program3) {
23046
23112
  }
23047
23113
  if (o.json) {
23048
23114
  const now = /* @__PURE__ */ new Date();
23049
- console.log(JSON.stringify({ org: entries.map((e) => withTickState(e, now)), incomplete, drift, reconciliation }, null, 2));
23115
+ console.log(JSON.stringify({ org: entries.map((e) => withTickState(e, now)), incomplete, drift, reconciliation, parked }, null, 2));
23050
23116
  } else {
23051
23117
  console.log(formatSchedulesTable(entries));
23118
+ reportParked(parkedLines);
23052
23119
  reportDrift(drift);
23053
23120
  warnIncomplete2(incomplete);
23054
23121
  }
@@ -23069,6 +23136,30 @@ function registerSchedulesCommands(program3) {
23069
23136
  await failGraceful(e.message);
23070
23137
  }
23071
23138
  });
23139
+ schedules.command("park <schedule-id>").description("record a deliberate hold on a SCHEDULE# row \u2014 the reconciler skips it and `org schedules` renders `parked (<reason>)` instead of DRIFT (#4257)").requiredOption("--reason <ref>", "why the lane is held, e.g. an issue ref (#1774)").action(async (scheduleId, o) => {
23140
+ try {
23141
+ const res = await postSchedulesPark(scheduleId, o.reason, registryClientDeps(await loadConfig()));
23142
+ if (!res.ok) {
23143
+ await failGraceful(`schedules park: HTTP ${res.status} \u2014 ${JSON.stringify(res.body ?? res.error ?? "failed")}`);
23144
+ return;
23145
+ }
23146
+ console.log(JSON.stringify(res.body, null, 2));
23147
+ } catch (e) {
23148
+ await failGraceful(e.message);
23149
+ }
23150
+ });
23151
+ schedules.command("unpark <schedule-id>").description("clear a deliberate hold on a SCHEDULE# row \u2014 reconciliation resumes normally (#4257)").action(async (scheduleId) => {
23152
+ try {
23153
+ const res = await postSchedulesUnpark(scheduleId, registryClientDeps(await loadConfig()));
23154
+ if (!res.ok) {
23155
+ await failGraceful(`schedules unpark: HTTP ${res.status} \u2014 ${JSON.stringify(res.body ?? res.error ?? "failed")}`);
23156
+ return;
23157
+ }
23158
+ console.log(JSON.stringify(res.body, null, 2));
23159
+ } catch (e) {
23160
+ await failGraceful(e.message);
23161
+ }
23162
+ });
23072
23163
  schedules.command("mode <mode>").description("fleet schedules doctrine for a repo: 'managed' (default \u2014 Hub registry + drift enforcement) or 'self-managed' (opt out; the repo owns its crons). Project-admin self-serve (#3228).").option("--repo <name>", "bare repo name (defaults to the origin remote basename)").action(async (mode, o) => {
23073
23164
  try {
23074
23165
  if (mode !== "managed" && mode !== "self-managed") {
@@ -24017,6 +24108,62 @@ function renderPropagationReport(plan) {
24017
24108
  return lines.join("\n");
24018
24109
  }
24019
24110
 
24111
+ // src/bootstrap-rollback.ts
24112
+ function resolveRollbackRecord(records, repo, target) {
24113
+ const candidates = records.filter((r) => r.repo === repo && r.target === target && r.mergeSha);
24114
+ if (candidates.length === 0) {
24115
+ return { found: false, reason: `no propagation record resolves for ${repo} + ${target} \u2014 refusing to guess a commit to revert` };
24116
+ }
24117
+ const clean4 = candidates.filter((r) => r.files.length === 1 && r.files[0] === target);
24118
+ if (clean4.length === 0) {
24119
+ return {
24120
+ found: false,
24121
+ reason: `${candidates.length} merged seed-propagate PR(s) found for ${repo} + ${target}, but none is a clean single-file propagation of exactly this target \u2014 refusing to guess a commit to revert`
24122
+ };
24123
+ }
24124
+ const latest = clean4.slice().sort((a, b) => a.mergedAt < b.mergedAt ? 1 : a.mergedAt > b.mergedAt ? -1 : 0)[0];
24125
+ return { found: true, record: latest };
24126
+ }
24127
+ function planRollback(repo, target, slug, records) {
24128
+ const resolution = resolveRollbackRecord(records, repo, target);
24129
+ if (!resolution.found) return { repo, target, resolution };
24130
+ const branch = `seed-rollback-${slug}`;
24131
+ const title = `revert: rollback org-owned ${target} in ${repo} (seed PR #${resolution.record.number})`;
24132
+ const body = renderRollbackPrBody(resolution.record);
24133
+ return { repo, target, resolution, branch, title, body };
24134
+ }
24135
+ function renderRollbackPrBody(record) {
24136
+ return [
24137
+ `Per-repo emergency revert of \`${record.target}\`'s seed-propagate merge (\`mmi-cli bootstrap rollback\`, #4240).`,
24138
+ "",
24139
+ `Reverts ${record.url} (merge ${record.mergeSha}) \u2014 restores this repo's copy of \`${record.target}\` to its content immediately before that merge. Nothing else in this repo changes.`,
24140
+ "",
24141
+ "This is the emergency stop, not the fix: MMI-Hub is still the source of truth during a rollback. Closure is the Hub reverting (or fixing forward) the bad seed commit on `development` \u2014 once it does, this repo matches the Hub again and the drift alarm closes itself. A revert with no Hub-side follow-up re-alarms as drift within a week \u2014 deliberately, so an emergency divergence can never silently become permanent.",
24142
+ "",
24143
+ "Never re-run `bootstrap propagate` with the pre-revert bytes before the Hub itself is fixed \u2014 that reopens exactly what this PR closes."
24144
+ ].join("\n");
24145
+ }
24146
+ function renderRollbackReport(plan) {
24147
+ if (!plan.resolution.found) {
24148
+ return `bootstrap rollback \u2014 ${plan.repo} / ${plan.target}: REFUSED \u2014 ${plan.resolution.reason}`;
24149
+ }
24150
+ const r = plan.resolution.record;
24151
+ const opened = plan.prUrl ? ` \u2014 opened ${plan.prUrl}` : "";
24152
+ return `bootstrap rollback \u2014 ${plan.repo} / ${plan.target}: reverting PR#${r.number} (merge ${r.mergeSha}${r.mergedAt ? `, merged ${r.mergedAt}` : ""}) on branch ${plan.branch}${opened}`;
24153
+ }
24154
+ function seedPrRecordFromPropagationRecord(record) {
24155
+ if (!record.mergeSha || record.prNumber == null || !record.prUrl) return null;
24156
+ return {
24157
+ repo: record.repo,
24158
+ target: record.target,
24159
+ number: record.prNumber,
24160
+ url: record.prUrl,
24161
+ mergeSha: record.mergeSha,
24162
+ mergedAt: "",
24163
+ files: [record.target]
24164
+ };
24165
+ }
24166
+
24020
24167
  // src/bootstrap-verify.ts
24021
24168
  var TRAIN_BRANCHES2 = ["development", "rc", "main"];
24022
24169
  var requiredDocs = ["README.md", "architecture.md", "docs/decisions/README.md", "docs/index.md"];
@@ -25145,9 +25292,28 @@ LIVE apply to ${repo}:
25145
25292
  }
25146
25293
  const bySlugMeta = new Map(projects.flatMap((p) => (p.repos ?? []).map((r) => [(r.includes("/") ? r : `mutmutco/${r}`).toLowerCase(), p])));
25147
25294
  const classOf = (repo) => bySlugMeta.get(repo.toLowerCase())?.class ?? "deployable";
25148
- const canaryProject = projects.find((p) => p.seedCanary === true);
25149
- const canarySlug = canaryProject ? (canaryProject.repos ?? [])[0]?.split("/").pop()?.toLowerCase() ?? null : null;
25150
- const repos = rosterRepos2.map((repo) => {
25295
+ const inClassRepos = rosterRepos2.filter((repo) => seed.classes.includes(classOf(repo)));
25296
+ const classPriority = [.../* @__PURE__ */ new Set(["deployable", ...seed.classes])].filter((c) => seed.classes.includes(c));
25297
+ let canaryProject = null;
25298
+ for (const cls of classPriority) {
25299
+ const inClass = projects.filter((p) => p.seedCanary === true && (p.class ?? "deployable") === cls);
25300
+ if (inClass.length === 0) continue;
25301
+ if (inClass.length > 1) {
25302
+ const slugs = inClass.map((p) => (p.repos ?? [])[0]?.split("/").pop() ?? "?").join(", ");
25303
+ return fail(
25304
+ `bootstrap propagate: target '${seed.target}' (classes [${seed.classes.join(", ")}]) matches ${inClass.length} seedCanary:true repos in class '${cls}' (${slugs}) \u2014 exactly one seedCanary:true repo per class is required`
25305
+ );
25306
+ }
25307
+ canaryProject = inClass[0];
25308
+ break;
25309
+ }
25310
+ if (!canaryProject) {
25311
+ return fail(
25312
+ `bootstrap propagate: target '${seed.target}' needs classes [${seed.classes.join(", ")}] but no registry repo with seedCanary:true carries a matching class \u2014 set seedCanary:true on an in-class repo (mmi-cli org project set <slug> --var seedCanary=true)`
25313
+ );
25314
+ }
25315
+ const canarySlug = (canaryProject.repos ?? [])[0]?.split("/").pop()?.toLowerCase() ?? null;
25316
+ const repos = inClassRepos.map((repo) => {
25151
25317
  const slug = repo.split("/").pop().toLowerCase();
25152
25318
  const waiver = seed.waivers?.[slug];
25153
25319
  return { repo, slug, waiver };
@@ -25271,6 +25437,136 @@ Rollback: \`mmi-cli bootstrap rollback ${rec.repo} --target ${seed.target} --exe
25271
25437
  else console.log(renderPropagationReport(plan));
25272
25438
  if (plan.halted) process.exitCode = 1;
25273
25439
  });
25440
+ bootstrap.command("rollback <repo>").description("#4240: open a per-repo revert PR of the recorded seed-propagate merge \u2014 never a fleet-wide overwrite; dry-run unless --execute").addOption(new Option("--class <class>", "deployable | content").default("deployable").choices(["deployable", "content"])).option("--target <path>", "the manifest target to roll back (an ownership:org + source:self seed, e.g. .github/workflows/agent-pr.yml)").option("--record <path>", "read propagation candidates from a persisted `bootstrap propagate --json` report instead of the live seed-propagate PR history").option("--execute", "LIVE revert via gh (master-gated) \u2014 opens/reuses the seed-rollback PR; dry-run prints the plan only").option("--json", "machine-readable output").action(async (repo) => {
25441
+ const o = {
25442
+ class: rawValue("--class", "deployable"),
25443
+ target: rawValue("--target", ""),
25444
+ record: rawValue("--record", ""),
25445
+ execute: rawFlag("--execute"),
25446
+ json: rawFlag("--json")
25447
+ };
25448
+ if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap rollback: --class must be deployable or content");
25449
+ let parsedRepo;
25450
+ try {
25451
+ parsedRepo = parseOwnerRepo(repo);
25452
+ } catch (e) {
25453
+ return fail(`bootstrap rollback: ${e.message}`);
25454
+ }
25455
+ const manifestPath = "skills/bootstrap/seeds/manifest.json";
25456
+ if (!(0, import_node_fs31.existsSync)(manifestPath)) return fail(`bootstrap rollback: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the manifest names which targets are org-owned and therefore propagated (and rollback-able)`);
25457
+ const manifest = loadBootstrapSeeds((0, import_node_fs31.readFileSync)(manifestPath, "utf8"));
25458
+ const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
25459
+ if (!o.target) {
25460
+ return fail(`bootstrap rollback: --target <path> is required \u2014 one of:
25461
+ ${propagatable.map((s) => s.target).join("\n ")}`);
25462
+ }
25463
+ const seed = propagatable.find((s) => s.target === o.target);
25464
+ if (!seed) return fail(`bootstrap rollback: --target '${o.target}' names no ownership:org + source:self seed in ${manifestPath}. Propagatable (hence rollback-able) targets:
25465
+ ${propagatable.map((s) => s.target).join("\n ")}`);
25466
+ const slug = parsedRepo.slug;
25467
+ const baseBranch = o.class === "content" ? "main" : "development";
25468
+ const branchPrefix = "seed-propagate";
25469
+ const propagateBranch = `${branchPrefix}-${slug}`;
25470
+ const gh = async (args) => execFileP2("gh", args, { timeout: 2e4 });
25471
+ const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
25472
+ let candidates;
25473
+ if (o.record) {
25474
+ if (!(0, import_node_fs31.existsSync)(o.record)) return fail(`bootstrap rollback: --record '${o.record}' not found`);
25475
+ let parsed;
25476
+ try {
25477
+ parsed = JSON.parse((0, import_node_fs31.readFileSync)(o.record, "utf8"));
25478
+ } catch (e) {
25479
+ return fail(`bootstrap rollback: --record '${o.record}' is not valid JSON: ${e.message}`);
25480
+ }
25481
+ const recs = Array.isArray(parsed?.records) ? parsed.records : Array.isArray(parsed) ? parsed : [];
25482
+ candidates = recs.map((r) => {
25483
+ try {
25484
+ return seedPrRecordFromPropagationRecord(r);
25485
+ } catch {
25486
+ return null;
25487
+ }
25488
+ }).filter((r) => r !== null);
25489
+ } else {
25490
+ candidates = [];
25491
+ try {
25492
+ const listed = await gh(["pr", "list", "--repo", repo, "--head", propagateBranch, "--base", baseBranch, "--state", "merged", "--json", "number,url,mergedAt,mergeCommit,files", "--limit", "20"]);
25493
+ const arr = JSON.parse(listed.stdout || "[]");
25494
+ for (const p of arr) {
25495
+ if (!p.mergeCommit?.oid) continue;
25496
+ candidates.push({
25497
+ repo,
25498
+ target: seed.target,
25499
+ number: p.number,
25500
+ url: p.url,
25501
+ mergeSha: p.mergeCommit.oid,
25502
+ mergedAt: p.mergedAt ?? "",
25503
+ files: (p.files ?? []).map((f) => f.path)
25504
+ });
25505
+ }
25506
+ } catch (e) {
25507
+ return fail(`bootstrap rollback: could not read ${repo}'s merged ${propagateBranch} PR history: ${e.message}`);
25508
+ }
25509
+ }
25510
+ const plan = planRollback(repo, seed.target, slug, candidates);
25511
+ if (!plan.resolution.found) {
25512
+ if (o.json) console.log(JSON.stringify(plan, null, 2));
25513
+ else console.log(renderRollbackReport(plan));
25514
+ return fail(`bootstrap rollback: ${plan.resolution.reason}`);
25515
+ }
25516
+ if (o.execute) {
25517
+ const record = plan.resolution.record;
25518
+ const parentResp = await gh(["api", `repos/${repo}/commits/${record.mergeSha}`, "--jq", ".parents[0].sha"]);
25519
+ const parentSha = parentResp.stdout.trim();
25520
+ if (!parentSha) return fail(`bootstrap rollback: could not resolve ${repo}@${record.mergeSha}'s parent commit \u2014 refusing to guess what to restore`);
25521
+ let preSeedContent = null;
25522
+ try {
25523
+ const resp = await gh(["api", `repos/${repo}/contents/${enc(seed.target)}?ref=${parentSha}`]);
25524
+ const parsed = JSON.parse(resp.stdout);
25525
+ preSeedContent = parsed.encoding === "base64" && typeof parsed.content === "string" ? Buffer.from(parsed.content, "base64").toString("utf8") : null;
25526
+ } catch {
25527
+ preSeedContent = null;
25528
+ }
25529
+ if (preSeedContent == null) return fail(`bootstrap rollback: '${seed.target}' did not exist in ${repo} at ${parentSha} (the commit before the seed merge) \u2014 nothing to restore; refusing to guess`);
25530
+ const baseRef = await gh(["api", `repos/${repo}/git/ref/heads/${baseBranch}`, "--jq", ".object.sha"]);
25531
+ const baseSha = baseRef.stdout.trim();
25532
+ let branchExists = true;
25533
+ try {
25534
+ await gh(["api", `repos/${repo}/git/ref/heads/${plan.branch}`]);
25535
+ } catch {
25536
+ branchExists = false;
25537
+ }
25538
+ if (!branchExists) await gh(["api", `repos/${repo}/git/refs`, "-f", `ref=refs/heads/${plan.branch}`, "-f", `sha=${baseSha}`]);
25539
+ let existingSha;
25540
+ try {
25541
+ const cur = await gh(["api", `repos/${repo}/contents/${enc(seed.target)}?ref=${plan.branch}`]);
25542
+ existingSha = JSON.parse(cur.stdout).sha;
25543
+ } catch {
25544
+ existingSha = void 0;
25545
+ }
25546
+ const tmp = (0, import_node_path29.join)((0, import_node_os11.tmpdir)(), `mmi-rollback-${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}.json`);
25547
+ (0, import_node_fs31.writeFileSync)(tmp, JSON.stringify(contentPutBody(seed.target, preSeedContent, plan.branch, existingSha)), "utf8");
25548
+ try {
25549
+ await gh(contentPutInputArgs(repo, seed.target, tmp));
25550
+ } finally {
25551
+ try {
25552
+ (0, import_node_fs31.unlinkSync)(tmp);
25553
+ } catch {
25554
+ }
25555
+ }
25556
+ const openPrs = await gh(["pr", "list", "--repo", repo, "--head", plan.branch, "--base", baseBranch, "--state", "open", "--json", "number,url"]);
25557
+ const prDecision = decideSeedPrAction(JSON.parse(openPrs.stdout || "[]"));
25558
+ let prUrl;
25559
+ if (prDecision.action === "reuse") {
25560
+ prUrl = prDecision.url;
25561
+ } else {
25562
+ const created = await ghCreate(["pr", "create", "--repo", repo, "--base", baseBranch, "--head", plan.branch, "--title", plan.title, "--body", plan.body]);
25563
+ prUrl = created.url;
25564
+ }
25565
+ plan.prUrl = prUrl;
25566
+ }
25567
+ if (o.json) console.log(JSON.stringify(plan, null, 2));
25568
+ else console.log(renderRollbackReport(plan));
25569
+ });
25274
25570
  }
25275
25571
 
25276
25572
  // src/stage-commands.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.99.0",
3
+ "version": "3.101.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox and shared cross-IDE engine for every registry-declared MMI coding surface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",