@mutmutco/cli 4.5.76 → 4.5.77

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 +266 -23
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -7435,6 +7435,9 @@ var DECLARED_ENTRIES = [
7435
7435
  // archived stage (including a stage whose containers are down) into scratch Postgres and compare
7436
7436
  // row counts, and read-verify every storage archive against its listing; per-stage PASS/FAIL.
7437
7437
  { 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)" },
7438
+ // #7662 (MM-Work#1520): Vercel Cron's 15-minute central mail call, moved to the box with MM-Work. The app
7439
+ // calls its own endpoint with its own CRON_SECRET via docker exec, so the secret stays in the container.
7440
+ { 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
7441
  // #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
7442
  // setup-node before it can prune, so at 100% it dies at checkout and never prunes — the cleanup is
7440
7443
  // unavailable exactly when it is needed. This one is root cron on the box: no checkout, no node, and
@@ -8560,7 +8563,6 @@ function attributionFields(attr) {
8560
8563
  // src/secrets-transport.ts
8561
8564
  var OWNER = "mutmutco";
8562
8565
  var SSM_ROOT = "/mmi-future";
8563
- var PROJECT_TIER_SEGMENT = "dev";
8564
8566
  var ORG_INFRA_SLUG = "_org";
8565
8567
  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
8568
  var KEY_MAX_PATH_SEGMENTS = 4;
@@ -8573,10 +8575,9 @@ function isValidSecretKey(key) {
8573
8575
  function stripTrailingCRLF(value) {
8574
8576
  return value.replace(/\r\n$|\r$|\n$/, "");
8575
8577
  }
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";
8578
+ var ORG_WIDE_SLUGS = /* @__PURE__ */ new Set([ORG_INFRA_SLUG, "shared", "cloudflare", "docs", "mmi-hub"]);
8579
+ function classifyTier(slug, _key) {
8580
+ return ORG_WIDE_SLUGS.has(slug) ? "org" : "project";
8580
8581
  }
8581
8582
  function secretParamName(slug, key) {
8582
8583
  return `${SSM_ROOT}/${slug}/${key}`;
@@ -17230,10 +17231,10 @@ var rollout_plan_default = {
17230
17231
  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
17232
  },
17232
17233
  baseline: {
17233
- version: "4.5.76",
17234
- tag: "v4.5.76",
17235
- commit: "9a7afd53566b",
17236
- npm: "@mutmutco/cli@4.5.76"
17234
+ version: "4.5.77",
17235
+ tag: "v4.5.77",
17236
+ commit: "760ee9725625",
17237
+ npm: "@mutmutco/cli@4.5.77"
17237
17238
  },
17238
17239
  exitCriterion: "fleet-n-of-n",
17239
17240
  hubOnlyShortcut: "forbidden",
@@ -17250,14 +17251,14 @@ var rollout_plan_default = {
17250
17251
  repo: "mutmutco/mmi-hub",
17251
17252
  role: "canary",
17252
17253
  schedule: "train",
17253
- v3Target: "v4.5.76"
17254
+ v3Target: "v4.5.77"
17254
17255
  }
17255
17256
  ],
17256
17257
  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
17258
  rollback: {
17258
17259
  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)"
17260
+ mechanism: "npm dist-tag latest -> 4.5.77 and redeploy the Hub Lambda from tag v4.5.77 (760ee9725625); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
17261
+ v3Target: "v4.5.77 (@mutmutco/cli@4.5.77, tag commit 760ee9725625 \u2014 last known-good release carrying the repo-index v4-only contract)"
17261
17262
  }
17262
17263
  },
17263
17264
  {
@@ -30683,7 +30684,7 @@ init_hub_auth();
30683
30684
  init_github_client();
30684
30685
 
30685
30686
  // 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"];
30687
+ 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
30688
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
30688
30689
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
30689
30690
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -31063,6 +31064,27 @@ function parseVercelVersionEnvVar(raw) {
31063
31064
  }
31064
31065
  return { project: row.project, key: row.key, ...typeof row.team === "string" ? { team: row.team } : {} };
31065
31066
  }
31067
+ function parseStageAllowlistVar(raw) {
31068
+ let parsed;
31069
+ try {
31070
+ parsed = JSON.parse(raw);
31071
+ } catch {
31072
+ throw new Error('org project set: stageAllowlist must be JSON, e.g. {"key":"MMW_STAGE_ALLOWLIST","stages":["dev"]}');
31073
+ }
31074
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
31075
+ throw new Error("org project set: stageAllowlist must be a JSON object { key, stages }");
31076
+ }
31077
+ const row = parsed;
31078
+ const unknown = Object.keys(row).filter((key) => key !== "key" && key !== "stages");
31079
+ if (unknown.length > 0) throw new Error(`org project set: stageAllowlist has unknown field(s): ${unknown.join(", ")}`);
31080
+ if (typeof row.key !== "string" || !VERCEL_VERSION_ENV_NAME_RE.test(row.key)) {
31081
+ throw new Error("org project set: stageAllowlist.key must be an env name (e.g. MMW_STAGE_ALLOWLIST)");
31082
+ }
31083
+ 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")) {
31084
+ throw new Error("org project set: stageAllowlist.stages must be a non-empty unique array of: dev, rc, main");
31085
+ }
31086
+ return { key: row.key, stages: row.stages };
31087
+ }
31066
31088
  function parseCiExemptReasonVar(raw) {
31067
31089
  const trimmed = raw.trim();
31068
31090
  if (!trimmed) throw new Error("org project set: ciExemptReason must be a non-empty reason (or use --unset ciExemptReason to clear it)");
@@ -31212,7 +31234,8 @@ var SETTABLE_VAR_KEYS = [
31212
31234
  "seedCanary",
31213
31235
  "installer",
31214
31236
  "notifyOnDeploy",
31215
- "vercelVersionEnv"
31237
+ "vercelVersionEnv",
31238
+ "stageAllowlist"
31216
31239
  ];
31217
31240
  var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
31218
31241
  var SETTABLE_VAR_HINTS = {
@@ -31244,6 +31267,7 @@ var SETTABLE_VAR_HINTS = {
31244
31267
  requiredCheckBranches: "non-empty JSON branch-name array; exact product-ruleset scope (#5210)",
31245
31268
  notifyOnDeploy: 'JSON stage array, e.g. ["dev"] \u2014 successful tenant-deploy sends <stage>-deployed to the project repo (#7358)',
31246
31269
  vercelVersionEnv: "JSON {project,key,team?} \u2014 train sets this Vercel env to the release tag before the main/rc push (#7362)",
31270
+ stageAllowlist: "JSON {key,stages[]} \u2014 `org access sync` writes the developer/admin company-email allowlist to <stage>/<key> for each listed stage (#7678)",
31247
31271
  ciExemptReason: "non-empty string \u2014 declares this deployable repo has no CI surface (#4265)",
31248
31272
  gate: "JSON {runtime,cmd,affectedCmd,workdir,cacheDepPath,pyVersion}"
31249
31273
  };
@@ -31407,6 +31431,8 @@ function buildProjectSetPatch(input) {
31407
31431
  patch[key] = parseNotifyOnDeployVar(raw);
31408
31432
  } else if (key === "vercelVersionEnv") {
31409
31433
  patch[key] = parseVercelVersionEnvVar(raw);
31434
+ } else if (key === "stageAllowlist") {
31435
+ patch[key] = parseStageAllowlistVar(raw);
31410
31436
  } else if (key === "ciExemptReason") {
31411
31437
  patch[key] = parseCiExemptReasonVar(raw);
31412
31438
  } else if (key === "gate") {
@@ -36017,6 +36043,12 @@ async function runRcDataSwitch(deps, opts) {
36017
36043
  if (opts.apply && !opts.ownerGo) {
36018
36044
  throw new Error("--apply requires --owner-go (it touches the live data key); nothing was written");
36019
36045
  }
36046
+ const rcSubstrate = await deps.readLeadRcSubstrate();
36047
+ if (rcSubstrate) {
36048
+ throw new Error(
36049
+ `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`
36050
+ );
36051
+ }
36020
36052
  const secrets = [];
36021
36053
  const token = await deps.readToken();
36022
36054
  secrets.push(token);
@@ -36342,9 +36374,49 @@ function parseCherryPickBlockingPaths(message2) {
36342
36374
  if (!m) return [];
36343
36375
  return m[1].split(",").map((s) => s.trim()).filter(Boolean);
36344
36376
  }
36345
- function formatHotfixBatchPreflightRefusal(failures, fromSpecs) {
36377
+ async function classifyHotfixPortLanePicks(deps, failures) {
36378
+ const exists = async (ref, path2) => {
36379
+ try {
36380
+ await deps.run("git", ["cat-file", "-e", `${ref}:${path2}`]);
36381
+ return true;
36382
+ } catch {
36383
+ return false;
36384
+ }
36385
+ };
36386
+ const portable = /* @__PURE__ */ new Map();
36387
+ for (const f of failures) {
36388
+ if (f.blocking.length === 0) continue;
36389
+ let fits = false;
36390
+ const bothSides = [];
36391
+ for (const path2 of f.blocking) {
36392
+ const onMain = await exists("origin/main", path2);
36393
+ if (!onMain && await exists("origin/development", path2)) fits = true;
36394
+ else if (onMain) bothSides.push(path2);
36395
+ }
36396
+ if (fits) portable.set(f.sha, bothSides);
36397
+ }
36398
+ return portable;
36399
+ }
36400
+ function bothSidesText(portFailures, portLaneShas) {
36401
+ const rows = portFailures.map((f) => ({ label: f.label, paths: portLaneShas.get(f.sha) ?? [] })).filter((r) => r.paths.length > 0);
36402
+ if (rows.length === 0) return "";
36403
+ 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). `;
36404
+ }
36405
+ function formatHotfixBatchPreflightRefusal(failures, fromSpecs, portLaneShas = /* @__PURE__ */ new Map()) {
36346
36406
  const lines2 = failures.map((f) => ` - ${f.label}: ${f.blocking.join(", ")}`);
36407
+ const portFailures = failures.filter((f) => portLaneShas.has(f.sha));
36408
+ const synthFailures = failures.filter((f) => !portLaneShas.has(f.sha));
36409
+ const head = `hotfix start batch preflight refused: ${failures.length} pick(s) would hard-stop on origin/main:
36410
+ ${lines2.join("\n")}
36411
+ `;
36347
36412
  const batchFrom = fromSpecs.join(",");
36413
+ const guide = "https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/train-troubleshooting.md#hotfix-pick-refused";
36414
+ if (portFailures.length > 0) {
36415
+ const originals = portFailures.map((f) => f.sha.slice(0, 7)).join(",");
36416
+ 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);
36417
+ if (synthFailures.length === 0) return `${head}${portText}Never hand-resolve onto main; see ${guide}`;
36418
+ 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}`;
36419
+ }
36348
36420
  return `hotfix start batch preflight refused: ${failures.length} pick(s) would hard-stop on origin/main:
36349
36421
  ${lines2.join("\n")}
36350
36422
  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`;
@@ -36608,7 +36680,8 @@ async function runHotfixStartLocked(deps, options, doctor) {
36608
36680
  } else {
36609
36681
  const preflight2 = await preflightHotfixBatchPicks(deps, sources, pickTolerated);
36610
36682
  if (preflight2.failures.length > 0) {
36611
- throw new Error(formatHotfixBatchPreflightRefusal(preflight2.failures, specs));
36683
+ const portLaneShas = await classifyHotfixPortLanePicks(deps, preflight2.failures);
36684
+ throw new Error(formatHotfixBatchPreflightRefusal(preflight2.failures, specs, portLaneShas));
36612
36685
  }
36613
36686
  if (preflight2.skipped.length > 0) {
36614
36687
  const skippedShas = new Set(preflight2.skipped.map((s) => s.sha));
@@ -41689,6 +41762,8 @@ function findEnvFiles(root) {
41689
41762
  // src/stage-runtime-env.ts
41690
41763
  var SERVICE_NAME_RE = /^[A-Za-z0-9_.-]+$/;
41691
41764
  var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
41765
+ var EXCLUDE_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
41766
+ var EXCLUDE_LABEL = "mmi.runtime-env.exclude";
41692
41767
  var RUNTIME_ENV_OVERRIDE_RELPATH = "tmp/stage/runtime-env.override.yml";
41693
41768
  function labelsMarkRuntimeEnv(labels) {
41694
41769
  if (Array.isArray(labels)) {
@@ -41699,6 +41774,50 @@ function labelsMarkRuntimeEnv(labels) {
41699
41774
  }
41700
41775
  return false;
41701
41776
  }
41777
+ function labelValue(labels, name) {
41778
+ if (Array.isArray(labels)) {
41779
+ for (const l of labels) {
41780
+ if (typeof l !== "string") continue;
41781
+ const eq = l.indexOf("=");
41782
+ if (eq < 0 || l.slice(0, eq).trim() !== name) continue;
41783
+ const value = l.slice(eq + 1).trim();
41784
+ const quote = value[0];
41785
+ const quoted = value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote);
41786
+ return quoted ? value.slice(1, -1) : value;
41787
+ }
41788
+ return "";
41789
+ }
41790
+ if (labels && typeof labels === "object") return String(labels[name] ?? "");
41791
+ return "";
41792
+ }
41793
+ function parseRuntimeEnvExclude(value, service = "?") {
41794
+ if (!value.trim()) return [];
41795
+ return value.split(",").map((raw) => {
41796
+ const key = raw.trim();
41797
+ if (!EXCLUDE_KEY_RE.test(key)) {
41798
+ throw new Error(`invalid ${EXCLUDE_LABEL} entry on service ${service}: '${key}' (comma-separated env-var names matching ^[A-Z_][A-Z0-9_]*$)`);
41799
+ }
41800
+ return key;
41801
+ });
41802
+ }
41803
+ function runtimeEnvExcludes(composeConfigJson) {
41804
+ let parsed;
41805
+ try {
41806
+ parsed = JSON.parse(composeConfigJson);
41807
+ } catch {
41808
+ return {};
41809
+ }
41810
+ const services = parsed?.services;
41811
+ if (!services || typeof services !== "object" || Array.isArray(services)) return {};
41812
+ const out = {};
41813
+ for (const [name, svc] of Object.entries(services)) {
41814
+ const labels = svc?.labels;
41815
+ if (!SERVICE_NAME_RE.test(name) || !labelsMarkRuntimeEnv(labels)) continue;
41816
+ const keys = parseRuntimeEnvExclude(labelValue(labels, EXCLUDE_LABEL), name);
41817
+ if (keys.length) out[name] = keys;
41818
+ }
41819
+ return out;
41820
+ }
41702
41821
  function runtimeEnvMarkedServices(composeConfigJson) {
41703
41822
  let parsed;
41704
41823
  try {
@@ -41710,19 +41829,24 @@ function runtimeEnvMarkedServices(composeConfigJson) {
41710
41829
  if (!services || typeof services !== "object" || Array.isArray(services)) return [];
41711
41830
  return Object.entries(services).filter(([name, svc]) => SERVICE_NAME_RE.test(name) && labelsMarkRuntimeEnv(svc?.labels)).map(([name]) => name).sort();
41712
41831
  }
41713
- function renderRuntimeEnvOverride(services, keys) {
41832
+ function renderRuntimeEnvOverride(services, keys, excludes = {}) {
41714
41833
  const safeServices = services.filter((s) => SERVICE_NAME_RE.test(s)).sort();
41715
41834
  const safeKeys = [...new Set(keys.filter((k) => ENV_KEY_RE.test(k)))].sort();
41716
- if (!safeServices.length || !safeKeys.length) return null;
41835
+ const keysFor = (service) => {
41836
+ const skip = new Set(excludes[service] ?? []);
41837
+ return safeKeys.filter((k) => !skip.has(k));
41838
+ };
41839
+ const withKeys = safeServices.filter((s) => keysFor(s).length);
41840
+ if (!withKeys.length) return null;
41717
41841
  const lines2 = [
41718
41842
  "# Generated by `mmi-cli stage` (#6345) \u2014 do not edit, do not commit.",
41719
41843
  '# Passes the vault-resolved runtime env through to every service labelled mmi.runtime-env: "true",',
41720
41844
  "# exactly as the box deploy does. KEY NAMES ONLY: the values live in the compose process env (#2655).",
41721
41845
  "services:"
41722
41846
  ];
41723
- for (const service of safeServices) {
41847
+ for (const service of withKeys) {
41724
41848
  lines2.push(` ${service}:`, " environment:");
41725
- for (const key of safeKeys) lines2.push(` ${key}: \${${key}:?}`);
41849
+ for (const key of keysFor(service)) lines2.push(` ${key}: \${${key}:?}`);
41726
41850
  }
41727
41851
  return `${lines2.join("\n")}
41728
41852
  `;
@@ -42124,7 +42248,7 @@ async function prepareRuntimeEnvPassthrough(cwd, composeEnv, vaultEnvMerge) {
42124
42248
  } catch {
42125
42249
  return {};
42126
42250
  }
42127
- const body = renderRuntimeEnvOverride(runtimeEnvMarkedServices(configJson), keys);
42251
+ const body = renderRuntimeEnvOverride(runtimeEnvMarkedServices(configJson), keys, runtimeEnvExcludes(configJson));
42128
42252
  if (!body) return {};
42129
42253
  const overridePath = (0, import_node_path38.join)(cwd, RUNTIME_ENV_OVERRIDE_RELPATH);
42130
42254
  (0, import_node_fs38.mkdirSync)((0, import_node_path38.join)(cwd, "tmp", "stage"), { recursive: true });
@@ -46311,6 +46435,12 @@ function suiteRcDataProdDeps() {
46311
46435
  },
46312
46436
  readToken: readOrgVercelApiToken,
46313
46437
  readVault: async (vaultKey) => fetchSecretValue(makeSecretsDeps(await loadConfig()), vaultKey, { repo: RC_DATA_VAULT_REPO }),
46438
+ readLeadRcSubstrate: async () => {
46439
+ const facts = await fetchDeployFactsBySlug("mm-work", registryClientDeps(await loadConfig()));
46440
+ if (!facts) throw new Error("MM-Work deploy facts unreadable; stop");
46441
+ const rc = facts.stages.rc;
46442
+ return rc?.present ? rc.substrate ?? "an unnamed substrate" : null;
46443
+ },
46314
46444
  sleep: (ms) => new Promise((resolve7) => setTimeout(resolve7, ms))
46315
46445
  };
46316
46446
  }
@@ -49665,7 +49795,7 @@ function registerSchedulesCommands(program3) {
49665
49795
  await failGraceful(e.message);
49666
49796
  }
49667
49797
  });
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) => {
49798
+ 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
49799
  try {
49670
49800
  const res = await postSchedulesPark(scheduleId, o.reason, registryClientDeps(await loadConfig()));
49671
49801
  if (!res.ok) {
@@ -49673,6 +49803,7 @@ function registerSchedulesCommands(program3) {
49673
49803
  return;
49674
49804
  }
49675
49805
  console.log(JSON.stringify(res.body, null, 2));
49806
+ 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
49807
  } catch (e) {
49677
49808
  await failGraceful(e.message);
49678
49809
  }
@@ -49717,6 +49848,96 @@ function registerSchedulesCommands(program3) {
49717
49848
  });
49718
49849
  }
49719
49850
 
49851
+ // ../company-emails.json
49852
+ var company_emails_default = {
49853
+ _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.",
49854
+ emails: {
49855
+ jervaise: "suphi@mutatismutandis.co",
49856
+ sezenlerinadil: "adil@mutatismutandis.co"
49857
+ }
49858
+ };
49859
+
49860
+ // src/stage-allowlist.ts
49861
+ var ALLOWLIST_STAGES = ["dev", "rc", "main"];
49862
+ var ALLOWLIST_ROLES = /* @__PURE__ */ new Set(["write", "maintain", "admin"]);
49863
+ function bundledCompanyEmails() {
49864
+ return normalizeEmailMap(company_emails_default.emails ?? {});
49865
+ }
49866
+ function normalizeEmailMap(raw) {
49867
+ const out = {};
49868
+ for (const [login, email] of Object.entries(raw)) {
49869
+ if (typeof email === "string" && email.trim()) out[login.toLowerCase()] = email.trim().toLowerCase();
49870
+ }
49871
+ return out;
49872
+ }
49873
+ function stageAllowlistConfig(project2) {
49874
+ const cfg = project2?.stageAllowlist;
49875
+ if (!cfg || typeof cfg.key !== "string" || !Array.isArray(cfg.stages)) return null;
49876
+ const stages = cfg.stages.filter((s) => ALLOWLIST_STAGES.includes(s));
49877
+ return stages.length > 0 ? { key: cfg.key, stages } : null;
49878
+ }
49879
+ function developerAdminLogins(collaborators) {
49880
+ const logins = /* @__PURE__ */ new Set();
49881
+ for (const c of collaborators) {
49882
+ const role = c.role_name ?? (c.permissions?.admin ? "admin" : c.permissions?.maintain ? "maintain" : c.permissions?.push ? "write" : "read");
49883
+ if (ALLOWLIST_ROLES.has(role)) logins.add(c.login.toLowerCase());
49884
+ }
49885
+ return [...logins].sort();
49886
+ }
49887
+ function computeAllowlist(logins, emailMap) {
49888
+ const emails = /* @__PURE__ */ new Set();
49889
+ const unmapped = [];
49890
+ for (const login of logins) {
49891
+ const email = emailMap[login.toLowerCase()];
49892
+ if (email) emails.add(email);
49893
+ else unmapped.push(login);
49894
+ }
49895
+ return { emails: [...emails].sort(), unmapped };
49896
+ }
49897
+ function diffAllowlist(current, next) {
49898
+ const before = new Set((current ?? "").split(",").map((e) => e.trim().toLowerCase()).filter(Boolean));
49899
+ const after = new Set(next);
49900
+ const added = [...after].filter((e) => !before.has(e)).sort();
49901
+ const removed = [...before].filter((e) => !after.has(e)).sort();
49902
+ return { added, removed, changed: added.length > 0 || removed.length > 0 };
49903
+ }
49904
+ async function syncStageAllowlist(deps, repo, config, emailMap, opts = {}) {
49905
+ const logins = developerAdminLogins(await deps.listCollaborators(repo));
49906
+ const { emails, unmapped } = computeAllowlist(logins, emailMap);
49907
+ const stages = [];
49908
+ for (const stage of config.stages) {
49909
+ const coordinate = `${stage}/${config.key}`;
49910
+ const diff = diffAllowlist(await deps.readValue(repo, coordinate), emails);
49911
+ if (!diff.changed || opts.dryRun) {
49912
+ stages.push({ stage, ...diff, written: false });
49913
+ continue;
49914
+ }
49915
+ if (emails.length === 0) {
49916
+ stages.push({ stage, ...diff, written: false, error: "no developer/admin collaborator has a mapped email \u2014 refusing to write an empty allowlist" });
49917
+ continue;
49918
+ }
49919
+ const written = await deps.writeValue(repo, coordinate, emails.join(","));
49920
+ stages.push({ stage, ...diff, written, ...written ? {} : { error: `vault write of ${coordinate} was refused` } });
49921
+ }
49922
+ return { repo, key: config.key, logins, unmapped, emails, stages, ok: unmapped.length === 0 && stages.every((s) => !s.error) };
49923
+ }
49924
+ function renderStageAllowlist(r, dryRun = false) {
49925
+ const lines2 = [`${r.repo}: ${r.key} from ${r.logins.length} developer/admin collaborator(s): ${r.logins.map((l) => `@${l}`).join(", ") || "(none)"}`];
49926
+ for (const s of r.stages) {
49927
+ const change = !s.changed ? "unchanged" : `${s.added.length ? `+${s.added.join(" +")}` : ""}${s.added.length && s.removed.length ? " " : ""}${s.removed.length ? `-${s.removed.join(" -")}` : ""}`;
49928
+ const verb = s.error ? "FAILED" : s.written ? "written" : s.changed && dryRun ? "would write" : "no write needed";
49929
+ lines2.push(` ${s.stage}/${r.key}: ${change} (${verb})${s.error ? ` \u2014 ${s.error}` : ""}`);
49930
+ }
49931
+ for (const login of r.unmapped) {
49932
+ 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.`);
49933
+ }
49934
+ 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>`");
49935
+ return lines2.join("\n");
49936
+ }
49937
+ async function listRepoCollaborators(client, repo) {
49938
+ return client.restPaginate(`repos/${repo}/collaborators`);
49939
+ }
49940
+
49720
49941
  // src/session-report.ts
49721
49942
  init_cli_shared();
49722
49943
  var GC_GH_TIMEOUT_MS4 = 2e4;
@@ -52474,7 +52695,7 @@ ${filelessTransitionGuide(target, o.stage)}`
52474
52695
  return reportWrite("org project set-deploy", res);
52475
52696
  });
52476
52697
  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) => {
52698
+ 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
52699
  const cfg = await loadConfig();
52479
52700
  let target;
52480
52701
  try {
@@ -52722,6 +52943,28 @@ access.command("role [repo]").description("D14 train authority for a repo (serve
52722
52943
  console.log(`${a.repo}: @${a.login} is ${a.role} \u2014 train ${a.train ? "AUTHORIZED" : "not authorized"}${a.hubTrainMasterOnly ? " (Hub train is master-only)" : ""}`);
52723
52944
  if (!a.train) process.exitCode = 1;
52724
52945
  });
52946
+ 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) => {
52947
+ const o = { json: rawFlag("--json"), dryRun: rawFlag("--dry-run") };
52948
+ const repo = repoArg ?? await resolveRepo();
52949
+ if (!repo) return fail("org access sync: pass <owner/repo> or run inside a repo checkout");
52950
+ const cfg = await loadConfig();
52951
+ if (!cfg.sagaApiUrl) return fail("org access sync: Hub API URL not configured");
52952
+ const projects = await fetchProjectsList(registryClientDeps(cfg));
52953
+ if (!projects) return failGraceful("org access sync: could not read the org project registry \u2014 nothing written");
52954
+ const project2 = projects.find((p) => (p.repos ?? []).some((r) => r.toLowerCase() === repo.toLowerCase())) ?? null;
52955
+ const config = stageAllowlistConfig(project2);
52956
+ 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"]}'\``);
52957
+ const secretsDeps = makeSecretsDeps(cfg);
52958
+ const client = defaultGitHubClient();
52959
+ const result = await syncStageAllowlist({
52960
+ listCollaborators: (r) => listRepoCollaborators(client, r),
52961
+ readValue: (r, coordinate) => fetchSecretValue(secretsDeps, coordinate, { repo: r }),
52962
+ // The value is written in-process from here; putSecret confirms by NAME only.
52963
+ writeValue: (r, coordinate, value) => putSecret(secretsDeps, coordinate, value, { repo: r })
52964
+ }, repo, config, bundledCompanyEmails(), { dryRun: o.dryRun });
52965
+ console.log(o.json ? JSON.stringify(result, null, 2) : renderStageAllowlist(result, o.dryRun));
52966
+ if (!result.ok) process.exitCode = 1;
52967
+ });
52725
52968
  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
52969
  const o = { json: rawFlag("--json"), repo: rawValue("--repo", ""), class: rawValue("--class", "deployable") };
52727
52970
  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.77",
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",