@mutmutco/cli 4.5.76 → 4.5.78

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 +388 -47
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -6986,6 +6986,24 @@ function findClaimedButUnregisteredClosings(text, closing, alreadyClosed = []) {
6986
6986
  }
6987
6987
  return [...claimed].filter((n) => !registered.has(n) && !closed.has(n));
6988
6988
  }
6989
+ function findLineStartClosings(text, repo) {
6990
+ const found = /* @__PURE__ */ new Set();
6991
+ let inFence = false;
6992
+ for (const line of text.split(/\r?\n/)) {
6993
+ if (FENCE_LINE_RE.test(line)) {
6994
+ inFence = !inFence;
6995
+ continue;
6996
+ }
6997
+ if (inFence || !LINE_START_CLOSING_RE.test(line)) continue;
6998
+ const m = findClosingMentions(line)[0];
6999
+ if (!m || m.negated || !Number.isInteger(m.issue) || m.issue <= 0) continue;
7000
+ const link = /\[#\d+\]\(([^)\s]+)\)/.exec(line);
7001
+ if (link && !(link[1] ?? "").toLowerCase().includes(`github.com/${repo.toLowerCase()}/issues/${m.issue}`)) continue;
7002
+ found.add(m.issue);
7003
+ }
7004
+ const ambiguous = new Set(findCrossRepoAmbiguousClosings(text, [...found], repo));
7005
+ return [...found].filter((n) => !ambiguous.has(n));
7006
+ }
6989
7007
  function claimedClosingWarning(unregistered, context = "pr merge") {
6990
7008
  const named = unregistered.map((n) => `#${n}`).join(", ");
6991
7009
  const first = `#${unregistered[0] ?? "N"}`;
@@ -7435,6 +7453,9 @@ var DECLARED_ENTRIES = [
7435
7453
  // archived stage (including a stage whose containers are down) into scratch Postgres and compare
7436
7454
  // row counts, and read-verify every storage archive against its listing; per-stage PASS/FAIL.
7437
7455
  { name: "mmi-backup-restore-check (mm-central, mm-work, mmi-oguz)", cadence: "0 3 1 * *", executor: "box cron.d", llm: "no", resolved: "declared", source: "/etc/cron.d/mmi-backup-restore-check \u2192 /opt/mmi-control/nightly-backup-restore-check.sh; monthly restore proof for every archived stage (#7148; source scripts/nightly-backup-restore-check.sh); zuber (46.225.58.31) is gone: deleted 2026-09-18 by the owner-approved ZuberShade decommission (MMC-ZuberShade#1838, snapshot kept); Hetzner has since reassigned that IP to another machine (#7351)" },
7456
+ // #7662 (MM-Work#1520): Vercel Cron's 15-minute central mail call, moved to the box with MM-Work. The app
7457
+ // calls its own endpoint with its own CRON_SECRET via docker exec, so the secret stays in the container.
7458
+ { name: "mm-work-mail-tick (mm-work)", cadence: "*/15 * * * *", executor: "box cron.d", llm: "no", resolved: "declared", source: "/etc/cron.d/mm-work-mail-tick \u2192 /opt/mmi-control/mm-work-mail-tick.sh; GET /api/internal/mail per stage listed in MM_WORK_MAIL_STAGES (source scripts/mm-work-mail-tick.sh; log /var/log/mm-work-mail-tick.log)" },
7438
7459
  // #3370/#6270: the one runner hygiene layer (runner-hygiene.yml was folded in and deleted). A workflow ran ON the runner and needed checkout +
7439
7460
  // setup-node before it can prune, so at 100% it dies at checkout and never prunes — the cleanup is
7440
7461
  // unavailable exactly when it is needed. This one is root cron on the box: no checkout, no node, and
@@ -8560,7 +8581,6 @@ function attributionFields(attr) {
8560
8581
  // src/secrets-transport.ts
8561
8582
  var OWNER = "mutmutco";
8562
8583
  var SSM_ROOT = "/mmi-future";
8563
- var PROJECT_TIER_SEGMENT = "dev";
8564
8584
  var ORG_INFRA_SLUG = "_org";
8565
8585
  var MMI_VAULT_GUIDANCE = 'Check MMI access with `mmi-cli oracle org access capabilities`; a master can locate credentials with `mmi-cli vault secrets find "<what it is for>"`.';
8566
8586
  var KEY_MAX_PATH_SEGMENTS = 4;
@@ -8573,10 +8593,9 @@ function isValidSecretKey(key) {
8573
8593
  function stripTrailingCRLF(value) {
8574
8594
  return value.replace(/\r\n$|\r$|\n$/, "");
8575
8595
  }
8576
- function classifyTier(_slug, key) {
8577
- const slash2 = key.indexOf("/");
8578
- if (slash2 === -1) return "project";
8579
- return key.slice(0, slash2) === PROJECT_TIER_SEGMENT ? "project" : "org";
8596
+ var ORG_WIDE_SLUGS = /* @__PURE__ */ new Set([ORG_INFRA_SLUG, "shared", "cloudflare", "docs", "mmi-hub"]);
8597
+ function classifyTier(slug, _key) {
8598
+ return ORG_WIDE_SLUGS.has(slug) ? "org" : "project";
8580
8599
  }
8581
8600
  function secretParamName(slug, key) {
8582
8601
  return `${SSM_ROOT}/${slug}/${key}`;
@@ -17230,10 +17249,10 @@ var rollout_plan_default = {
17230
17249
  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)."
17231
17250
  },
17232
17251
  baseline: {
17233
- version: "4.5.76",
17234
- tag: "v4.5.76",
17235
- commit: "9a7afd53566b",
17236
- npm: "@mutmutco/cli@4.5.76"
17252
+ version: "4.5.78",
17253
+ tag: "v4.5.78",
17254
+ commit: "55782d939329",
17255
+ npm: "@mutmutco/cli@4.5.78"
17237
17256
  },
17238
17257
  exitCriterion: "fleet-n-of-n",
17239
17258
  hubOnlyShortcut: "forbidden",
@@ -17250,14 +17269,14 @@ var rollout_plan_default = {
17250
17269
  repo: "mutmutco/mmi-hub",
17251
17270
  role: "canary",
17252
17271
  schedule: "train",
17253
- v3Target: "v4.5.76"
17272
+ v3Target: "v4.5.78"
17254
17273
  }
17255
17274
  ],
17256
17275
  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.",
17257
17276
  rollback: {
17258
17277
  independent: true,
17259
- mechanism: "npm dist-tag latest -> 4.5.76 and redeploy the Hub Lambda from tag v4.5.76 (9a7afd53566b); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
17260
- v3Target: "v4.5.76 (@mutmutco/cli@4.5.76, tag commit 9a7afd53566b \u2014 last known-good release carrying the repo-index v4-only contract)"
17278
+ mechanism: "npm dist-tag latest -> 4.5.78 and redeploy the Hub Lambda from tag v4.5.78 (55782d939329); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
17279
+ v3Target: "v4.5.78 (@mutmutco/cli@4.5.78, tag commit 55782d939329 \u2014 last known-good release carrying the repo-index v4-only contract)"
17261
17280
  }
17262
17281
  },
17263
17282
  {
@@ -20071,10 +20090,10 @@ async function listRemoteReleaseTags(deps) {
20071
20090
  }
20072
20091
  return [...tags].sort(cmpReleaseTag);
20073
20092
  }
20074
- async function assertComputedReleaseTagStillNext(deps, tag) {
20093
+ async function assertComputedReleaseTagStillNext(deps, tag, preOneZero = false) {
20075
20094
  if (!/^v\d+\.\d+\.\d+$/.test(tag)) return;
20076
20095
  const remote = await listRemoteReleaseTags(deps);
20077
- const newer = remote.filter((t) => cmpReleaseTag(t, tag) > 0);
20096
+ const newer = remote.filter((t) => cmpReleaseTag(t, tag) > 0 && !(preOneZero && !t.startsWith("v0.")));
20078
20097
  if (newer.length === 0) return;
20079
20098
  const latest = newer[newer.length - 1];
20080
20099
  throw new Error(
@@ -21696,6 +21715,17 @@ Resume it: ${resumeCommand}
21696
21715
  );
21697
21716
  }
21698
21717
 
21718
+ // src/pre-one-zero.ts
21719
+ function preOneZeroInForce(meta) {
21720
+ return !!meta?.suite && meta.preOneZero !== false;
21721
+ }
21722
+ function assertPreOneZero(repo, meta, tag) {
21723
+ if (preOneZeroInForce(meta) && /^v?[1-9]\d*\./.test(tag)) {
21724
+ throw new Error(`${repo} is a ${meta.suite} suite member held on 0.x.y; ${tag} would be major >= 1. Only the owner lifts this: set registry META preOneZero to false. If the tag came from a stale scripts/next-version.mjs, propagate the current seed first (#7699).`);
21725
+ }
21726
+ return tag;
21727
+ }
21728
+
21699
21729
  // src/train-doctor.ts
21700
21730
  var import_node_child_process9 = require("node:child_process");
21701
21731
  var import_node_fs23 = require("node:fs");
@@ -24832,7 +24862,7 @@ async function runTrainDoctor(input) {
24832
24862
  let newestRemoteReleaseTag;
24833
24863
  try {
24834
24864
  remoteTags = tagNames(await runGitRemoteRead(train, ["ls-remote", "--tags", "origin", "refs/tags/v*"]));
24835
- const releaseTags = await listRemoteReleaseTags(train);
24865
+ const releaseTags = (await listRemoteReleaseTags(train)).filter((t) => !preOneZeroInForce(meta) || t.startsWith("v0."));
24836
24866
  const latest = releaseTags[releaseTags.length - 1];
24837
24867
  newestRemoteReleaseTag = latest;
24838
24868
  if (latest) {
@@ -26054,7 +26084,7 @@ async function completeMainRelease(deps, ctx, meta, deployModel, watch, options,
26054
26084
  let tagLedgerStarted = false;
26055
26085
  const recoveryInput = { repo: ctx.repo, tag, stage: "main", deployModel, meta, branchHints };
26056
26086
  try {
26057
- await assertComputedReleaseTagStillNext(deps, tag);
26087
+ await assertComputedReleaseTagStillNext(deps, tag, preOneZeroInForce(meta));
26058
26088
  if (deployModel === "hub-serverless") {
26059
26089
  await deps.run("node", ["scripts/release-distribution.mjs", "assert-release-lineage", tag.replace(/^v/, ""), "--release-commit", releaseSha]);
26060
26090
  }
@@ -26478,7 +26508,7 @@ Nothing was written. Inspect ${ledger.path} before retrying.`);
26478
26508
  async function runReleaseResumePipeline(deps, options, prepared) {
26479
26509
  const watch = options.watch ?? false;
26480
26510
  const { ctx, meta, branchHints, directTrack, deployModel } = prepared;
26481
- const tags = clean2(await deps.run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname"])).split("\n").map((t) => t.trim()).filter((t) => /^v\d+\.\d+\.\d+$/.test(t));
26511
+ const tags = clean2(await deps.run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname"])).split("\n").map((t) => t.trim()).filter((t) => (preOneZeroInForce(meta) ? /^v0\.\d+\.\d+$/ : /^v\d+\.\d+\.\d+$/).test(t));
26482
26512
  const tag = tags[0];
26483
26513
  if (!tag) throw new Error("release --resume: no final v* release tag found \u2014 there is no release to resume");
26484
26514
  const tagSha = await probeRemoteTag(deps, tag);
@@ -26838,7 +26868,7 @@ async function runReleaseAbortLocked(deps, options) {
26838
26868
  throw new Error("release --abort must run from the primary checkout left on main by the failed train; nothing was written");
26839
26869
  }
26840
26870
  await runGitRemoteRead(deps, ["fetch", "origin", "--tags"]);
26841
- const tags = clean2(await deps.run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname"])).split("\n").map((tag2) => tag2.trim()).filter((tag2) => /^v\d+\.\d+\.\d+$/.test(tag2));
26871
+ const tags = clean2(await deps.run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname"])).split("\n").map((tag2) => tag2.trim()).filter((tag2) => (preOneZeroInForce(meta) ? /^v0\.\d+\.\d+$/ : /^v\d+\.\d+\.\d+$/).test(tag2));
26842
26872
  const tag = tags[0];
26843
26873
  if (!tag) throw new Error("release --abort found no final v* tag; nothing was written");
26844
26874
  const tagSha = await probeRemoteTag(deps, tag);
@@ -27023,7 +27053,7 @@ async function runTrainApplyPipeline(mode, input) {
27023
27053
  "nothing to promote: origin/development is not ahead of origin/rc"
27024
27054
  );
27025
27055
  const deployModel2 = await preflight(deps, ctx, "rc", meta);
27026
- const releaseBase = requireValue(clean2(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release base");
27056
+ const releaseBase = requireValue(clean2(await nextVersion(deps, ctx, meta, "cycle")), "release base");
27027
27057
  const { tolerated: tolerated2, predicted: predicted2 } = await preflightMergeToMain(
27028
27058
  deps,
27029
27059
  deployModel2,
@@ -27048,7 +27078,7 @@ async function runTrainApplyPipeline(mode, input) {
27048
27078
  else await mergeWithToleratedResolution(deps, "development", "development -> rc", "theirs", tolerated2);
27049
27079
  rcSha = requireValue(clean2(await deps.run("git", ["rev-parse", "rc"])), "rc sha");
27050
27080
  resume = await resolveRcResumeTag(deps, releaseBase, rcSha, rcTagProbe);
27051
- tag2 = resume.tag ?? requireValue(clean2(await deps.run("node", ["scripts/next-version.mjs", "rc"])), "rc tag");
27081
+ tag2 = resume.tag ?? requireValue(clean2(await nextVersion(deps, ctx, meta, "rc")), "rc tag");
27052
27082
  requiredChecks = await discoverRequiredCheckContexts(deps, ctx, "rc");
27053
27083
  assertTagAddressableRequiredContexts(deps, requiredChecks, ctx.repo);
27054
27084
  const anchors2 = { repo: ctx.repo, tag: tag2, tagSha: rcSha };
@@ -27128,7 +27158,7 @@ ${spent.note}`), rcandRecovery(tag2));
27128
27158
  "nothing to release: origin/development is not ahead of origin/main"
27129
27159
  );
27130
27160
  const deployModel2 = await preflight(deps, ctx, "main", meta);
27131
- const tag2 = requireValue(clean2(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release tag");
27161
+ const tag2 = requireValue(clean2(await nextVersion(deps, ctx, meta, "cycle")), "release tag");
27132
27162
  const rcShaAtRelease = !directTrack && hasRcBranch ? clean2(await deps.run("git", ["rev-parse", "origin/rc"])) : "";
27133
27163
  const { foldPaths: foldPaths2, tolerated: tolerated2, predicted: predicted2 } = await preflightMergeToMain(
27134
27164
  deps,
@@ -27246,7 +27276,7 @@ ${spent.note}`), rcandRecovery(tag2));
27246
27276
  );
27247
27277
  }
27248
27278
  const releasedRcSha = clean2(await deps.run("git", ["rev-parse", "origin/rc"]));
27249
- const tag = requireValue(clean2(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
27279
+ const tag = requireValue(clean2(await nextVersion(deps, ctx, meta, "release")), "release tag");
27250
27280
  const tagProbe = { remoteSha: await probeRemoteTag(deps, tag) };
27251
27281
  const preFold = {};
27252
27282
  const { versionFold, releaseSha } = await runFoldStage(deps, "rc", preFold, "mmi-cli devops release --apply", async () => {
@@ -27305,19 +27335,23 @@ ${spent.note}`), rcandRecovery(tag2));
27305
27335
  ...ledgerReport ? { cutBy: ledgerReport.cutBy, ledger: ledgerReport } : {}
27306
27336
  };
27307
27337
  }
27308
- async function latestLocalReleaseTag(deps, mergedInto) {
27338
+ async function nextVersion(deps, ctx, meta, mode) {
27339
+ const held = preOneZeroInForce(meta);
27340
+ return assertPreOneZero(ctx.repo, meta, clean2(await deps.run("node", ["scripts/next-version.mjs", mode, ...held ? ["--pre-one-zero"] : []])));
27341
+ }
27342
+ async function latestLocalReleaseTag(deps, mergedInto, preOneZero = false) {
27309
27343
  const tags = clean2(await deps.run("git", ["tag", "--list", "v*", ...mergedInto ? ["--merged", mergedInto] : []])).split("\n");
27310
27344
  let latest = { major: 0, minor: 0, patch: 0 };
27311
27345
  for (const tag of tags) {
27312
27346
  const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
27313
- if (!m) continue;
27347
+ if (!m || preOneZero && m[1] !== "0") continue;
27314
27348
  const v = { major: +m[1], minor: +m[2], patch: +m[3] };
27315
27349
  const delta = v.major - latest.major || v.minor - latest.minor || v.patch - latest.patch;
27316
27350
  if (delta > 0) latest = v;
27317
27351
  }
27318
27352
  return latest;
27319
27353
  }
27320
- async function resolveTrainBumpIntent(deps, env = process.env, latestRelease) {
27354
+ async function resolveTrainBumpIntent(deps, env = process.env, latestRelease, preOneZero = false) {
27321
27355
  const explicitIntent = (env.MMI_BUMP_INTENT ?? "").trim();
27322
27356
  if (explicitIntent) {
27323
27357
  if (!TRAIN_BUMP_INTENTS.includes(explicitIntent)) {
@@ -27330,7 +27364,8 @@ async function resolveTrainBumpIntent(deps, env = process.env, latestRelease) {
27330
27364
  const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(explicitVersion);
27331
27365
  if (!m) throw new Error(`MMI_RELEASE_VERSION must be X.Y.Z (got ${explicitVersion})`);
27332
27366
  const explicit = { major: +m[1], minor: +m[2], patch: +m[3] };
27333
- const latest = latestRelease ?? await latestLocalReleaseTag(deps);
27367
+ if (preOneZero && explicit.major >= 1) throw new Error(`MMI_RELEASE_VERSION ${explicitVersion} is major >= 1 but this suite member is held on 0.x.y; only the owner lifts it (registry META preOneZero=false) (#7699)`);
27368
+ const latest = latestRelease ?? await latestLocalReleaseTag(deps, void 0, preOneZero);
27334
27369
  const delta = explicit.major - latest.major || explicit.minor - latest.minor || explicit.patch - latest.patch;
27335
27370
  if (delta <= 0) {
27336
27371
  throw new Error(
@@ -27341,8 +27376,8 @@ async function resolveTrainBumpIntent(deps, env = process.env, latestRelease) {
27341
27376
  }
27342
27377
  return "patch";
27343
27378
  }
27344
- async function resolveHotfixVersion(deps, env = process.env) {
27345
- const latest = await latestLocalReleaseTag(deps, "origin/main");
27379
+ async function resolveHotfixVersion(deps, env = process.env, preOneZero = false) {
27380
+ const latest = await latestLocalReleaseTag(deps, "origin/main", preOneZero);
27346
27381
  if (latest.major === 0 && latest.minor === 0 && latest.patch === 0) {
27347
27382
  throw new Error("no vX.Y.Z release tag found on origin/main \u2014 cannot derive the hotfix PATCH version");
27348
27383
  }
@@ -27354,14 +27389,14 @@ async function resolveHotfixVersion(deps, env = process.env) {
27354
27389
  declared.push({
27355
27390
  name: "MMI_BUMP_INTENT",
27356
27391
  value: explicitIntent,
27357
- intent: await resolveTrainBumpIntent(deps, { MMI_BUMP_INTENT: explicitIntent }, latest)
27392
+ intent: await resolveTrainBumpIntent(deps, { MMI_BUMP_INTENT: explicitIntent }, latest, preOneZero)
27358
27393
  });
27359
27394
  }
27360
27395
  if (explicitVersion) {
27361
27396
  declared.push({
27362
27397
  name: "MMI_RELEASE_VERSION",
27363
27398
  value: explicitVersion,
27364
- intent: await resolveTrainBumpIntent(deps, { MMI_RELEASE_VERSION: explicitVersion }, latest)
27399
+ intent: await resolveTrainBumpIntent(deps, { MMI_RELEASE_VERSION: explicitVersion }, latest, preOneZero)
27365
27400
  });
27366
27401
  }
27367
27402
  const nonPatch = declared.find((d) => d.intent !== "patch");
@@ -27385,8 +27420,8 @@ async function runTrainApplyLocked(command, deps, options = {}) {
27385
27420
  const ctx = await buildTrainApplyContext(deps);
27386
27421
  await requireCleanTree(deps);
27387
27422
  await runGitRemoteRead(deps, ["fetch", "origin"]);
27388
- const bumpIntent = await resolveTrainBumpIntent(deps);
27389
27423
  const meta = requireProjectMetaForTrain(await loadProjectMeta(deps, ctx), ctx.repo);
27424
+ const bumpIntent = await resolveTrainBumpIntent(deps, process.env, void 0, preOneZeroInForce(meta));
27390
27425
  const branchHints = await loadReleaseTrackBranchHints(deps);
27391
27426
  const directTrack = isHubControlRepo(ctx.repo) || resolveReleaseTrack(meta, branchHints) === "direct";
27392
27427
  const pipelineInput = { deps, ctx, command, meta, branchHints, watch, options, bumpIntent };
@@ -30683,7 +30718,7 @@ init_hub_auth();
30683
30718
  init_github_client();
30684
30719
 
30685
30720
  // src/project-set.ts
30686
- var UNSET_KEYS = ["projectAdmins", "installer", "oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary", "notifyOnDeploy", "vercelVersionEnv", "suiteHold"];
30721
+ var UNSET_KEYS = ["projectAdmins", "installer", "oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary", "notifyOnDeploy", "vercelVersionEnv", "stageAllowlist", "suiteHold"];
30687
30722
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
30688
30723
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
30689
30724
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -31063,6 +31098,27 @@ function parseVercelVersionEnvVar(raw) {
31063
31098
  }
31064
31099
  return { project: row.project, key: row.key, ...typeof row.team === "string" ? { team: row.team } : {} };
31065
31100
  }
31101
+ function parseStageAllowlistVar(raw) {
31102
+ let parsed;
31103
+ try {
31104
+ parsed = JSON.parse(raw);
31105
+ } catch {
31106
+ throw new Error('org project set: stageAllowlist must be JSON, e.g. {"key":"MMW_STAGE_ALLOWLIST","stages":["dev"]}');
31107
+ }
31108
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
31109
+ throw new Error("org project set: stageAllowlist must be a JSON object { key, stages }");
31110
+ }
31111
+ const row = parsed;
31112
+ const unknown = Object.keys(row).filter((key) => key !== "key" && key !== "stages");
31113
+ if (unknown.length > 0) throw new Error(`org project set: stageAllowlist has unknown field(s): ${unknown.join(", ")}`);
31114
+ if (typeof row.key !== "string" || !VERCEL_VERSION_ENV_NAME_RE.test(row.key)) {
31115
+ throw new Error("org project set: stageAllowlist.key must be an env name (e.g. MMW_STAGE_ALLOWLIST)");
31116
+ }
31117
+ if (!Array.isArray(row.stages) || row.stages.length === 0 || new Set(row.stages).size !== row.stages.length || !row.stages.every((s) => s === "dev" || s === "rc" || s === "main")) {
31118
+ throw new Error("org project set: stageAllowlist.stages must be a non-empty unique array of: dev, rc, main");
31119
+ }
31120
+ return { key: row.key, stages: row.stages };
31121
+ }
31066
31122
  function parseCiExemptReasonVar(raw) {
31067
31123
  const trimmed = raw.trim();
31068
31124
  if (!trimmed) throw new Error("org project set: ciExemptReason must be a non-empty reason (or use --unset ciExemptReason to clear it)");
@@ -31212,7 +31268,8 @@ var SETTABLE_VAR_KEYS = [
31212
31268
  "seedCanary",
31213
31269
  "installer",
31214
31270
  "notifyOnDeploy",
31215
- "vercelVersionEnv"
31271
+ "vercelVersionEnv",
31272
+ "stageAllowlist"
31216
31273
  ];
31217
31274
  var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
31218
31275
  var SETTABLE_VAR_HINTS = {
@@ -31244,6 +31301,7 @@ var SETTABLE_VAR_HINTS = {
31244
31301
  requiredCheckBranches: "non-empty JSON branch-name array; exact product-ruleset scope (#5210)",
31245
31302
  notifyOnDeploy: 'JSON stage array, e.g. ["dev"] \u2014 successful tenant-deploy sends <stage>-deployed to the project repo (#7358)',
31246
31303
  vercelVersionEnv: "JSON {project,key,team?} \u2014 train sets this Vercel env to the release tag before the main/rc push (#7362)",
31304
+ stageAllowlist: "JSON {key,stages[]} \u2014 `org access sync` writes the developer/admin company-email allowlist to <stage>/<key> for each listed stage (#7678)",
31247
31305
  ciExemptReason: "non-empty string \u2014 declares this deployable repo has no CI surface (#4265)",
31248
31306
  gate: "JSON {runtime,cmd,affectedCmd,workdir,cacheDepPath,pyVersion}"
31249
31307
  };
@@ -31407,6 +31465,8 @@ function buildProjectSetPatch(input) {
31407
31465
  patch[key] = parseNotifyOnDeployVar(raw);
31408
31466
  } else if (key === "vercelVersionEnv") {
31409
31467
  patch[key] = parseVercelVersionEnvVar(raw);
31468
+ } else if (key === "stageAllowlist") {
31469
+ patch[key] = parseStageAllowlistVar(raw);
31410
31470
  } else if (key === "ciExemptReason") {
31411
31471
  patch[key] = parseCiExemptReasonVar(raw);
31412
31472
  } else if (key === "gate") {
@@ -36017,6 +36077,12 @@ async function runRcDataSwitch(deps, opts) {
36017
36077
  if (opts.apply && !opts.ownerGo) {
36018
36078
  throw new Error("--apply requires --owner-go (it touches the live data key); nothing was written");
36019
36079
  }
36080
+ const rcSubstrate = await deps.readLeadRcSubstrate();
36081
+ if (rcSubstrate) {
36082
+ throw new Error(
36083
+ `suite rc-data applies only while rc is served by Vercel (the lead's DEPLOY#rc runs on ${rcSubstrate}); rc data now follows the tenant runtime, not Vercel Preview envs; nothing was written`
36084
+ );
36085
+ }
36020
36086
  const secrets = [];
36021
36087
  const token = await deps.readToken();
36022
36088
  secrets.push(token);
@@ -36342,9 +36408,49 @@ function parseCherryPickBlockingPaths(message2) {
36342
36408
  if (!m) return [];
36343
36409
  return m[1].split(",").map((s) => s.trim()).filter(Boolean);
36344
36410
  }
36345
- function formatHotfixBatchPreflightRefusal(failures, fromSpecs) {
36411
+ async function classifyHotfixPortLanePicks(deps, failures) {
36412
+ const exists = async (ref, path2) => {
36413
+ try {
36414
+ await deps.run("git", ["cat-file", "-e", `${ref}:${path2}`]);
36415
+ return true;
36416
+ } catch {
36417
+ return false;
36418
+ }
36419
+ };
36420
+ const portable = /* @__PURE__ */ new Map();
36421
+ for (const f of failures) {
36422
+ if (f.blocking.length === 0) continue;
36423
+ let fits = false;
36424
+ const bothSides = [];
36425
+ for (const path2 of f.blocking) {
36426
+ const onMain = await exists("origin/main", path2);
36427
+ if (!onMain && await exists("origin/development", path2)) fits = true;
36428
+ else if (onMain) bothSides.push(path2);
36429
+ }
36430
+ if (fits) portable.set(f.sha, bothSides);
36431
+ }
36432
+ return portable;
36433
+ }
36434
+ function bothSidesText(portFailures, portLaneShas) {
36435
+ const rows = portFailures.map((f) => ({ label: f.label, paths: portLaneShas.get(f.sha) ?? [] })).filter((r) => r.paths.length > 0);
36436
+ if (rows.length === 0) return "";
36437
+ return `BEFORE that --ack: ${rows.map((r) => `${r.label} also conflicts on ${r.paths.join(", ")}`).join("; ")}, which exist on both branches \u2014 the port resolves them by hand for main, so land the SAME resolution on development first (the --ack covers the whole commit, and the next release would otherwise overwrite or clash with the main-only content). `;
36438
+ }
36439
+ function formatHotfixBatchPreflightRefusal(failures, fromSpecs, portLaneShas = /* @__PURE__ */ new Map()) {
36346
36440
  const lines2 = failures.map((f) => ` - ${f.label}: ${f.blocking.join(", ")}`);
36441
+ const portFailures = failures.filter((f) => portLaneShas.has(f.sha));
36442
+ const synthFailures = failures.filter((f) => !portLaneShas.has(f.sha));
36443
+ const head = `hotfix start batch preflight refused: ${failures.length} pick(s) would hard-stop on origin/main:
36444
+ ${lines2.join("\n")}
36445
+ `;
36347
36446
  const batchFrom = fromSpecs.join(",");
36447
+ const guide = "https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-pick-refused";
36448
+ if (portFailures.length > 0) {
36449
+ const originals = portFailures.map((f) => f.sha.slice(0, 7)).join(",");
36450
+ const portText = `${portFailures.length === failures.length ? "Every failing pick" : `Pick(s) ${portFailures.map((f) => f.label).join(", ")}`} fail on path(s) ABSENT on origin/main but present on origin/development, so a development-based cite-only synthesis PR cannot carry them (a new development commit cannot add files development already has \u2014 its cherry-pick onto main carries nothing, #7669). Use the main-based PORT-COMMIT lane: cut a branch FROM origin/main, add ONE port commit that brings the listed path(s) and the fix onto main-lineage (e.g. \`git checkout origin/development -- <paths>\` then the fix), push it and keep it pushed until the release \u2014 never merge it into development \u2014 then run \`mmi-cli devops hotfix start --from <portSHA> --ack ${originals}\` (a bare SHA is accepted; the originals are the acked development fixes the port replaces), and record the port SHA in the hotfix PR body. The port commit never reaches development and will not patch-id match, so the NEXT rc\u2192main release must pass \`--ack <the hotfix's main commit>\` (hotfix-coverage). ` + bothSidesText(portFailures, portLaneShas);
36451
+ if (synthFailures.length === 0) return `${head}${portText}Never hand-resolve onto main; see ${guide}`;
36452
+ return `${head}Mixed batch \u2014 two lanes. ${portText}Pick(s) ${synthFailures.map((f) => f.label).join(", ")} fit the development-based cite-only synthesis lane instead: cut the branch FROM origin/development, regenerate the conflict content so it cherry-picks cleanly onto current origin/main, and land that development-parented PR with the cite-only marker (the #5096 flow). Then rerun \`mmi-cli devops hotfix start --from <synthesis merge SHA(s)>,<portSHA> --ack ${originals}\` (the original --from was ${batchFrom}) \u2014 never hand-resolve onto main; never a main-parented branch into development; see ${guide}`;
36453
+ }
36348
36454
  return `hotfix start batch preflight refused: ${failures.length} pick(s) would hard-stop on origin/main:
36349
36455
  ${lines2.join("\n")}
36350
36456
  Land ONE development-based cite-only synthesis PR for the whole batch (#4643 supersedes the old cut-from-origin/main wording, which produced main-lineage PRs into development \u2014 the shape portFoldToDevelopment forbids, #4365/#4371): cut the branch FROM origin/development, regenerate the listed conflict content so it cherry-picks cleanly onto current origin/main, and land that development-parented PR with the cite-only marker (the proven #5096 flow), then rerun \`mmi-cli devops hotfix start --from ${batchFrom}\` with the port merge SHA(s) \u2014 never hand-resolve onto main; never a main-parented branch into development; never N serial ports (#3167) \u2014 see https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-pick-refused`;
@@ -36538,7 +36644,7 @@ async function runHotfixStartLocked(deps, options, doctor) {
36538
36644
  await deps.run("git", ["fetch", "origin", "--tags"]);
36539
36645
  const { deployModel, meta } = await hotfixPreflight(deps, ctx, "start");
36540
36646
  const bumpNotes = [];
36541
- let resolved = await resolveHotfixVersion(deps);
36647
+ let resolved = await resolveHotfixVersion(deps, process.env, preOneZeroInForce(meta));
36542
36648
  for (; ; ) {
36543
36649
  const priorPr = await findHotfixPr(deps, ctx, resolved.tag);
36544
36650
  if ((priorPr?.state ?? "").toUpperCase() !== "MERGED") break;
@@ -36608,7 +36714,8 @@ async function runHotfixStartLocked(deps, options, doctor) {
36608
36714
  } else {
36609
36715
  const preflight2 = await preflightHotfixBatchPicks(deps, sources, pickTolerated);
36610
36716
  if (preflight2.failures.length > 0) {
36611
- throw new Error(formatHotfixBatchPreflightRefusal(preflight2.failures, specs));
36717
+ const portLaneShas = await classifyHotfixPortLanePicks(deps, preflight2.failures);
36718
+ throw new Error(formatHotfixBatchPreflightRefusal(preflight2.failures, specs, portLaneShas));
36612
36719
  }
36613
36720
  if (preflight2.skipped.length > 0) {
36614
36721
  const skippedShas = new Set(preflight2.skipped.map((s) => s.sha));
@@ -37720,7 +37827,6 @@ async function advanceClosedIssuesToDone(deps) {
37720
37827
  error: `expected an array of closing issue references, got ${closing === null ? "null" : typeof closing}`
37721
37828
  };
37722
37829
  }
37723
- if (closing.length === 0) return { status: "none", entries: [] };
37724
37830
  const refs = [];
37725
37831
  for (const [i, candidate] of closing.entries()) {
37726
37832
  const repo = repoOf2(candidate);
@@ -37734,6 +37840,29 @@ async function advanceClosedIssuesToDone(deps) {
37734
37840
  refs.push({ ref: candidate, repo });
37735
37841
  }
37736
37842
  const entries = [];
37843
+ const fallbackNumbers = /* @__PURE__ */ new Set();
37844
+ let fallbackError;
37845
+ if (deps.fallback) {
37846
+ try {
37847
+ const registered = new Set(refs.filter((r) => r.repo.toLowerCase() === deps.repo.toLowerCase()).map((r) => r.ref.number));
37848
+ for (const n of [...new Set(await deps.fallback.fetchClaimed())].filter((n2) => !registered.has(n2))) {
37849
+ try {
37850
+ const closed = await deps.fallback.closeIssue(n);
37851
+ if ("error" in closed) entries.push({ issue: `#${n}`, moved: false, status: "failed", error: `GitHub did not register the closing reference and the CLI could not close it (${closed.error})` });
37852
+ else if (closed.state === "closed") {
37853
+ fallbackNumbers.add(n);
37854
+ const [owner, name] = deps.repo.split("/");
37855
+ refs.push({ ref: { number: n, repository: { name: name ?? "", owner: { login: owner ?? "" } } }, repo: deps.repo });
37856
+ }
37857
+ } catch (e) {
37858
+ entries.push({ issue: `#${n}`, moved: false, status: "failed", error: `GitHub did not register the closing reference and the CLI could not close it (${e.message})` });
37859
+ }
37860
+ }
37861
+ } catch (e) {
37862
+ fallbackError = e.message;
37863
+ }
37864
+ }
37865
+ if (refs.length === 0 && entries.length === 0) return { status: "none", entries: [], ...fallbackError ? { fallbackError } : {} };
37737
37866
  for (const { ref, repo } of refs) {
37738
37867
  if (repo.toLowerCase() !== deps.repo.toLowerCase()) {
37739
37868
  entries.push({
@@ -37774,14 +37903,15 @@ async function advanceClosedIssuesToDone(deps) {
37774
37903
  entries.push({ issue, moved: false, status: "failed", error: e.message });
37775
37904
  }
37776
37905
  }
37777
- return { status: entries.every((e) => e.moved) ? "ok" : "partial", entries };
37906
+ for (const e of entries) if (fallbackNumbers.has(Number(e.issue.slice(1)))) e.closedByFallback = true;
37907
+ return { status: entries.every((e) => e.moved) && !fallbackError ? "ok" : "partial", entries, ...fallbackError ? { fallbackError } : {} };
37778
37908
  }
37779
37909
  function boardAdvanceFailures(result) {
37780
37910
  return (result?.entries ?? []).filter((e) => !e.moved);
37781
37911
  }
37782
37912
  function boardAdvanceExitCode(result) {
37783
37913
  if (!result) return void 0;
37784
- if (result.status === "fetch-failed") return 1;
37914
+ if (result.status === "fetch-failed" || result.fallbackError) return 1;
37785
37915
  return boardAdvanceFailures(result).length > 0 ? 1 : void 0;
37786
37916
  }
37787
37917
  function boardAdvanceFailureMessage(result) {
@@ -37790,6 +37920,7 @@ function boardAdvanceFailureMessage(result) {
37790
37920
  return `pr merge: could not read the PR's closing issues (${result.error ?? "unknown error"}) \u2014 the PR MERGED, but no board item was advanced. Check the board by hand.`;
37791
37921
  }
37792
37922
  const failures = boardAdvanceFailures(result);
37923
+ if (result.fallbackError) return `pr merge: could not read the PR body to close unregistered closing lines (${result.fallbackError}) \u2014 the PR MERGED; check by hand whether its "Closes #N" issues are still open.`;
37793
37924
  if (!failures.length) return void 0;
37794
37925
  const named = failures.map((f) => f.error ? `${f.issue} (${f.error})` : f.issue).join(", ");
37795
37926
  return `pr merge: board advance incomplete for ${named} \u2014 the PR MERGED, but ${failures.length === 1 ? "this issue" : "these issues"} did not reach Done on the board.`;
@@ -38882,6 +39013,41 @@ async function resolveBoardAdvanceForPr(prNumber, repoOption) {
38882
39013
  }
38883
39014
  return advanceClosedIssuesToDone({
38884
39015
  repo,
39016
+ // #7691: GitHub sometimes fails to register a body `Closes #N`, so the merge closes nothing. Close the
39017
+ // still-OPEN issues the body claims on a deliberate line-start closing line (same repo only). GitHub closes
39018
+ // only for PRs into the default branch, so a PR into rc/main (a train or hotfix PR) never falls back.
39019
+ fallback: {
39020
+ fetchClaimed: async () => {
39021
+ const { stdout } = await execFileP("gh", ["pr", "view", prNumber, ...repoArgs, "--json", "body,baseRefName"], { timeout: GC_GH_TIMEOUT_MS });
39022
+ const { body, baseRefName } = JSON.parse(stdout || "null") ?? {};
39023
+ if (typeof body !== "string" || typeof baseRefName !== "string") throw new Error("gh returned no PR body or base branch");
39024
+ const repoView = await execFileP("gh", ["repo", "view", repo, "--json", "defaultBranchRef"], { timeout: GC_GH_TIMEOUT_MS });
39025
+ const defaultBranch = JSON.parse(repoView.stdout || "null")?.defaultBranchRef?.name;
39026
+ if (typeof defaultBranch !== "string" || !defaultBranch) throw new Error("gh returned no default branch");
39027
+ if (baseRefName !== defaultBranch) return [];
39028
+ return findLineStartClosings(body, repo);
39029
+ },
39030
+ closeIssue: async (n) => {
39031
+ try {
39032
+ const view = await execFileP("gh", ["issue", "view", String(n), "--repo", repo, "--json", "state"], { timeout: GC_GH_TIMEOUT_MS });
39033
+ if (JSON.parse(view.stdout || "null")?.state !== "OPEN") return { state: "already-closed" };
39034
+ await execFileP("gh", [
39035
+ "issue",
39036
+ "close",
39037
+ String(n),
39038
+ "--repo",
39039
+ repo,
39040
+ "--reason",
39041
+ "completed",
39042
+ "--comment",
39043
+ `Closed by the CLI after PR #${prNumber} merged: its body says it closes this issue, but GitHub did not register the closing reference (#7691).`
39044
+ ], { timeout: GH_MUTATION_TIMEOUT_MS });
39045
+ return { state: "closed" };
39046
+ } catch (e) {
39047
+ return { error: e.message };
39048
+ }
39049
+ }
39050
+ },
38885
39051
  fetchClosingIssues: async () => {
38886
39052
  const { stdout } = await execFileP("gh", ["pr", "view", prNumber, ...repoArgs, "--json", "closingIssuesReferences"], { timeout: GC_GH_TIMEOUT_MS });
38887
39053
  const parsed = JSON.parse(stdout || "null");
@@ -41689,6 +41855,8 @@ function findEnvFiles(root) {
41689
41855
  // src/stage-runtime-env.ts
41690
41856
  var SERVICE_NAME_RE = /^[A-Za-z0-9_.-]+$/;
41691
41857
  var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
41858
+ var EXCLUDE_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
41859
+ var EXCLUDE_LABEL = "mmi.runtime-env.exclude";
41692
41860
  var RUNTIME_ENV_OVERRIDE_RELPATH = "tmp/stage/runtime-env.override.yml";
41693
41861
  function labelsMarkRuntimeEnv(labels) {
41694
41862
  if (Array.isArray(labels)) {
@@ -41699,6 +41867,50 @@ function labelsMarkRuntimeEnv(labels) {
41699
41867
  }
41700
41868
  return false;
41701
41869
  }
41870
+ function labelValue(labels, name) {
41871
+ if (Array.isArray(labels)) {
41872
+ for (const l of labels) {
41873
+ if (typeof l !== "string") continue;
41874
+ const eq = l.indexOf("=");
41875
+ if (eq < 0 || l.slice(0, eq).trim() !== name) continue;
41876
+ const value = l.slice(eq + 1).trim();
41877
+ const quote = value[0];
41878
+ const quoted = value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote);
41879
+ return quoted ? value.slice(1, -1) : value;
41880
+ }
41881
+ return "";
41882
+ }
41883
+ if (labels && typeof labels === "object") return String(labels[name] ?? "");
41884
+ return "";
41885
+ }
41886
+ function parseRuntimeEnvExclude(value, service = "?") {
41887
+ if (!value.trim()) return [];
41888
+ return value.split(",").map((raw) => {
41889
+ const key = raw.trim();
41890
+ if (!EXCLUDE_KEY_RE.test(key)) {
41891
+ throw new Error(`invalid ${EXCLUDE_LABEL} entry on service ${service}: '${key}' (comma-separated env-var names matching ^[A-Z_][A-Z0-9_]*$)`);
41892
+ }
41893
+ return key;
41894
+ });
41895
+ }
41896
+ function runtimeEnvExcludes(composeConfigJson) {
41897
+ let parsed;
41898
+ try {
41899
+ parsed = JSON.parse(composeConfigJson);
41900
+ } catch {
41901
+ return {};
41902
+ }
41903
+ const services = parsed?.services;
41904
+ if (!services || typeof services !== "object" || Array.isArray(services)) return {};
41905
+ const out = {};
41906
+ for (const [name, svc] of Object.entries(services)) {
41907
+ const labels = svc?.labels;
41908
+ if (!SERVICE_NAME_RE.test(name) || !labelsMarkRuntimeEnv(labels)) continue;
41909
+ const keys = parseRuntimeEnvExclude(labelValue(labels, EXCLUDE_LABEL), name);
41910
+ if (keys.length) out[name] = keys;
41911
+ }
41912
+ return out;
41913
+ }
41702
41914
  function runtimeEnvMarkedServices(composeConfigJson) {
41703
41915
  let parsed;
41704
41916
  try {
@@ -41710,19 +41922,24 @@ function runtimeEnvMarkedServices(composeConfigJson) {
41710
41922
  if (!services || typeof services !== "object" || Array.isArray(services)) return [];
41711
41923
  return Object.entries(services).filter(([name, svc]) => SERVICE_NAME_RE.test(name) && labelsMarkRuntimeEnv(svc?.labels)).map(([name]) => name).sort();
41712
41924
  }
41713
- function renderRuntimeEnvOverride(services, keys) {
41925
+ function renderRuntimeEnvOverride(services, keys, excludes = {}) {
41714
41926
  const safeServices = services.filter((s) => SERVICE_NAME_RE.test(s)).sort();
41715
41927
  const safeKeys = [...new Set(keys.filter((k) => ENV_KEY_RE.test(k)))].sort();
41716
- if (!safeServices.length || !safeKeys.length) return null;
41928
+ const keysFor = (service) => {
41929
+ const skip = new Set(excludes[service] ?? []);
41930
+ return safeKeys.filter((k) => !skip.has(k));
41931
+ };
41932
+ const withKeys = safeServices.filter((s) => keysFor(s).length);
41933
+ if (!withKeys.length) return null;
41717
41934
  const lines2 = [
41718
41935
  "# Generated by `mmi-cli stage` (#6345) \u2014 do not edit, do not commit.",
41719
41936
  '# Passes the vault-resolved runtime env through to every service labelled mmi.runtime-env: "true",',
41720
41937
  "# exactly as the box deploy does. KEY NAMES ONLY: the values live in the compose process env (#2655).",
41721
41938
  "services:"
41722
41939
  ];
41723
- for (const service of safeServices) {
41940
+ for (const service of withKeys) {
41724
41941
  lines2.push(` ${service}:`, " environment:");
41725
- for (const key of safeKeys) lines2.push(` ${key}: \${${key}:?}`);
41942
+ for (const key of keysFor(service)) lines2.push(` ${key}: \${${key}:?}`);
41726
41943
  }
41727
41944
  return `${lines2.join("\n")}
41728
41945
  `;
@@ -42124,7 +42341,7 @@ async function prepareRuntimeEnvPassthrough(cwd, composeEnv, vaultEnvMerge) {
42124
42341
  } catch {
42125
42342
  return {};
42126
42343
  }
42127
- const body = renderRuntimeEnvOverride(runtimeEnvMarkedServices(configJson), keys);
42344
+ const body = renderRuntimeEnvOverride(runtimeEnvMarkedServices(configJson), keys, runtimeEnvExcludes(configJson));
42128
42345
  if (!body) return {};
42129
42346
  const overridePath = (0, import_node_path38.join)(cwd, RUNTIME_ENV_OVERRIDE_RELPATH);
42130
42347
  (0, import_node_fs38.mkdirSync)((0, import_node_path38.join)(cwd, "tmp", "stage"), { recursive: true });
@@ -44100,6 +44317,11 @@ ${list}`);
44100
44317
  `
44101
44318
  );
44102
44319
  }
44320
+ const safeTitle = rewriteUnregisteredClosingKeywords(title, findClosingMentions(body).map((m) => m.issue));
44321
+ if (safeTitle !== title) {
44322
+ title = safeTitle;
44323
+ process.stderr.write('pr create: WARNING \u2014 rewrote close-keyword text in the title to "part of #N": the squash commit subject would close it (#7688). Put closing keywords on a "Closes #N" body line.\n');
44324
+ }
44103
44325
  const docsCheck = await checkDocsIndexAtHead({ repo: o.repo, head: o.head }, createPrCreateDocsIndexDeps());
44104
44326
  if (docsCheck && !docsCheck.ok) {
44105
44327
  return fail(`pr create: ${docsCheck.detail} \u2014 ${docsCheck.fix}`);
@@ -45047,7 +45269,7 @@ async function requireFreshTrainCli(commandName) {
45047
45269
  process.exit(converge.reexecCode);
45048
45270
  }
45049
45271
  var GH_TRAIN_TIMEOUT_MS = 3e4;
45050
- var GH_RUN_WATCH_TIMEOUT_MS = 20 * 6e4;
45272
+ var GH_RUN_WATCH_TIMEOUT_MS = 90 * 6e4;
45051
45273
  var NODE_PREPARE_TIMEOUT_MS = 10 * 6e4;
45052
45274
  var NODE_VERIFY_TIMEOUT_MS = 5 * 6e4;
45053
45275
  var NPM_TRAIN_TIMEOUT_MS = 6e4;
@@ -46311,6 +46533,12 @@ function suiteRcDataProdDeps() {
46311
46533
  },
46312
46534
  readToken: readOrgVercelApiToken,
46313
46535
  readVault: async (vaultKey) => fetchSecretValue(makeSecretsDeps(await loadConfig()), vaultKey, { repo: RC_DATA_VAULT_REPO }),
46536
+ readLeadRcSubstrate: async () => {
46537
+ const facts = await fetchDeployFactsBySlug("mm-work", registryClientDeps(await loadConfig()));
46538
+ if (!facts) throw new Error("MM-Work deploy facts unreadable; stop");
46539
+ const rc = facts.stages.rc;
46540
+ return rc?.present ? rc.substrate ?? "an unnamed substrate" : null;
46541
+ },
46314
46542
  sleep: (ms) => new Promise((resolve7) => setTimeout(resolve7, ms))
46315
46543
  };
46316
46544
  }
@@ -49665,7 +49893,7 @@ function registerSchedulesCommands(program3) {
49665
49893
  await failGraceful(e.message);
49666
49894
  }
49667
49895
  });
49668
- 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) => {
49896
+ 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). It does NOT stop an armed clock from dispatching: to stop a lane firing, disable its workflow (`gh workflow disable`) or remove its header and re-register (#7685)").requiredOption("--reason <ref>", "why the lane is held, e.g. an issue ref (#1774)").action(async (scheduleId, o) => {
49669
49897
  try {
49670
49898
  const res = await postSchedulesPark(scheduleId, o.reason, registryClientDeps(await loadConfig()));
49671
49899
  if (!res.ok) {
@@ -49673,6 +49901,7 @@ function registerSchedulesCommands(program3) {
49673
49901
  return;
49674
49902
  }
49675
49903
  console.log(JSON.stringify(res.body, null, 2));
49904
+ console.error(`schedules park: ${scheduleId} is parked for drift reporting only \u2014 an armed clock still dispatches it. To stop it firing: gh workflow disable <workflow> -R <owner/repo>, or remove its schedule header and re-run \`mmi-cli harbour org schedules register\`.`);
49676
49905
  } catch (e) {
49677
49906
  await failGraceful(e.message);
49678
49907
  }
@@ -49717,6 +49946,96 @@ function registerSchedulesCommands(program3) {
49717
49946
  });
49718
49947
  }
49719
49948
 
49949
+ // ../company-emails.json
49950
+ var company_emails_default = {
49951
+ _comment: "Org-level, NON-secret GitHub login -> company email map (#7678). `mmi-cli oracle org access sync <repo>` maps a repo's current developer/admin collaborators through it to rebuild the stage allowlist a project opts into with the registry field `stageAllowlist` (e.g. MM-Work's MMW_STAGE_ALLOWLIST). Bundled into mmi-cli at build time (cli/src/stage-allowlist.ts) \u2014 one source of truth, edited here in a PR. Keys are lowercase GitHub logins; a collaborator missing here is reported loudly by the sync, never silently dropped.",
49952
+ emails: {
49953
+ jervaise: "suphi@mutatismutandis.co",
49954
+ sezenlerinadil: "adil@mutatismutandis.co"
49955
+ }
49956
+ };
49957
+
49958
+ // src/stage-allowlist.ts
49959
+ var ALLOWLIST_STAGES = ["dev", "rc", "main"];
49960
+ var ALLOWLIST_ROLES = /* @__PURE__ */ new Set(["write", "maintain", "admin"]);
49961
+ function bundledCompanyEmails() {
49962
+ return normalizeEmailMap(company_emails_default.emails ?? {});
49963
+ }
49964
+ function normalizeEmailMap(raw) {
49965
+ const out = {};
49966
+ for (const [login, email] of Object.entries(raw)) {
49967
+ if (typeof email === "string" && email.trim()) out[login.toLowerCase()] = email.trim().toLowerCase();
49968
+ }
49969
+ return out;
49970
+ }
49971
+ function stageAllowlistConfig(project2) {
49972
+ const cfg = project2?.stageAllowlist;
49973
+ if (!cfg || typeof cfg.key !== "string" || !Array.isArray(cfg.stages)) return null;
49974
+ const stages = cfg.stages.filter((s) => ALLOWLIST_STAGES.includes(s));
49975
+ return stages.length > 0 ? { key: cfg.key, stages } : null;
49976
+ }
49977
+ function developerAdminLogins(collaborators) {
49978
+ const logins = /* @__PURE__ */ new Set();
49979
+ for (const c of collaborators) {
49980
+ const role = c.role_name ?? (c.permissions?.admin ? "admin" : c.permissions?.maintain ? "maintain" : c.permissions?.push ? "write" : "read");
49981
+ if (ALLOWLIST_ROLES.has(role)) logins.add(c.login.toLowerCase());
49982
+ }
49983
+ return [...logins].sort();
49984
+ }
49985
+ function computeAllowlist(logins, emailMap) {
49986
+ const emails = /* @__PURE__ */ new Set();
49987
+ const unmapped = [];
49988
+ for (const login of logins) {
49989
+ const email = emailMap[login.toLowerCase()];
49990
+ if (email) emails.add(email);
49991
+ else unmapped.push(login);
49992
+ }
49993
+ return { emails: [...emails].sort(), unmapped };
49994
+ }
49995
+ function diffAllowlist(current, next) {
49996
+ const before = new Set((current ?? "").split(",").map((e) => e.trim().toLowerCase()).filter(Boolean));
49997
+ const after = new Set(next);
49998
+ const added = [...after].filter((e) => !before.has(e)).sort();
49999
+ const removed = [...before].filter((e) => !after.has(e)).sort();
50000
+ return { added, removed, changed: added.length > 0 || removed.length > 0 };
50001
+ }
50002
+ async function syncStageAllowlist(deps, repo, config, emailMap, opts = {}) {
50003
+ const logins = developerAdminLogins(await deps.listCollaborators(repo));
50004
+ const { emails, unmapped } = computeAllowlist(logins, emailMap);
50005
+ const stages = [];
50006
+ for (const stage of config.stages) {
50007
+ const coordinate = `${stage}/${config.key}`;
50008
+ const diff = diffAllowlist(await deps.readValue(repo, coordinate), emails);
50009
+ if (!diff.changed || opts.dryRun) {
50010
+ stages.push({ stage, ...diff, written: false });
50011
+ continue;
50012
+ }
50013
+ if (emails.length === 0) {
50014
+ stages.push({ stage, ...diff, written: false, error: "no developer/admin collaborator has a mapped email \u2014 refusing to write an empty allowlist" });
50015
+ continue;
50016
+ }
50017
+ const written = await deps.writeValue(repo, coordinate, emails.join(","));
50018
+ stages.push({ stage, ...diff, written, ...written ? {} : { error: `vault write of ${coordinate} was refused` } });
50019
+ }
50020
+ return { repo, key: config.key, logins, unmapped, emails, stages, ok: unmapped.length === 0 && stages.every((s) => !s.error) };
50021
+ }
50022
+ function renderStageAllowlist(r, dryRun = false) {
50023
+ const lines2 = [`${r.repo}: ${r.key} from ${r.logins.length} developer/admin collaborator(s): ${r.logins.map((l) => `@${l}`).join(", ") || "(none)"}`];
50024
+ for (const s of r.stages) {
50025
+ const change = !s.changed ? "unchanged" : `${s.added.length ? `+${s.added.join(" +")}` : ""}${s.added.length && s.removed.length ? " " : ""}${s.removed.length ? `-${s.removed.join(" -")}` : ""}`;
50026
+ const verb = s.error ? "FAILED" : s.written ? "written" : s.changed && dryRun ? "would write" : "no write needed";
50027
+ lines2.push(` ${s.stage}/${r.key}: ${change} (${verb})${s.error ? ` \u2014 ${s.error}` : ""}`);
50028
+ }
50029
+ for (const login of r.unmapped) {
50030
+ lines2.push(` UNMAPPED: @${login} holds developer/admin on ${r.repo} but has NO company email in company-emails.json \u2014 they are NOT on the allowlist. Add "${login.toLowerCase()}" there, then re-run.`);
50031
+ }
50032
+ if (r.stages.some((s) => s.written)) lines2.push(" redeploy the stage for the runtime to pick the new value up: `mmi-cli devops runtime tenant redeploy <owner/repo> <stage>`");
50033
+ return lines2.join("\n");
50034
+ }
50035
+ async function listRepoCollaborators(client, repo) {
50036
+ return client.restPaginate(`repos/${repo}/collaborators`);
50037
+ }
50038
+
49720
50039
  // src/session-report.ts
49721
50040
  init_cli_shared();
49722
50041
  var GC_GH_TIMEOUT_MS4 = 2e4;
@@ -52474,7 +52793,7 @@ ${filelessTransitionGuide(target, o.stage)}`
52474
52793
  return reportWrite("org project set-deploy", res);
52475
52794
  });
52476
52795
  var projectTenantTasks = project.command("tenant-tasks").description("project-admin self-declare of tenant tasks for every stage of your own repo \u2014 new-task-only; editing an existing task stays master-only (`org project set --var tenantTasks=...`)");
52477
- projectTenantTasks.command("declare <name>").description("register a NEW tenant task \u2014 becomes runnable via `runtime tenant control \u2026 run-task` on its declared dev/rc/main stages. Body shape {service,command[],stages[],timeoutSeconds?,artifact?}; required artifact tasks must carry {artifact} in the command argv.").option("--service <name>", "service in the tenant runtime the task runs in (required unless --var carries it)").option("--command <argv...>", "task command as argv (repeatable; required unless --var carries it)").option("--stages <list>", "comma-separated dev,rc,main stages this task may run on (required unless --var carries it)").option("--timeout-seconds <n>", "optional wall-clock ceiling 1..3600").option("--artifact <none|required>", "artifact contract (default none; required tasks must contain {artifact} in argv)").option("--var <json>", "full task object {service,command[],stages[],timeoutSeconds?,artifact?} \u2014 use instead of the individual flags. A task carrying secretOutput:{path,vault:{repo,key},overwrite?} is master-only; declare that via `org project set --var tenantTasks=...`").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action(async (name, o) => {
52796
+ projectTenantTasks.command("declare <name>").description("register a NEW tenant task \u2014 becomes runnable via `runtime tenant control \u2026 run-task` on its declared dev/rc/main stages. Body shape {service,command[],stages[],timeoutSeconds?,artifact?}; required artifact tasks must carry {artifact} in the command argv.").option("--service <name>", "service in the tenant runtime the task runs in (required unless --var carries it)").option("--command <argv...>", `task command as argv (repeatable; required unless --var carries it). An argv token that starts with "-" (python -m mod) is read as an option and the whole call fails to parse: declare such a command with --var '{"service":"<svc>","command":["python","-m","mod"],"stages":["dev"]}' instead (#7668)`).option("--stages <list>", "comma-separated dev,rc,main stages this task may run on (required unless --var carries it)").option("--timeout-seconds <n>", "optional wall-clock ceiling 1..3600").option("--artifact <none|required>", "artifact contract (default none; required tasks must contain {artifact} in argv)").option("--var <json>", "full task object {service,command[],stages[],timeoutSeconds?,artifact?} \u2014 use instead of the individual flags. A task carrying secretOutput:{path,vault:{repo,key},overwrite?} is master-only; declare that via `org project set --var tenantTasks=...`").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action(async (name, o) => {
52478
52797
  const cfg = await loadConfig();
52479
52798
  let target;
52480
52799
  try {
@@ -52722,6 +53041,28 @@ access.command("role [repo]").description("D14 train authority for a repo (serve
52722
53041
  console.log(`${a.repo}: @${a.login} is ${a.role} \u2014 train ${a.train ? "AUTHORIZED" : "not authorized"}${a.hubTrainMasterOnly ? " (Hub train is master-only)" : ""}`);
52723
53042
  if (!a.train) process.exitCode = 1;
52724
53043
  });
53044
+ access.command("sync [repo]").description("rebuild the stage allowlist (e.g. MM-Work MMW_STAGE_ALLOWLIST) from the repo's developer/admin collaborators via company-emails.json and write it to the vault for each stage the project opted into (registry stageAllowlist); run it after every developer/admin change (#7678)").option("--dry-run", "print what would change without writing").option("--json", "machine-readable output").action(async (repoArg) => {
53045
+ const o = { json: rawFlag("--json"), dryRun: rawFlag("--dry-run") };
53046
+ const repo = repoArg ?? await resolveRepo();
53047
+ if (!repo) return fail("org access sync: pass <owner/repo> or run inside a repo checkout");
53048
+ const cfg = await loadConfig();
53049
+ if (!cfg.sagaApiUrl) return fail("org access sync: Hub API URL not configured");
53050
+ const projects = await fetchProjectsList(registryClientDeps(cfg));
53051
+ if (!projects) return failGraceful("org access sync: could not read the org project registry \u2014 nothing written");
53052
+ const project2 = projects.find((p) => (p.repos ?? []).some((r) => r.toLowerCase() === repo.toLowerCase())) ?? null;
53053
+ const config = stageAllowlistConfig(project2);
53054
+ if (!config) return failGraceful(`org access sync: ${repo} has no stageAllowlist opt-in \u2014 set it with \`mmi-cli oracle org project set ${repo} --var 'stageAllowlist={"key":"<KEY>","stages":["dev"]}'\``);
53055
+ const secretsDeps = makeSecretsDeps(cfg);
53056
+ const client = defaultGitHubClient();
53057
+ const result = await syncStageAllowlist({
53058
+ listCollaborators: (r) => listRepoCollaborators(client, r),
53059
+ readValue: (r, coordinate) => fetchSecretValue(secretsDeps, coordinate, { repo: r }),
53060
+ // The value is written in-process from here; putSecret confirms by NAME only.
53061
+ writeValue: (r, coordinate, value) => putSecret(secretsDeps, coordinate, value, { repo: r })
53062
+ }, repo, config, bundledCompanyEmails(), { dryRun: o.dryRun });
53063
+ console.log(o.json ? JSON.stringify(result, null, 2) : renderStageAllowlist(result, o.dryRun));
53064
+ if (!result.ok) process.exitCode = 1;
53065
+ });
52725
53066
  access.command("audit").description("audit collaborator roles + train-branch push allowlists vs the locked state; read-only, emits gh remediation, never applies").option("--json", "machine-readable output").option("--repo <owner/repo>", "audit a single repo instead of the whole org").option("--class <class>", "repo class for --repo (deployable | content)", "deployable").action(async () => {
52726
53067
  const o = { json: rawFlag("--json"), repo: rawValue("--repo", ""), class: rawValue("--class", "deployable") };
52727
53068
  const deps = { client: defaultGitHubClient() };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.5.76",
3
+ "version": "4.5.78",
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",