@mutmutco/cli 4.5.0 → 4.5.2

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 +455 -94
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -757,7 +757,7 @@ var init_house_map = __esm({
757
757
  "use strict";
758
758
  HOUSE_ROOTS = ["oracle", "harbour", "devops", "vault", "learning"];
759
759
  COMPAT_SHIM_DEATH_WAVE = "Wave 3";
760
- FLAT_ALIAS_CUT_NOTE = `flat Wave 0 alias \u2014 removed in ${COMPAT_SHIM_DEATH_WAVE} (#4316, #4438); only the canonical house-prefixed path parses`;
760
+ FLAT_ALIAS_CUT_NOTE = `flat Wave 0 alias \u2014 no longer advertised since ${COMPAT_SHIM_DEATH_WAVE} (#4316, #4438); it still runs with a notice naming the canonical house-prefixed path (#7034)`;
761
761
  HOUSE_QUESTIONS = {
762
762
  oracle: "Is it live?",
763
763
  harbour: "Is it bounded?",
@@ -15017,6 +15017,26 @@ async function installAppFoldDeps(deps) {
15017
15017
  }
15018
15018
  await deps.run("npm", lockListing.trim() ? ["ci"] : ["install"]);
15019
15019
  }
15020
+ var SHARED_INSTALLER_PACKAGES = ["@mutmutco/installer-face", "@mutmutco/installer-launcher", "@mutmutco/installer-gate"];
15021
+ async function refreshSharedInstallerDeps(deps) {
15022
+ let declared;
15023
+ try {
15024
+ const lock = JSON.parse(await deps.run("git", ["show", "HEAD:package-lock.json"]));
15025
+ const present = Object.keys(lock.packages ?? {});
15026
+ declared = SHARED_INSTALLER_PACKAGES.filter((name) => present.some((key) => key === `node_modules/${name}` || key.endsWith(`/node_modules/${name}`)));
15027
+ } catch {
15028
+ return null;
15029
+ }
15030
+ if (declared.length === 0) return null;
15031
+ try {
15032
+ await deps.run("npm", ["update", ...declared, "--package-lock-only", "--no-audit", "--no-fund"]);
15033
+ } catch (e) {
15034
+ const why = e instanceof Error ? e.message.split("\n")[0] : String(e);
15035
+ deps.warn?.(`shared installer refresh skipped (${why}) \u2014 shipping the locked versions`);
15036
+ return `shared installer refresh skipped (${why}); the locked ${declared.join(", ")} still satisfy their ranges`;
15037
+ }
15038
+ return `shared installer deps refreshed to the newest their ranges allow (${declared.join(", ")})`;
15039
+ }
15020
15040
  async function restoreMainNpmPackIdentities(deps, version) {
15021
15041
  const bomPath = "distribution-bom.json";
15022
15042
  let mainListing;
@@ -15171,6 +15191,8 @@ async function foldReleaseVersion(deps, model, tag, foldPaths, sourceCommit = "H
15171
15191
  }
15172
15192
  } else {
15173
15193
  await installAppFoldDeps(deps);
15194
+ const refresh = await refreshSharedInstallerDeps(deps);
15195
+ if (refresh) foldNoteExtra = `; ${refresh}`;
15174
15196
  await deps.run("npm", ["version", version, "--no-git-tag-version", "--allow-same-version"]);
15175
15197
  const patch = Number(version.split(".")[2] ?? "0");
15176
15198
  if (model === "registry-publish" && Number.isFinite(patch) && patch > 0) {
@@ -15976,10 +15998,10 @@ var rollout_plan_default = {
15976
15998
  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)."
15977
15999
  },
15978
16000
  baseline: {
15979
- version: "4.5.0",
15980
- tag: "v4.5.0",
15981
- commit: "e9c83872b853",
15982
- npm: "@mutmutco/cli@4.5.0"
16001
+ version: "4.5.2",
16002
+ tag: "v4.5.2",
16003
+ commit: "32bfbb00fcb9",
16004
+ npm: "@mutmutco/cli@4.5.2"
15983
16005
  },
15984
16006
  exitCriterion: "fleet-n-of-n",
15985
16007
  hubOnlyShortcut: "forbidden",
@@ -15996,14 +16018,14 @@ var rollout_plan_default = {
15996
16018
  repo: "mutmutco/mmi-hub",
15997
16019
  role: "canary",
15998
16020
  schedule: "train",
15999
- v3Target: "v4.5.0"
16021
+ v3Target: "v4.5.2"
16000
16022
  }
16001
16023
  ],
16002
16024
  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.",
16003
16025
  rollback: {
16004
16026
  independent: true,
16005
- mechanism: "npm dist-tag latest -> 4.5.0 and redeploy the Hub Lambda from tag v4.5.0 (e9c83872b853); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
16006
- v3Target: "v4.5.0 (@mutmutco/cli@4.5.0, tag commit e9c83872b853 \u2014 last known-good release carrying the repo-index v4-only contract)"
16027
+ mechanism: "npm dist-tag latest -> 4.5.2 and redeploy the Hub Lambda from tag v4.5.2 (32bfbb00fcb9); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
16028
+ v3Target: "v4.5.2 (@mutmutco/cli@4.5.2, tag commit 32bfbb00fcb9 \u2014 last known-good release carrying the repo-index v4-only contract)"
16007
16029
  }
16008
16030
  },
16009
16031
  {
@@ -18779,6 +18801,64 @@ async function resolveRcResumeTag(deps, base, sha, probed) {
18779
18801
  return { tag: newest, note };
18780
18802
  }
18781
18803
 
18804
+ // ../infra/installer-families.mjs
18805
+ var INSTALLER_FAMILIES = ["jerv", "mmi"];
18806
+ var INSTALLER_FAMILY_HOSTS = {
18807
+ jerv: "get.jerv.ai",
18808
+ mmi: "get.mutmut.work"
18809
+ };
18810
+ var INSTALLER_LOGINS = ["github", "google"];
18811
+ var PRODUCT_RE = /^[a-z][a-z0-9-]{0,39}$/;
18812
+ function expectedInstallerLogin(family, division) {
18813
+ if (family === "jerv") return "github";
18814
+ return String(division ?? "").trim().toLowerCase() === "mmi" ? "github" : "google";
18815
+ }
18816
+ function installerVersionUrl(installer) {
18817
+ if (typeof installer.gate === "string" && installer.gate) return `${installer.gate.replace(/\/+$/, "")}/release/version`;
18818
+ return `https://${INSTALLER_FAMILY_HOSTS[installer.family]}/${installer.product}/release/version`;
18819
+ }
18820
+ function installerDeclarationError(value, division) {
18821
+ if (value === void 0 || value === null) return null;
18822
+ if (typeof value !== "object" || Array.isArray(value)) {
18823
+ return "installer must be an object {product, family, login?, gate?}";
18824
+ }
18825
+ const record = (
18826
+ /** @type {Record<string, unknown>} */
18827
+ value
18828
+ );
18829
+ for (const key of Object.keys(record)) {
18830
+ if (!["product", "family", "login", "gate"].includes(key)) return `installer.${key} is not a field \u2014 expected product, family and optionally login and gate`;
18831
+ }
18832
+ if (typeof record.product !== "string" || !PRODUCT_RE.test(record.product)) {
18833
+ return "installer.product must be a lowercase kebab name (e.g. mm-strategy) \u2014 it is the /<product> path on the family host";
18834
+ }
18835
+ if (!INSTALLER_FAMILIES.includes(
18836
+ /** @type {string} */
18837
+ record.family
18838
+ )) {
18839
+ return `installer.family must be one of: ${INSTALLER_FAMILIES.join(", ")} (jerv \u2192 ${INSTALLER_FAMILY_HOSTS.jerv}, mmi \u2192 ${INSTALLER_FAMILY_HOSTS.mmi})`;
18840
+ }
18841
+ if (record.gate !== void 0 && (typeof record.gate !== "string" || !/^https:\/\/[a-z0-9.-]+\.[a-z]{2,}(\/[A-Za-z0-9._\/-]*)?$/.test(record.gate))) {
18842
+ return "installer.gate must be the https origin of the product's own sign-in server (e.g. https://strategy.mutmut.work)";
18843
+ }
18844
+ if (record.login !== void 0 && !INSTALLER_LOGINS.includes(
18845
+ /** @type {string} */
18846
+ record.login
18847
+ )) {
18848
+ return `installer.login must be one of: ${INSTALLER_LOGINS.join(", ")}`;
18849
+ }
18850
+ const expected = expectedInstallerLogin(
18851
+ /** @type {'jerv' | 'mmi'} */
18852
+ record.family,
18853
+ division
18854
+ );
18855
+ if (record.login !== void 0 && record.login !== expected) {
18856
+ const who = record.family === "jerv" ? "Jerv products sign in with GitHub" : String(division ?? "").trim().toLowerCase() === "mmi" ? "MMI products sign in with GitHub" : "MM and other Mutatis Mutandis products sign in with Google";
18857
+ return `installer.login ${record.login} breaks the sign-in rule: ${who} (owner ruling 2026-09-23)`;
18858
+ }
18859
+ return null;
18860
+ }
18861
+
18782
18862
  // src/tenant-control-parse.ts
18783
18863
  var OUTPUT_BEGIN = "mmi-control-output-begin";
18784
18864
  var OUTPUT_END = "mmi-control-output-end";
@@ -18961,7 +19041,7 @@ function mmiGetRedeployCommand(repo) {
18961
19041
  }
18962
19042
  function readLocalLauncherVersion(root = process.cwd()) {
18963
19043
  try {
18964
- const raw = (0, import_node_fs19.readFileSync)((0, import_node_path17.join)(root, "installer", "launcher", "package.json"), "utf8");
19044
+ const raw = (0, import_node_fs19.readFileSync)((0, import_node_path17.join)(root, "cli", "package.json"), "utf8");
18965
19045
  const version = JSON.parse(raw).version;
18966
19046
  return typeof version === "string" && version.trim() ? version.trim() : null;
18967
19047
  } catch {
@@ -18990,17 +19070,54 @@ async function fetchServedChannelVersion(url, fetchImpl = fetch) {
18990
19070
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
18991
19071
  }
18992
19072
  }
19073
+ async function findPriorGreenDispatch(deps, repo, workflow, sha, titleTokens) {
19074
+ if (!/^[0-9a-f]{40}$/i.test(sha)) return void 0;
19075
+ try {
19076
+ const rows = JSON.parse(await deps.run("gh", [
19077
+ "run",
19078
+ "list",
19079
+ "--repo",
19080
+ repo,
19081
+ "--workflow",
19082
+ workflow,
19083
+ "--commit",
19084
+ sha,
19085
+ "--event",
19086
+ "workflow_dispatch",
19087
+ "--limit",
19088
+ "20",
19089
+ "--json",
19090
+ "databaseId,url,status,conclusion,displayTitle,headSha"
19091
+ ]));
19092
+ const hit = rows.find((r) => typeof r.databaseId === "number" && r.headSha?.toLowerCase() === sha.toLowerCase() && r.status === "completed" && r.conclusion === "success" && titleTokens.every((t) => (r.displayTitle ?? "").split(/[^\w.-]+/).includes(t)));
19093
+ return hit ? { runId: hit.databaseId, ...hit.url ? { runUrl: hit.url } : {} } : void 0;
19094
+ } catch {
19095
+ return void 0;
19096
+ }
19097
+ }
19098
+ function planeFailureRow(workflow, evidence) {
19099
+ return { workflow, runUrlNote: evidence, conclusion: "failure" };
19100
+ }
18993
19101
  async function appendMmiGetRedeploy(deps, input, dispatch) {
18994
19102
  if (!isHubControlRepo(input.repo)) return dispatch;
18995
19103
  const remedy = mmiGetRedeployCommand(input.repo);
18996
- const withNote = (note, deployStatus) => ({
19104
+ const withNote = (note, deployStatus, row) => ({
18997
19105
  ...dispatch,
18998
19106
  note: `${dispatch.note}; ${note}`,
18999
- ...deployStatus ? { deployStatus } : {}
19107
+ ...deployStatus ? { deployStatus } : {},
19108
+ ...row ? { workflowRuns: [...dispatch.workflowRuns ?? [], row] } : {}
19000
19109
  });
19001
19110
  if (input.publishStatus !== "success") {
19002
19111
  return withNote(`mmi-get box NOT redeployed (publish did not succeed) \u2014 after the publish is green, run \`${remedy}\``);
19003
19112
  }
19113
+ const prior = input.releaseSha ? await findPriorGreenDispatch(deps, input.repo, "tenant-deploy.yml", input.releaseSha, [HUB_GET_SLUG, HUB_GET_STAGE]) : void 0;
19114
+ if (prior) {
19115
+ return {
19116
+ ...dispatch,
19117
+ note: `${dispatch.note}; mmi-get box already redeployed for this release (tenant-deploy.yml run ${prior.runId} at ${input.releaseSha.slice(0, 12)}) \u2014 not dispatched again`,
19118
+ workflowRuns: [...dispatch.workflowRuns ?? [], { workflow: "tenant-deploy.yml", runId: prior.runId, ...prior.runUrl ? { runUrl: prior.runUrl } : {}, conclusion: "success" }]
19119
+ };
19120
+ }
19004
19121
  const since = (deps.now ?? Date.now)();
19005
19122
  try {
19006
19123
  await deps.run("gh", [
@@ -19022,12 +19139,12 @@ async function appendMmiGetRedeploy(deps, input, dispatch) {
19022
19139
  ]);
19023
19140
  } catch (e) {
19024
19141
  const msg = e instanceof Error ? e.message : String(e);
19025
- return withNote(`tenant-deploy dispatch FAILED: ${msg}. The release itself landed, but the MMI-GET BOX WAS NOT REDEPLOYED \u2014 recover with \`${remedy}\``, "failure");
19142
+ return withNote(`tenant-deploy dispatch FAILED: ${msg}. The release itself landed, but the MMI-GET BOX WAS NOT REDEPLOYED \u2014 recover with \`${remedy}\``, "failure", planeFailureRow("tenant-deploy.yml", `dispatch refused: ${msg}`));
19026
19143
  }
19027
19144
  const correlated = await correlateTenantRun(deps, since, [HUB_GET_SLUG, HUB_GET_STAGE]);
19028
19145
  if (correlated.read === "failed" || correlated.read === "absent") {
19029
19146
  const why = correlated.read === "failed" ? `its run could not be READ: ${correlated.error}` : "no matching run appeared";
19030
- return withNote(`tenant-deploy dispatched (slug=${HUB_GET_SLUG}, stage=${HUB_GET_STAGE}) but ${why}. Check the Actions tab; if it never ran, recover with \`${remedy}\``, "failure");
19147
+ return withNote(`tenant-deploy dispatched (slug=${HUB_GET_SLUG}, stage=${HUB_GET_STAGE}) but ${why}. Check the Actions tab; if it never ran, recover with \`${remedy}\``, "failure", planeFailureRow("tenant-deploy.yml", `dispatched, but ${why}`));
19031
19148
  }
19032
19149
  const { runId, runUrl } = correlated;
19033
19150
  const runRow = { workflow: "tenant-deploy.yml", ...runId ? { runId } : {}, ...runUrl ? { runUrl } : {} };
@@ -19057,35 +19174,44 @@ async function appendServedChannelPublish(deps, input, dispatch) {
19057
19174
  if (!isHubControlRepo(input.repo)) return dispatch;
19058
19175
  const channelUrl = input.channelUrl ?? readServedChannelUrl();
19059
19176
  const remedy = getPublishDispatchCommand(input.repo);
19060
- const withNote = (note, deployStatus) => ({
19177
+ const withNote = (note, deployStatus, row) => ({
19061
19178
  ...dispatch,
19062
19179
  note: `${dispatch.note}; ${note}`,
19063
- ...deployStatus ? { deployStatus } : {}
19180
+ ...deployStatus ? { deployStatus } : {},
19181
+ ...row ? { workflowRuns: [...dispatch.workflowRuns ?? [], row] } : {}
19064
19182
  });
19065
19183
  if (input.publishStatus !== "success") {
19066
19184
  return withNote(`served channel NOT moved (publish did not succeed) \u2014 after the publish is green, run \`${remedy}\``);
19067
19185
  }
19068
- const since = (deps.now ?? Date.now)();
19069
- try {
19070
- await deps.run("gh", ["workflow", "run", "get-publish.yml", "--repo", input.repo, "--ref", "main", "-f", `product=${HUB_SERVED_PRODUCT}`]);
19071
- } catch (e) {
19072
- const msg = e instanceof Error ? e.message : String(e);
19073
- return withNote(`get-publish dispatch FAILED: ${msg}. The release itself landed, but the SERVED CHANNEL IS UNCHANGED \u2014 recover with \`${remedy}\``, "failure");
19074
- }
19075
- const correlated = await correlateGetPublishRun(deps, since, HUB_SERVED_PRODUCT);
19076
- if (correlated.read === "failed") {
19077
- return withNote(`get-publish dispatched but its run could not be READ: ${correlated.error}. Check the Actions tab; if it never ran, recover with \`${remedy}\``, "failure");
19078
- }
19079
- const { runId, runUrl } = correlated;
19080
- const runRow = { workflow: "get-publish.yml", ...runId ? { runId } : {}, ...runUrl ? { runUrl } : {} };
19081
- if (!input.watch) {
19082
- return {
19083
- ...dispatch,
19084
- note: `${dispatch.note}; dispatched get-publish.yml (product=${HUB_SERVED_PRODUCT}) \u2014 NOT watched, so the served channel is not yet proven moved`,
19085
- workflowRuns: [...dispatch.workflowRuns ?? [], { ...runRow, conclusion: "pending" }]
19086
- };
19186
+ const prior = input.releaseSha ? await findPriorGreenDispatch(deps, input.repo, "get-publish.yml", input.releaseSha, [HUB_SERVED_PRODUCT]) : void 0;
19187
+ let runRow;
19188
+ let runStatus;
19189
+ if (prior) {
19190
+ runRow = { workflow: "get-publish.yml", runId: prior.runId, ...prior.runUrl ? { runUrl: prior.runUrl } : {} };
19191
+ runStatus = "success";
19192
+ } else {
19193
+ const since = (deps.now ?? Date.now)();
19194
+ try {
19195
+ await deps.run("gh", ["workflow", "run", "get-publish.yml", "--repo", input.repo, "--ref", "main", "-f", `product=${HUB_SERVED_PRODUCT}`]);
19196
+ } catch (e) {
19197
+ const msg = e instanceof Error ? e.message : String(e);
19198
+ return withNote(`get-publish dispatch FAILED: ${msg}. The release itself landed, but the SERVED CHANNEL IS UNCHANGED \u2014 recover with \`${remedy}\``, "failure", planeFailureRow("get-publish.yml", `dispatch refused: ${msg}`));
19199
+ }
19200
+ const correlated = await correlateGetPublishRun(deps, since, HUB_SERVED_PRODUCT);
19201
+ if (correlated.read === "failed") {
19202
+ return withNote(`get-publish dispatched but its run could not be READ: ${correlated.error}. Check the Actions tab; if it never ran, recover with \`${remedy}\``, "failure", planeFailureRow("get-publish.yml", `dispatched, but its run could not be read`));
19203
+ }
19204
+ const { runId, runUrl } = correlated;
19205
+ runRow = { workflow: "get-publish.yml", ...runId ? { runId } : {}, ...runUrl ? { runUrl } : {} };
19206
+ if (!input.watch) {
19207
+ return {
19208
+ ...dispatch,
19209
+ note: `${dispatch.note}; dispatched get-publish.yml (product=${HUB_SERVED_PRODUCT}) \u2014 NOT watched, so the served channel is not yet proven moved`,
19210
+ workflowRuns: [...dispatch.workflowRuns ?? [], { ...runRow, conclusion: "pending" }]
19211
+ };
19212
+ }
19213
+ runStatus = await watchTenantRun(deps, runId, input.repo);
19087
19214
  }
19088
- const runStatus = await watchTenantRun(deps, runId, input.repo);
19089
19215
  if (runStatus !== "success") {
19090
19216
  return {
19091
19217
  ...dispatch,
@@ -19170,13 +19296,22 @@ var SERVED_CHANNELS = {
19170
19296
  remedy: JERV_GATEWAY_REMEDY
19171
19297
  }
19172
19298
  };
19173
- function servedChannelFor(repo) {
19174
- return SERVED_CHANNELS[repo.toLowerCase()];
19299
+ function servedChannelFor(repo, installer) {
19300
+ const known = SERVED_CHANNELS[repo.toLowerCase()];
19301
+ if (!installer) return known;
19302
+ if (isHubControlRepo(repo)) return void 0;
19303
+ const url = installerVersionUrl(installer);
19304
+ return {
19305
+ product: installer.product,
19306
+ url,
19307
+ mover: known?.mover ?? `this repo's release (${installer.family === "jerv" ? "get.jerv.ai" : "get.mutmut.work"} serves /${installer.product})`,
19308
+ remedy: known?.remedy ?? `re-run the release's served-channel publish for ${installer.product} (\`mmi-cli devops release --resume\`)`
19309
+ };
19175
19310
  }
19176
19311
  var SERVED_CHANNEL_PROBE_ATTEMPTS = 3;
19177
19312
  var SERVED_CHANNEL_PROBE_BACKOFF_MS = 1e4;
19178
19313
  async function appendServedChannelProof(deps, input, dispatch) {
19179
- const channel = servedChannelFor(input.repo);
19314
+ const channel = servedChannelFor(input.repo, input.installer);
19180
19315
  if (!channel) return dispatch;
19181
19316
  const expected = input.tag.replace(/^v/, "");
19182
19317
  const withProof = (conclusion, evidence, note, failed = false) => ({
@@ -19216,6 +19351,15 @@ async function appendServedChannelProof(deps, input, dispatch) {
19216
19351
  }
19217
19352
  return withProof("success", `${channel.url} reports ${probe.version}`, `served channel moved \u2014 ${channel.url} reports ${probe.version}`);
19218
19353
  }
19354
+ async function appendServedChannelLegs(deps, input, dispatch) {
19355
+ const publishStatus = input.publish?.state === "complete" ? "success" : "failure";
19356
+ let out = await appendMmiGetRedeploy(deps, { repo: input.repo, publishStatus, watch: input.watch, releaseSha: input.releaseSha }, dispatch);
19357
+ out = await appendServedChannelPublish(deps, { repo: input.repo, publishStatus, watch: input.watch, releaseSha: input.releaseSha }, out);
19358
+ return appendServedChannelProof(deps, { repo: input.repo, tag: input.tag, shipped: shipLegsGreen(input.deploy, input.publish), installer: input.installer }, out);
19359
+ }
19360
+ function servedChannelRowsAfter(before, after) {
19361
+ return (after.workflowRuns ?? []).slice((before.workflowRuns ?? []).length);
19362
+ }
19219
19363
  var JERV_GATEWAY_RUN_URL_NOTE = "no run URL emitted \u2014 host health leg runs outside GitHub Actions";
19220
19364
  var JERV_GATEWAY_RELEASE_DEPLOY_SCRIPT = "scripts/jerv-gateway-release-deploy.sh";
19221
19365
  async function runTaggedJervGatewayLocalWrapper(deps, tag, tagSha) {
@@ -20852,6 +20996,20 @@ var import_node_crypto5 = require("node:crypto");
20852
20996
  // src/file-lock.ts
20853
20997
  var import_promises2 = require("node:fs/promises");
20854
20998
  var import_node_path21 = require("node:path");
20999
+ function pidIsDead(pid) {
21000
+ if (!Number.isInteger(pid) || pid <= 0) return false;
21001
+ if (pid === process.pid) return false;
21002
+ try {
21003
+ process.kill(pid, 0);
21004
+ return false;
21005
+ } catch (e) {
21006
+ return e.code === "ESRCH";
21007
+ }
21008
+ }
21009
+ function lockTokenPid(token) {
21010
+ const m = /^(\d+)-/.exec(token);
21011
+ return m ? Number(m[1]) : void 0;
21012
+ }
20855
21013
  var sleep = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms));
20856
21014
  var IMMEDIATE_RETRY_BUDGET = 3;
20857
21015
  var FileLockBusyError = class extends Error {
@@ -20867,9 +21025,24 @@ function resolveFileLockOpts(opts = {}) {
20867
21025
  staleMs: opts.staleMs ?? 3e4,
20868
21026
  retryMs: opts.retryMs ?? 50,
20869
21027
  maxWaitMs: opts.maxWaitMs ?? 5e3,
20870
- label: opts.label ?? "file lock"
21028
+ label: opts.label ?? "file lock",
21029
+ deadHolderSteal: opts.deadHolderSteal ?? false,
21030
+ holder: opts.holder ?? ""
20871
21031
  };
20872
21032
  }
21033
+ async function lockIsStealable(lockPath, opts) {
21034
+ const age = Date.now() - (await (0, import_promises2.stat)(lockPath)).mtimeMs;
21035
+ if (age > opts.staleMs) return true;
21036
+ if (!opts.deadHolderSteal) return false;
21037
+ let token;
21038
+ try {
21039
+ token = await (0, import_promises2.readFile)(lockPath, "utf8");
21040
+ } catch {
21041
+ return false;
21042
+ }
21043
+ const pid = lockTokenPid(token);
21044
+ return pid !== void 0 && pidIsDead(pid);
21045
+ }
20873
21046
  async function acquireFileLock(lockPath, opts, deadline) {
20874
21047
  let immediateRetries = 0;
20875
21048
  for (; ; ) {
@@ -20880,12 +21053,12 @@ async function acquireFileLock(lockPath, opts, deadline) {
20880
21053
  const code = e.code;
20881
21054
  if (code !== "EEXIST" && code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw e;
20882
21055
  const retryNow = async () => {
21056
+ if (++immediateRetries <= IMMEDIATE_RETRY_BUDGET) return;
20883
21057
  if (Date.now() >= deadline) throw new FileLockBusyError(opts.label, lockPath, opts.maxWaitMs);
20884
- if (++immediateRetries > IMMEDIATE_RETRY_BUDGET) await sleep(opts.retryMs);
21058
+ await sleep(opts.retryMs);
20885
21059
  };
20886
21060
  try {
20887
- const age = Date.now() - (await (0, import_promises2.stat)(lockPath)).mtimeMs;
20888
- if (age > opts.staleMs) {
21061
+ if (await lockIsStealable(lockPath, opts)) {
20889
21062
  try {
20890
21063
  await (0, import_promises2.unlink)(lockPath);
20891
21064
  await retryNow();
@@ -20911,7 +21084,8 @@ async function acquireFileLock(lockPath, opts, deadline) {
20911
21084
  await sleep(opts.retryMs);
20912
21085
  continue;
20913
21086
  }
20914
- const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
21087
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
21088
+ const token = opts.holder ? `${stamp} ${opts.holder.replace(/\s+/g, " ").trim()}` : stamp;
20915
21089
  try {
20916
21090
  await handle.writeFile(token);
20917
21091
  } catch (e) {
@@ -20935,11 +21109,14 @@ async function releaseFileLock(lockPath, guard) {
20935
21109
  }
20936
21110
  }
20937
21111
  async function withFileLock(lockPath, opts, fn) {
21112
+ return withFencedFileLock(lockPath, opts, () => fn());
21113
+ }
21114
+ async function withFencedFileLock(lockPath, opts, fn) {
20938
21115
  const resolved = resolveFileLockOpts(opts);
20939
21116
  await (0, import_promises2.mkdir)((0, import_node_path21.dirname)(lockPath), { recursive: true }).catch(() => void 0);
20940
21117
  const guard = await acquireFileLock(lockPath, resolved, Date.now() + resolved.maxWaitMs);
20941
21118
  try {
20942
- return await fn();
21119
+ return await fn(() => fileLockHeldBy(lockPath, guard.token));
20943
21120
  } finally {
20944
21121
  await releaseFileLock(lockPath, guard);
20945
21122
  }
@@ -21177,8 +21354,114 @@ function atomicWriteJson(path2, ledger) {
21177
21354
  `, "utf8");
21178
21355
  (0, import_node_fs22.renameSync)(tmp, path2);
21179
21356
  }
21357
+ var LEDGER_LOCK_OPTS = { label: "release ledger", staleMs: 18e4, deadHolderSteal: true };
21358
+ async function withLedgerLock(path2, fn, opts = {}) {
21359
+ return withFencedFileLock(`${path2}.lock`, { ...LEDGER_LOCK_OPTS, ...opts.maxWaitMs != null ? { maxWaitMs: opts.maxWaitMs } : {} }, (stillHeld) => fn(async (write) => {
21360
+ if (!await stillHeld()) {
21361
+ throw new ReleaseLedgerError("lock-lost", `release ledger at ${path2}: this writer's lock was stolen while it worked \u2014 nothing was written over the successor (#7027)`);
21362
+ }
21363
+ return write();
21364
+ }));
21365
+ }
21366
+ function checkoutRootFrom(cwd) {
21367
+ let dir = (0, import_node_path22.resolve)(cwd);
21368
+ for (; ; ) {
21369
+ if (resolveGitDir(dir)) return dir;
21370
+ const parent = (0, import_node_path22.dirname)(dir);
21371
+ if (parent === dir) throw new ReleaseLedgerError("no-checkout", `train writer lock: no git checkout found above ${cwd}`);
21372
+ dir = parent;
21373
+ }
21374
+ }
21375
+ function trainWriterLockPath(cwd) {
21376
+ return (0, import_node_path22.join)((0, import_node_path22.dirname)(releaseLedgerPath(checkoutRootFrom(cwd))), "train.lock");
21377
+ }
21378
+ var TrainWriterBusyError = class extends Error {
21379
+ lockPath;
21380
+ holder;
21381
+ constructor(lockPath, holder, verb) {
21382
+ super(
21383
+ `${verb} refused before any mutation: another train is running on this checkout (${holder}). One train at a time writes the release ledger \u2014 wait for it to finish, or, if that process is gone, rerun: a dead holder's lock is reclaimed automatically (lock: ${lockPath}) (#7027)`
21384
+ );
21385
+ this.name = "TrainWriterBusyError";
21386
+ this.lockPath = lockPath;
21387
+ this.holder = holder;
21388
+ }
21389
+ };
21390
+ var TRAIN_WRITER_STALE_MS = 12 * 60 * 6e4;
21391
+ function describeTrainWriterToken(token) {
21392
+ const pid = lockTokenPid(token);
21393
+ const [stamp, ...rest] = token.trim().split(" ");
21394
+ const since = Number((stamp ?? "").split("-")[1]);
21395
+ const when = Number.isFinite(since) && since > 0 ? `, since ${new Date(since).toISOString()}` : "";
21396
+ const what = rest.join(" ").trim();
21397
+ return `${what || "a train command"}${pid ? `, pid ${pid}` : ""}${when}`;
21398
+ }
21399
+ function liveTrainWriterHolder(cwd) {
21400
+ let token;
21401
+ try {
21402
+ token = (0, import_node_fs22.readFileSync)(trainWriterLockPath(cwd), "utf8");
21403
+ } catch {
21404
+ return void 0;
21405
+ }
21406
+ const pid = lockTokenPid(token);
21407
+ if (pid === void 0 || pid === process.pid || pidIsDead(pid)) return void 0;
21408
+ return describeTrainWriterToken(token);
21409
+ }
21410
+ var heldTrainWriter;
21411
+ async function acquireTrainWriterLock(cwd, verb) {
21412
+ let lockPath;
21413
+ try {
21414
+ lockPath = trainWriterLockPath(cwd);
21415
+ } catch {
21416
+ return async () => void 0;
21417
+ }
21418
+ if (heldTrainWriter && heldTrainWriter.lockPath === lockPath) {
21419
+ heldTrainWriter.depth += 1;
21420
+ return async () => {
21421
+ if (heldTrainWriter) heldTrainWriter.depth -= 1;
21422
+ };
21423
+ }
21424
+ (0, import_node_fs22.mkdirSync)((0, import_node_path22.dirname)(lockPath), { recursive: true });
21425
+ const opts = resolveFileLockOpts({ label: "train writer", staleMs: TRAIN_WRITER_STALE_MS, deadHolderSteal: true, maxWaitMs: 0, holder: verb });
21426
+ let guard;
21427
+ try {
21428
+ guard = await acquireFileLock(lockPath, opts, Date.now());
21429
+ } catch (e) {
21430
+ if (!(e instanceof FileLockBusyError)) throw e;
21431
+ let token = "";
21432
+ try {
21433
+ token = (0, import_node_fs22.readFileSync)(lockPath, "utf8");
21434
+ } catch {
21435
+ token = "";
21436
+ }
21437
+ throw new TrainWriterBusyError(lockPath, token ? describeTrainWriterToken(token) : "holder unreadable", verb);
21438
+ }
21439
+ const held = {
21440
+ lockPath,
21441
+ depth: 1,
21442
+ release: async () => {
21443
+ heldTrainWriter = void 0;
21444
+ await releaseFileLock(lockPath, guard);
21445
+ }
21446
+ };
21447
+ heldTrainWriter = held;
21448
+ return async () => {
21449
+ held.depth -= 1;
21450
+ if (held.depth <= 0 && heldTrainWriter === held) await held.release();
21451
+ };
21452
+ }
21453
+ async function withTrainWriter(deps, verb, fn) {
21454
+ const seam = "trainWriter" in deps ? deps.trainWriter : void 0;
21455
+ if (!seam) return fn();
21456
+ const release = await seam(verb, process.cwd());
21457
+ try {
21458
+ return await fn();
21459
+ } finally {
21460
+ await release();
21461
+ }
21462
+ }
21180
21463
  async function withReleaseLedger(path2, expected, fn, opts = {}) {
21181
- return withFileLock(`${path2}.lock`, { label: "release ledger", maxWaitMs: opts.maxWaitMs ?? 5e3 }, async () => {
21464
+ return withLedgerLock(path2, async (fence) => {
21182
21465
  let current;
21183
21466
  let raw;
21184
21467
  try {
@@ -21193,9 +21476,9 @@ async function withReleaseLedger(path2, expected, fn, opts = {}) {
21193
21476
  if (expected) validateLedgerAnchors(current, expected);
21194
21477
  }
21195
21478
  const { ledger, value } = await fn(current);
21196
- if (ledger) atomicWriteJson(path2, ledger);
21479
+ if (ledger) await fence(() => atomicWriteJson(path2, ledger));
21197
21480
  return value;
21198
- });
21481
+ }, { maxWaitMs: opts.maxWaitMs ?? 5e3 });
21199
21482
  }
21200
21483
  function openReleaseLedgerWriter(cwd, opts = {}) {
21201
21484
  const now = opts.now ?? (() => /* @__PURE__ */ new Date());
@@ -21810,7 +22093,7 @@ async function reverifyPersistedRunPhases(deps, path2, anchors, input) {
21810
22093
  return result;
21811
22094
  }
21812
22095
  async function clearLedgerFile(path2, anchors, maxWaitMs) {
21813
- return withFileLock(`${path2}.lock`, { label: "release ledger", maxWaitMs }, async () => {
22096
+ return withLedgerLock(path2, async (fence) => {
21814
22097
  let raw;
21815
22098
  try {
21816
22099
  raw = (0, import_node_fs22.readFileSync)(path2, "utf8");
@@ -21829,9 +22112,9 @@ async function clearLedgerFile(path2, anchors, maxWaitMs) {
21829
22112
  } catch (e) {
21830
22113
  return { cleared: false, note: `release ledger at ${path2} belongs to ${ledger.tag} @ ${ledger.tagSha.slice(0, 12)} \u2014 not this release; left in place (${e instanceof Error ? e.message : String(e)})` };
21831
22114
  }
21832
- (0, import_node_fs22.unlinkSync)(path2);
22115
+ await fence(() => (0, import_node_fs22.unlinkSync)(path2));
21833
22116
  return { cleared: true, note: `cleared the release ledger at ${path2} for the live-proven aborted ${anchors.tag}` };
21834
- });
22117
+ }, { maxWaitMs });
21835
22118
  }
21836
22119
  async function clearReleaseLedger(cwd, anchors, opts = {}) {
21837
22120
  let path2;
@@ -21971,7 +22254,7 @@ async function alignmentLegClosedLive(deps, prior) {
21971
22254
  }
21972
22255
  async function healStaleAlignmentLeg(deps, cwd, expected) {
21973
22256
  const path2 = releaseLedgerPath(cwd);
21974
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22257
+ return withLedgerLock(path2, async (fence) => {
21975
22258
  let prior;
21976
22259
  try {
21977
22260
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -21987,7 +22270,7 @@ async function healStaleAlignmentLeg(deps, cwd, expected) {
21987
22270
  }
21988
22271
  const closed = await alignmentLegClosedLive(deps, prior);
21989
22272
  if (!closed) return void 0;
21990
- atomicWriteJson(path2, { ...prior, phases: { ...prior.phases, alignment: closed }, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
22273
+ await fence(() => atomicWriteJson(path2, { ...prior, phases: { ...prior.phases, alignment: closed }, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
21991
22274
  return prior.tag;
21992
22275
  });
21993
22276
  }
@@ -22060,7 +22343,7 @@ function supersedeableDeployLegShape(rec) {
22060
22343
  }
22061
22344
  async function supersedeContentCausedDeployLeg(deps, cwd) {
22062
22345
  const path2 = releaseLedgerPath(cwd);
22063
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22346
+ return withLedgerLock(path2, async (fence) => {
22064
22347
  let prior;
22065
22348
  try {
22066
22349
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -22101,14 +22384,14 @@ async function supersedeContentCausedDeployLeg(deps, cwd) {
22101
22384
  );
22102
22385
  }
22103
22386
  const note = `superseded: content-caused deploy failure on the immutable promoted ${prior.tag} \u2014 the cause fix rides the next patch train (master-approved closure, #6553)`;
22104
- atomicWriteJson(path2, {
22387
+ await fence(() => atomicWriteJson(path2, {
22105
22388
  ...prior,
22106
22389
  phases: {
22107
22390
  ...phases,
22108
22391
  deploy: { state: "skipped", sha: prior.tagSha, superseded: true, note, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
22109
22392
  },
22110
22393
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22111
- });
22394
+ }));
22112
22395
  return {
22113
22396
  command: "release-supersede-deploy",
22114
22397
  repo: prior.repo,
@@ -22124,7 +22407,7 @@ function supersedeablePublishLegShape(rec) {
22124
22407
  function deferredGatewayDeployLegShape(rec) {
22125
22408
  return rec.state === "pending" && rec.runId == null && rec.workflow === "jerv-gateway";
22126
22409
  }
22127
- async function proveOriginPromotionOrArchived(deps, prior, path2, label, ref) {
22410
+ async function proveOriginPromotionOrArchived(deps, prior, path2, label, ref, fence) {
22128
22411
  let liveSha = "";
22129
22412
  try {
22130
22413
  liveSha = (await deps.run("git", ["ls-remote", "origin", `refs/tags/${prior.tag}`])).trim().split(/\s+/)[0] ?? "";
@@ -22163,12 +22446,12 @@ async function proveOriginPromotionOrArchived(deps, prior, path2, label, ref) {
22163
22446
  if ((0, import_node_fs22.existsSync)(archivePath)) {
22164
22447
  throw new Error(`${label}: archive already exists at ${archivePath} \u2014 preserving both ledger records; resolve the collision by hand; nothing was written (${ref})`);
22165
22448
  }
22166
- (0, import_node_fs22.renameSync)(path2, archivePath);
22449
+ await fence(() => (0, import_node_fs22.renameSync)(path2, archivePath));
22167
22450
  return "deleted";
22168
22451
  }
22169
22452
  async function supersedeContentCausedPublishLeg(deps, cwd) {
22170
22453
  const path2 = releaseLedgerPath(cwd);
22171
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22454
+ return withLedgerLock(path2, async (fence) => {
22172
22455
  let prior;
22173
22456
  try {
22174
22457
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -22199,7 +22482,7 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
22199
22482
  `release --supersede-publish: ${prior.tag} has unresolved legs besides publish (${unresolved.map((p) => `${p}=${phases[p].state}`).join(", ")}) \u2014 the supersession closes only the publish leg of an otherwise-complete release; resolve those first, nothing was written (#6638)`
22200
22483
  );
22201
22484
  }
22202
- if (await proveOriginPromotionOrArchived(deps, prior, path2, "release --supersede-publish", "#6638") === "deleted") {
22485
+ if (await proveOriginPromotionOrArchived(deps, prior, path2, "release --supersede-publish", "#6638", fence) === "deleted") {
22203
22486
  return {
22204
22487
  command: "release-supersede-publish",
22205
22488
  repo: prior.repo,
@@ -22228,14 +22511,14 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
22228
22511
  gatewayDeploy = { workflow: "jerv-gateway", state: "complete", note: dispatch.note };
22229
22512
  }
22230
22513
  const note = `superseded: content-caused publish failure on the immutable promoted ${prior.tag} \u2014 the cause fix rides the next patch train (master-approved closure, #6638)`;
22231
- atomicWriteJson(path2, {
22514
+ await fence(() => atomicWriteJson(path2, {
22232
22515
  ...prior,
22233
22516
  phases: {
22234
22517
  ...phases,
22235
22518
  publish: { state: "skipped", sha: prior.tagSha, superseded: true, note, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
22236
22519
  },
22237
22520
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22238
- });
22521
+ }));
22239
22522
  return {
22240
22523
  command: "release-supersede-publish",
22241
22524
  repo: prior.repo,
@@ -22250,7 +22533,7 @@ async function supersedeContentCausedDeployAndPublishLegs(deps, cwd) {
22250
22533
  const label = "release --supersede-deploy --supersede-publish";
22251
22534
  const ref = "#6752";
22252
22535
  const path2 = releaseLedgerPath(cwd);
22253
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22536
+ return withLedgerLock(path2, async (fence) => {
22254
22537
  let prior;
22255
22538
  try {
22256
22539
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -22298,7 +22581,7 @@ async function supersedeContentCausedDeployAndPublishLegs(deps, cwd) {
22298
22581
  `${label}: ${prior.tag} has unresolved legs besides deploy and publish (${unresolved.map((p) => `${p}=${phases[p].state}`).join(", ")}) \u2014 the combined closure closes only the two content-caused legs of an otherwise-complete release; resolve those first, nothing was written (${ref})`
22299
22582
  );
22300
22583
  }
22301
- if (await proveOriginPromotionOrArchived(deps, prior, path2, label, ref) === "deleted") {
22584
+ if (await proveOriginPromotionOrArchived(deps, prior, path2, label, ref, fence) === "deleted") {
22302
22585
  return {
22303
22586
  command: "release-supersede-deploy-and-publish",
22304
22587
  repo: prior.repo,
@@ -22344,7 +22627,7 @@ async function supersedeContentCausedDeployAndPublishLegs(deps, cwd) {
22344
22627
  note: `superseded: content-caused publish failure on the immutable promoted ${prior.tag} \u2014 the cause fix rides the next patch train (master-approved closure, ${ref})`,
22345
22628
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22346
22629
  };
22347
- atomicWriteJson(path2, { ...prior, phases, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
22630
+ await fence(() => atomicWriteJson(path2, { ...prior, phases, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
22348
22631
  return {
22349
22632
  command: "release-supersede-deploy-and-publish",
22350
22633
  repo: prior.repo,
@@ -22375,7 +22658,7 @@ async function beginReleaseLedger(deps, cwd, targetTag) {
22375
22658
  return { ok: true };
22376
22659
  }
22377
22660
  await reverifyPriorRunLegs(deps, path2, targetTag);
22378
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22661
+ return withLedgerLock(path2, async (fence) => {
22379
22662
  let raw;
22380
22663
  try {
22381
22664
  raw = (0, import_node_fs22.readFileSync)(path2, "utf8");
@@ -22395,7 +22678,7 @@ async function beginReleaseLedger(deps, cwd, targetTag) {
22395
22678
  const closed = await alignmentLegClosedLive(deps, prior);
22396
22679
  if (closed) {
22397
22680
  prior = { ...prior, phases: { ...prior.phases, alignment: closed }, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
22398
- atomicWriteJson(path2, prior);
22681
+ await fence(() => atomicWriteJson(path2, prior));
22399
22682
  derived = deriveReleasePhaseStatus(prior);
22400
22683
  }
22401
22684
  }
@@ -22418,13 +22701,13 @@ async function beginReleaseLedger(deps, cwd, targetTag) {
22418
22701
  }
22419
22702
  const archivePath = (0, import_node_path22.join)((0, import_node_path22.dirname)(path2), `phases.archive-${prior.tag}.json`);
22420
22703
  try {
22421
- (0, import_node_fs22.unlinkSync)(archivePath);
22704
+ await fence(() => (0, import_node_fs22.unlinkSync)(archivePath));
22422
22705
  } catch (e) {
22423
22706
  if (e.code !== "ENOENT") {
22424
22707
  return { ok: false, error: `could not clear the stale archive at ${archivePath} (${e instanceof Error ? e.message : String(e)}) \u2014 the ledger for ${prior.tag} stays active (#5987)` };
22425
22708
  }
22426
22709
  }
22427
- (0, import_node_fs22.renameSync)(path2, archivePath);
22710
+ await fence(() => (0, import_node_fs22.renameSync)(path2, archivePath));
22428
22711
  return { ok: true, note: `archived the completed release ledger ${prior.tag} to ${archivePath} (live-proven at ${prior.tagSha.slice(0, 12)})` };
22429
22712
  });
22430
22713
  }
@@ -22449,7 +22732,7 @@ async function defaultRetryDeploy(deps, prior) {
22449
22732
  }
22450
22733
  async function retryHotfixDeployLegs(deps, cwd, opts = {}) {
22451
22734
  const path2 = releaseLedgerPath(cwd);
22452
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22735
+ return withLedgerLock(path2, async (fence) => {
22453
22736
  let prior;
22454
22737
  try {
22455
22738
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -22530,7 +22813,7 @@ async function retryHotfixDeployLegs(deps, cwd, opts = {}) {
22530
22813
  );
22531
22814
  }
22532
22815
  }
22533
- atomicWriteJson(path2, { ...prior, phases, updatedAt: now().toISOString() });
22816
+ await fence(() => atomicWriteJson(path2, { ...prior, phases, updatedAt: now().toISOString() }));
22534
22817
  const stillFailed = retried.filter((phase) => phases[phase].state === "failed");
22535
22818
  const status = stillFailed.length === 0 ? "success" : "failed";
22536
22819
  return {
@@ -22683,6 +22966,7 @@ var defaultDeps2 = {
22683
22966
  },
22684
22967
  healAlignmentLeg: healStaleAlignmentLeg,
22685
22968
  healRunLegs: healUnfoldedRunLegs,
22969
+ trainWriterHolder: liveTrainWriterHolder,
22686
22970
  readSurfacesRaw: (cwd) => {
22687
22971
  try {
22688
22972
  return (0, import_node_fs23.readFileSync)((0, import_node_path23.join)(cwd, "surfaces.json"), "utf8");
@@ -23025,12 +23309,17 @@ async function runTrainDoctor(input) {
23025
23309
  break;
23026
23310
  }
23027
23311
  }
23312
+ const runningTrain = deps.trainWriterHolder(cwd);
23313
+ if (runningTrain) {
23314
+ add({ code: "train-running", severity: "blocker", source: "local", title: `another train is running on this checkout: ${runningTrain}`, remedy: "wait for it to finish, then rerun; if that process is gone, just rerun \u2014 a dead holder's lock is reclaimed automatically (#7027)" });
23315
+ }
23316
+ const healLedger = heal && !runningTrain;
23028
23317
  if (ledgerRaw !== void 0 && ledgerPath) {
23029
23318
  try {
23030
23319
  let ledger = parseReleaseLedger(ledgerRaw);
23031
23320
  let derived = deriveReleasePhaseStatus(ledger);
23032
23321
  const deployModel = typeof meta?.deployModel === "string" && isDeployModel(meta.deployModel) ? meta.deployModel : void 0;
23033
- if (heal && deployModel && derived.legs.some((l) => (l.phase === "deploy" || l.phase === "publish") && l.state === "pending")) {
23322
+ if (healLedger && deployModel && derived.legs.some((l) => (l.phase === "deploy" || l.phase === "publish") && l.state === "pending")) {
23034
23323
  const run = await deps.healRunLegs(train, cwd, deployModel);
23035
23324
  if (run) {
23036
23325
  add({
@@ -23044,7 +23333,7 @@ async function runTrainDoctor(input) {
23044
23333
  derived = deriveReleasePhaseStatus(ledger);
23045
23334
  }
23046
23335
  }
23047
- if (heal && derived.phaseStatus === "phases-pending" && derived.legs.every((l) => l.state !== "pending" || l.phase === "alignment")) {
23336
+ if (healLedger && derived.phaseStatus === "phases-pending" && derived.legs.every((l) => l.state !== "pending" || l.phase === "alignment")) {
23048
23337
  const closedTag = await deps.healAlignmentLeg(train, cwd);
23049
23338
  if (closedTag) {
23050
23339
  add({ code: "ledger-alignment-stale", severity: "healed", source: "origin", title: `closed the alignment-pending leg of ${closedTag}: its alignment PR(s) read MERGED on a fresh origin read (#6061)`, remedy: "nothing \u2014 the ledger is phases-green" });
@@ -24070,18 +24359,11 @@ ${recovery.note}`), recoveryInput);
24070
24359
  releaseAnnounceDeferral(deployModel, options.announceSummaryFile)
24071
24360
  );
24072
24361
  const aligning = watch && alignment ? awaitAlignmentsLanded(deps, ctx, alignment) : Promise.resolve(alignment);
24073
- let dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
24074
- dispatch = await appendMmiGetRedeploy(
24075
- deps,
24076
- { repo: ctx.repo, publishStatus: publishLeg.state === "complete" ? "success" : "failure", watch },
24077
- dispatch
24078
- );
24079
- dispatch = await appendServedChannelPublish(
24362
+ const dispatch = await appendServedChannelLegs(
24080
24363
  deps,
24081
- { repo: ctx.repo, publishStatus: publishLeg.state === "complete" ? "success" : "failure", watch },
24082
- dispatch
24364
+ { repo: ctx.repo, tag, releaseSha, watch, deploy: deployLeg, publish: publishLeg, installer: meta.installer },
24365
+ appendPublishDispatch(deployDispatch, publishDispatch)
24083
24366
  );
24084
- dispatch = await appendServedChannelProof(deps, { repo: ctx.repo, tag, shipped: shipLegsGreen(deployLeg, publishLeg) }, dispatch);
24085
24367
  alignment = await aligning;
24086
24368
  const announceNote = await announcing;
24087
24369
  return { checks, releaseUrl, announceNote, dispatch, ledgerAnchors, ...alignment ? { alignment } : {} };
@@ -24150,6 +24432,9 @@ async function probeAncestor(deps, ancestor, descendant, label) {
24150
24432
  }
24151
24433
  }
24152
24434
  async function runRcandResume(deps, options = {}) {
24435
+ return withTrainWriter(deps, "rcand --resume", () => runRcandResumeLocked(deps, options));
24436
+ }
24437
+ async function runRcandResumeLocked(deps, options) {
24153
24438
  const watch = options.watch ?? false;
24154
24439
  const ctx = await buildTrainApplyContext(deps);
24155
24440
  await requireCleanTree(deps);
@@ -24410,6 +24695,12 @@ Nothing was written. Inspect ${ledger.path} (or clear it only after proving the
24410
24695
  await recordPhase(ledger, deps, anchors, "githubRelease", phaseEntry({ state: "complete", sha: tagSha, note: "re-verified live: the GitHub Release exists at the immutable tag SHA" }), { strict: true, landed: "the promotion (verified live)" });
24411
24696
  await recordPhase(ledger, deps, anchors, "promotion", phaseEntry({ state: "complete", sha: tagSha, note: "re-verified live: origin/main contains the immutable tag SHA" }), { strict: true, landed: "nothing beyond the already-public release" });
24412
24697
  }
24698
+ const shippedDispatch = await appendServedChannelLegs(
24699
+ deps,
24700
+ { repo: ctx.repo, tag, releaseSha: tagSha, watch, deploy: phaseInputs.deploy, publish: phaseInputs.publish, installer: meta.installer },
24701
+ dispatch2
24702
+ );
24703
+ Object.assign(dispatch2, shippedDispatch);
24413
24704
  if (isJervHubRepo(ctx.repo)) steps2.push(dispatch2.note);
24414
24705
  const settledBySupersede = persisted?.phases.deploy.superseded === true || persisted?.phases.publish.superseded === true;
24415
24706
  const announceNote2 = persisted && !shippedBefore && !settledBySupersede && (prodReleaseTargets(deployModel).length > 0 || companion !== null) ? await announceShippedRelease(
@@ -24543,7 +24834,11 @@ ${recovery.note}`), recoveryInput);
24543
24834
  landed: "the immutable tag, the re-proven wall, origin/main, the verified GitHub Release, and the dispatched deploy path"
24544
24835
  });
24545
24836
  }
24546
- const dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
24837
+ const dispatch = await appendServedChannelLegs(
24838
+ deps,
24839
+ { repo: ctx.repo, tag, releaseSha: tagSha, watch, deploy: deployLeg, publish: publishLeg, installer: meta.installer },
24840
+ appendPublishDispatch(deployDispatch, publishDispatch)
24841
+ );
24547
24842
  const announcing = announceShippedRelease(
24548
24843
  deps,
24549
24844
  { repo: ctx.repo, tag, summaryFile: options.announceSummaryFile },
@@ -24600,6 +24895,9 @@ async function withCheckoutRestoredAfterReleaseResume(deps, startBranch, operati
24600
24895
  return withCheckoutRestoredAfterRelease(deps, result, startBranch);
24601
24896
  }
24602
24897
  async function runReleaseResume(deps, options = {}) {
24898
+ return withTrainWriter(deps, "release --resume", () => runReleaseResumeLocked(deps, options));
24899
+ }
24900
+ async function runReleaseResumeLocked(deps, options) {
24603
24901
  const ctx = await buildTrainApplyContext(deps);
24604
24902
  await requireCleanTree(deps);
24605
24903
  await runGitRemoteRead(deps, ["fetch", "origin", "--tags"]);
@@ -24615,6 +24913,10 @@ async function runReleaseResume(deps, options = {}) {
24615
24913
  );
24616
24914
  }
24617
24915
  async function runReleaseAbort(deps, options = {}) {
24916
+ if (!options.approved) return runReleaseAbortLocked(deps, options);
24917
+ return withTrainWriter(deps, "release --abort", () => runReleaseAbortLocked(deps, options));
24918
+ }
24919
+ async function runReleaseAbortLocked(deps, options) {
24618
24920
  if (!options.approved) {
24619
24921
  throw new Error("release --abort requires --apply after explicit approval; nothing was written");
24620
24922
  }
@@ -24689,7 +24991,7 @@ async function runReleaseSupersedeDeploy(deps, options = {}) {
24689
24991
  throw new Error("release --supersede-deploy requires --apply after explicit master approval; nothing was written");
24690
24992
  }
24691
24993
  try {
24692
- return await supersedeContentCausedDeployLeg(deps, process.cwd());
24994
+ return await withTrainWriter(deps, "release --supersede-deploy", () => supersedeContentCausedDeployLeg(deps, process.cwd()));
24693
24995
  } catch (e) {
24694
24996
  const message2 = e instanceof Error ? e.message : String(e);
24695
24997
  throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
@@ -24700,7 +25002,7 @@ async function runReleaseSupersedePublish(deps, options = {}) {
24700
25002
  throw new Error("release --supersede-publish requires --apply after explicit master approval; nothing was written");
24701
25003
  }
24702
25004
  try {
24703
- return await supersedeContentCausedPublishLeg(deps, process.cwd());
25005
+ return await withTrainWriter(deps, "release --supersede-publish", () => supersedeContentCausedPublishLeg(deps, process.cwd()));
24704
25006
  } catch (e) {
24705
25007
  const message2 = e instanceof Error ? e.message : String(e);
24706
25008
  throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
@@ -24711,13 +25013,17 @@ async function runReleaseSupersedeDeployAndPublish(deps, options = {}) {
24711
25013
  throw new Error("release --supersede-deploy --supersede-publish requires --apply after explicit master approval; nothing was written");
24712
25014
  }
24713
25015
  try {
24714
- return await supersedeContentCausedDeployAndPublishLegs(deps, process.cwd());
25016
+ return await withTrainWriter(deps, "release --supersede-deploy --supersede-publish", () => supersedeContentCausedDeployAndPublishLegs(deps, process.cwd()));
24715
25017
  } catch (e) {
24716
25018
  const message2 = e instanceof Error ? e.message : String(e);
24717
25019
  throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
24718
25020
  }
24719
25021
  }
24720
25022
  async function runReleasePublishRetry(deps, runId, options = {}) {
25023
+ if (!options.approved) return runReleasePublishRetryLocked(deps, runId, options);
25024
+ return withTrainWriter(deps, "release --retry-publish", () => runReleasePublishRetryLocked(deps, runId, options));
25025
+ }
25026
+ async function runReleasePublishRetryLocked(deps, runId, options) {
24721
25027
  if (!options.approved) {
24722
25028
  throw new Error("release --retry-publish requires --apply after explicit approval; nothing was written");
24723
25029
  }
@@ -25162,6 +25468,9 @@ async function resolveHotfixVersion(deps, env = process.env) {
25162
25468
  return { tag: `v${version}`, version, baseTag };
25163
25469
  }
25164
25470
  async function runTrainApply(command, deps, options = {}) {
25471
+ return withTrainWriter(deps, `${command} --apply`, () => runTrainApplyLocked(command, deps, options));
25472
+ }
25473
+ async function runTrainApplyLocked(command, deps, options = {}) {
25165
25474
  const watch = options.watch ?? false;
25166
25475
  await runTrainDoctor({ lane: command === "rcand" ? "rcand" : "release", dev: command === "release" && options.dev === true, heal: true, train: deps, refuse: true });
25167
25476
  const ctx = await buildTrainApplyContext(deps);
@@ -30588,7 +30897,7 @@ function registerDeployCommands(program3) {
30588
30897
  init_cli_shared();
30589
30898
 
30590
30899
  // src/project-set.ts
30591
- var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary"];
30900
+ var UNSET_KEYS = ["installer", "oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary"];
30592
30901
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
30593
30902
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
30594
30903
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -30846,6 +31155,17 @@ function parseRuntimeVaultOnlyVar(raw) {
30846
31155
  if (raw === "false") return false;
30847
31156
  throw new Error("org project set: runtimeVaultOnly must be true or false");
30848
31157
  }
31158
+ function parseInstallerVar(raw) {
31159
+ let value;
31160
+ try {
31161
+ value = JSON.parse(raw);
31162
+ } catch {
31163
+ throw new Error('org project set: installer must be JSON, e.g. {"product":"mm-strategy","family":"mmi"}');
31164
+ }
31165
+ const error = installerDeclarationError(value, "mmi");
31166
+ if (error && !error.includes("breaks the sign-in rule")) throw new Error(`org project set: ${error}`);
31167
+ return value;
31168
+ }
30849
31169
  function parseSeedCanaryVar(raw) {
30850
31170
  if (raw === "true") return true;
30851
31171
  if (raw === "false") return false;
@@ -31016,7 +31336,8 @@ var SETTABLE_VAR_KEYS = [
31016
31336
  "ciExemptReason",
31017
31337
  "gate",
31018
31338
  "secrets",
31019
- "seedCanary"
31339
+ "seedCanary",
31340
+ "installer"
31020
31341
  ];
31021
31342
  var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
31022
31343
  var SETTABLE_VAR_HINTS = {
@@ -31029,6 +31350,7 @@ var SETTABLE_VAR_HINTS = {
31029
31350
  consumesDesignSystem: '"fofu"',
31030
31351
  runtimeVaultOnly: "true|false",
31031
31352
  seedCanary: "true|false",
31353
+ installer: "JSON {product,family:jerv|mmi,login?:github|google,gate?:https origin of its own sign-in server} \u2014 the repo ships a one-line installer on get.jerv.ai or get.mutmut.work",
31032
31354
  repos: 'JSON array, e.g. ["mutmutco/mm-foo"]',
31033
31355
  oauth: "JSON {subdomains,domains,callbackPath,extraCallbackPaths}",
31034
31356
  requiredGcpApis: "comma-string",
@@ -31164,6 +31486,8 @@ function buildProjectSetPatch(input) {
31164
31486
  patch[key] = parseRuntimeVaultOnlyVar(raw);
31165
31487
  } else if (key === "seedCanary") {
31166
31488
  patch[key] = parseSeedCanaryVar(raw);
31489
+ } else if (key === "installer") {
31490
+ patch[key] = parseInstallerVar(raw);
31167
31491
  } else if (key === "consumesDesignSystem") {
31168
31492
  patch[key] = parseConsumesDesignSystemVar(raw);
31169
31493
  } else if (key === "publishDir") {
@@ -44403,6 +44727,9 @@ async function hotfixReleaseExists(deps, ctx, tag) {
44403
44727
  }
44404
44728
  }
44405
44729
  async function runHotfixStart(deps, options, doctor = runTrainDoctor) {
44730
+ return withTrainWriter(deps, "hotfix start", () => runHotfixStartLocked(deps, options, doctor));
44731
+ }
44732
+ async function runHotfixStartLocked(deps, options, doctor) {
44406
44733
  await doctor({ lane: "hotfix", heal: true, train: deps, refuse: true });
44407
44734
  const ctx = await buildTrainApplyContext(deps);
44408
44735
  const status = await deps.run("git", ["status", "--porcelain"]);
@@ -44700,6 +45027,24 @@ async function appendHotfixGatewayRun(deps, repo, tag, sha, runs, note) {
44700
45027
  }
44701
45028
  return gateway.note;
44702
45029
  }
45030
+ async function appendHotfixServedChannel(deps, input, runs, note) {
45031
+ const before = hotfixDispatchFromRuns(runs, note);
45032
+ const after = await appendServedChannelLegs(
45033
+ deps,
45034
+ { repo: input.repo, tag: input.tag, releaseSha: input.sha, watch: true, deploy: input.deploy, publish: input.publish, installer: input.installer },
45035
+ before
45036
+ );
45037
+ for (const row of servedChannelRowsAfter(before, after)) {
45038
+ runs.push({
45039
+ workflow: row.workflow,
45040
+ ...row.runId != null ? { runId: row.runId } : {},
45041
+ ...row.runUrl ? { url: row.runUrl } : {},
45042
+ ...row.runUrlNote ? { runUrlNote: row.runUrlNote } : {},
45043
+ conclusion: row.conclusion
45044
+ });
45045
+ }
45046
+ return after.note;
45047
+ }
44703
45048
  function hotfixPhaseInputsFromRuns(deployModel, runs, note, publishDispatch, publishRequired, opts) {
44704
45049
  const dispatch = hotfixDispatchFromRuns(runs, note);
44705
45050
  return {
@@ -44822,6 +45167,9 @@ async function assertConsumerBomLineage(deps, mergedSha, tag) {
44822
45167
  }
44823
45168
  }
44824
45169
  async function runHotfixRelease(deps, versionInput, options = {}, doctor = runTrainDoctor) {
45170
+ return withTrainWriter(deps, `hotfix release ${versionInput}`, () => runHotfixReleaseLocked(deps, versionInput, options, doctor));
45171
+ }
45172
+ async function runHotfixReleaseLocked(deps, versionInput, options, doctor) {
44825
45173
  await doctor({ lane: "hotfix", heal: true, train: deps, refuse: true });
44826
45174
  const ctx = await buildTrainApplyContext(deps);
44827
45175
  const { tag, version } = normalizeHotfixVersion(versionInput);
@@ -44968,6 +45316,7 @@ ${decision.note}`);
44968
45316
  );
44969
45317
  await recordHotfixPhase(ledger, anchors, "deploy", ledgerInputs.deploy, "the immutable tag, the green wall, origin/main, and the GitHub Release");
44970
45318
  await recordHotfixPhase(ledger, anchors, "publish", ledgerInputs.publish, "the immutable tag, the green wall, origin/main, the GitHub Release, and the deploy path");
45319
+ deployNote = await appendHotfixServedChannel(deps, { repo: ctx.repo, tag, sha: mergedSha, deploy: ledgerInputs.deploy, publish: ledgerInputs.publish, installer: meta.installer }, runs, deployNote);
44971
45320
  const announceNote = releaseExists ? void 0 : await announceShippedRelease(
44972
45321
  deps,
44973
45322
  { repo: ctx.repo, tag, summaryFile: options.announceSummaryFile },
@@ -45036,6 +45385,9 @@ ${decision.note}`);
45036
45385
  return { ...result, ...localSync ? { localSync } : {} };
45037
45386
  }
45038
45387
  async function runHotfixRetryDeploy(deps, versionInput) {
45388
+ return withTrainWriter(deps, `hotfix release ${versionInput} --retry-deploy`, () => runHotfixRetryDeployLocked(deps, versionInput));
45389
+ }
45390
+ async function runHotfixRetryDeployLocked(deps, versionInput) {
45039
45391
  const ctx = await buildTrainApplyContext(deps);
45040
45392
  const { tag } = normalizeHotfixVersion(versionInput);
45041
45393
  const status = await deps.run("git", ["status", "--porcelain"]);
@@ -45651,6 +46003,8 @@ async function announceSummaryRefusal(path2) {
45651
46003
  }
45652
46004
  function trainApplyDeps() {
45653
46005
  return {
46006
+ // #7027: every mutating train verb holds this checkout's train writer lock for its whole run.
46007
+ trainWriter: (verb, cwd) => acquireTrainWriterLock(cwd, `mmi-cli devops ${verb}`),
45654
46008
  run: async (file, args) => {
45655
46009
  const timeout = file === "node" && args[1] === "prepare" ? NODE_PREPARE_TIMEOUT_MS : file === "node" && args[1] === "verify" ? NODE_VERIFY_TIMEOUT_MS : file === "npm" && (args[0] === "version" || args[0] === "ci" || args[0] === "install") ? NPM_FOLD_TIMEOUT_MS : file === "npm" ? NPM_TRAIN_TIMEOUT_MS : file === "bash" && /jerv-gateway-release-deploy\.sh$/.test(args[0] ?? "") ? JERV_GATEWAY_DEPLOY_TIMEOUT_MS : file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
45656
46010
  try {
@@ -47866,10 +48220,9 @@ function resolveHouseShim(argv) {
47866
48220
  const flatPath = flatTokens.join(" ");
47867
48221
  const flatHouse = houseForPath(flatPath);
47868
48222
  if (!flatHouse || flatHouse === "core") return;
47869
- refuseHouseShim(
47870
- flatPath,
47871
- `mmi-cli: the flat '${flatPath}' alias was removed in ${COMPAT_SHIM_DEATH_WAVE} (#4316) \u2014 ${recoveryPointerFor(flatPath)}`
47872
- );
48223
+ process.stderr.write(`mmi-cli: '${flatPath}' is the old flat form \u2014 ran it as \`mmi-cli ${canonicalPathFor(registeredPathPrefix(flatPath))}\` (use that spelling)
48224
+ `);
48225
+ return;
47873
48226
  }
47874
48227
  const remainder = argv.slice(3);
47875
48228
  const first = remainder[0];
@@ -47891,6 +48244,14 @@ function resolveHouseShim(argv) {
47891
48244
  if (actual && isDeclaredHouseAlias(houseToken, lookupPath)) {
47892
48245
  process.stderr.write(
47893
48246
  `mmi-cli: '${lookupPath}' lives in house '${actual}', not '${houseToken}' \u2014 routed to \`mmi-cli ${canonicalPathFor(lookupPath)}\`
48247
+ `
48248
+ );
48249
+ argv.splice(2, 1);
48250
+ return;
48251
+ }
48252
+ if (actual && actual !== "core" && registeredPathPrefix(lookupPath) === lookupPath) {
48253
+ process.stderr.write(
48254
+ `mmi-cli: '${lookupPath}' lives in house '${actual}', not '${houseToken}' \u2014 ran it as \`mmi-cli ${canonicalPathFor(lookupPath)}\`
47894
48255
  `
47895
48256
  );
47896
48257
  argv.splice(2, 1);
@@ -47919,8 +48280,8 @@ function printHouseRootHelp(house) {
47919
48280
  }
47920
48281
  lines2.push(
47921
48282
  "",
47922
- `Canonical: \`mmi-cli ${house} <command> \u2026\`. The flat \`mmi-cli <command> \u2026\` compatibility shims`,
47923
- `were removed in ${COMPAT_SHIM_DEATH_WAVE} (#4316, #4438) \u2014 only the canonical house-prefixed form parses.`
48283
+ `Canonical: \`mmi-cli ${house} <command> \u2026\`. The old flat \`mmi-cli <command> \u2026\` form still runs`,
48284
+ "with a notice naming the canonical spelling (#7034), so an out-of-date plugin keeps working."
47924
48285
  );
47925
48286
  consoleIo.log(lines2.join("\n"));
47926
48287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.5.0",
3
+ "version": "4.5.2",
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",