@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.
- package/dist/main.cjs +266 -23
- 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
|
-
|
|
8577
|
-
|
|
8578
|
-
|
|
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.
|
|
17234
|
-
tag: "v4.5.
|
|
17235
|
-
commit: "
|
|
17236
|
-
npm: "@mutmutco/cli@4.5.
|
|
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.
|
|
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.
|
|
17260
|
-
v3Target: "v4.5.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
41847
|
+
for (const service of withKeys) {
|
|
41724
41848
|
lines2.push(` ${service}:`, " environment:");
|
|
41725
|
-
for (const key of
|
|
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...>",
|
|
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