@mutmutco/cli 4.5.0 → 4.5.1

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 +451 -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.1",
16002
+ tag: "v4.5.1",
16003
+ commit: "489af0245908",
16004
+ npm: "@mutmutco/cli@4.5.1"
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.1"
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.1 and redeploy the Hub Lambda from tag v4.5.1 (489af0245908); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
16028
+ v3Target: "v4.5.1 (@mutmutco/cli@4.5.1, tag commit 489af0245908 \u2014 last known-good release carrying the repo-index v4-only contract)"
16007
16029
  }
16008
16030
  },
16009
16031
  {
@@ -18779,6 +18801,60 @@ 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
+ return `https://${INSTALLER_FAMILY_HOSTS[installer.family]}/${installer.product}/release/version`;
18818
+ }
18819
+ function installerDeclarationError(value, division) {
18820
+ if (value === void 0 || value === null) return null;
18821
+ if (typeof value !== "object" || Array.isArray(value)) {
18822
+ return "installer must be an object {product, family, login?}";
18823
+ }
18824
+ const record = (
18825
+ /** @type {Record<string, unknown>} */
18826
+ value
18827
+ );
18828
+ for (const key of Object.keys(record)) {
18829
+ if (!["product", "family", "login"].includes(key)) return `installer.${key} is not a field \u2014 expected product, family and optionally login`;
18830
+ }
18831
+ if (typeof record.product !== "string" || !PRODUCT_RE.test(record.product)) {
18832
+ return "installer.product must be a lowercase kebab name (e.g. mm-strategy) \u2014 it is the /<product> path on the family host";
18833
+ }
18834
+ if (!INSTALLER_FAMILIES.includes(
18835
+ /** @type {string} */
18836
+ record.family
18837
+ )) {
18838
+ return `installer.family must be one of: ${INSTALLER_FAMILIES.join(", ")} (jerv \u2192 ${INSTALLER_FAMILY_HOSTS.jerv}, mmi \u2192 ${INSTALLER_FAMILY_HOSTS.mmi})`;
18839
+ }
18840
+ if (record.login !== void 0 && !INSTALLER_LOGINS.includes(
18841
+ /** @type {string} */
18842
+ record.login
18843
+ )) {
18844
+ return `installer.login must be one of: ${INSTALLER_LOGINS.join(", ")}`;
18845
+ }
18846
+ const expected = expectedInstallerLogin(
18847
+ /** @type {'jerv' | 'mmi'} */
18848
+ record.family,
18849
+ division
18850
+ );
18851
+ if (record.login !== void 0 && record.login !== expected) {
18852
+ 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";
18853
+ return `installer.login ${record.login} breaks the sign-in rule: ${who} (owner ruling 2026-09-23)`;
18854
+ }
18855
+ return null;
18856
+ }
18857
+
18782
18858
  // src/tenant-control-parse.ts
18783
18859
  var OUTPUT_BEGIN = "mmi-control-output-begin";
18784
18860
  var OUTPUT_END = "mmi-control-output-end";
@@ -18961,7 +19037,7 @@ function mmiGetRedeployCommand(repo) {
18961
19037
  }
18962
19038
  function readLocalLauncherVersion(root = process.cwd()) {
18963
19039
  try {
18964
- const raw = (0, import_node_fs19.readFileSync)((0, import_node_path17.join)(root, "installer", "launcher", "package.json"), "utf8");
19040
+ const raw = (0, import_node_fs19.readFileSync)((0, import_node_path17.join)(root, "cli", "package.json"), "utf8");
18965
19041
  const version = JSON.parse(raw).version;
18966
19042
  return typeof version === "string" && version.trim() ? version.trim() : null;
18967
19043
  } catch {
@@ -18990,17 +19066,54 @@ async function fetchServedChannelVersion(url, fetchImpl = fetch) {
18990
19066
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
18991
19067
  }
18992
19068
  }
19069
+ async function findPriorGreenDispatch(deps, repo, workflow, sha, titleTokens) {
19070
+ if (!/^[0-9a-f]{40}$/i.test(sha)) return void 0;
19071
+ try {
19072
+ const rows = JSON.parse(await deps.run("gh", [
19073
+ "run",
19074
+ "list",
19075
+ "--repo",
19076
+ repo,
19077
+ "--workflow",
19078
+ workflow,
19079
+ "--commit",
19080
+ sha,
19081
+ "--event",
19082
+ "workflow_dispatch",
19083
+ "--limit",
19084
+ "20",
19085
+ "--json",
19086
+ "databaseId,url,status,conclusion,displayTitle,headSha"
19087
+ ]));
19088
+ 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)));
19089
+ return hit ? { runId: hit.databaseId, ...hit.url ? { runUrl: hit.url } : {} } : void 0;
19090
+ } catch {
19091
+ return void 0;
19092
+ }
19093
+ }
19094
+ function planeFailureRow(workflow, evidence) {
19095
+ return { workflow, runUrlNote: evidence, conclusion: "failure" };
19096
+ }
18993
19097
  async function appendMmiGetRedeploy(deps, input, dispatch) {
18994
19098
  if (!isHubControlRepo(input.repo)) return dispatch;
18995
19099
  const remedy = mmiGetRedeployCommand(input.repo);
18996
- const withNote = (note, deployStatus) => ({
19100
+ const withNote = (note, deployStatus, row) => ({
18997
19101
  ...dispatch,
18998
19102
  note: `${dispatch.note}; ${note}`,
18999
- ...deployStatus ? { deployStatus } : {}
19103
+ ...deployStatus ? { deployStatus } : {},
19104
+ ...row ? { workflowRuns: [...dispatch.workflowRuns ?? [], row] } : {}
19000
19105
  });
19001
19106
  if (input.publishStatus !== "success") {
19002
19107
  return withNote(`mmi-get box NOT redeployed (publish did not succeed) \u2014 after the publish is green, run \`${remedy}\``);
19003
19108
  }
19109
+ const prior = input.releaseSha ? await findPriorGreenDispatch(deps, input.repo, "tenant-deploy.yml", input.releaseSha, [HUB_GET_SLUG, HUB_GET_STAGE]) : void 0;
19110
+ if (prior) {
19111
+ return {
19112
+ ...dispatch,
19113
+ 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`,
19114
+ workflowRuns: [...dispatch.workflowRuns ?? [], { workflow: "tenant-deploy.yml", runId: prior.runId, ...prior.runUrl ? { runUrl: prior.runUrl } : {}, conclusion: "success" }]
19115
+ };
19116
+ }
19004
19117
  const since = (deps.now ?? Date.now)();
19005
19118
  try {
19006
19119
  await deps.run("gh", [
@@ -19022,12 +19135,12 @@ async function appendMmiGetRedeploy(deps, input, dispatch) {
19022
19135
  ]);
19023
19136
  } catch (e) {
19024
19137
  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");
19138
+ 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
19139
  }
19027
19140
  const correlated = await correlateTenantRun(deps, since, [HUB_GET_SLUG, HUB_GET_STAGE]);
19028
19141
  if (correlated.read === "failed" || correlated.read === "absent") {
19029
19142
  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");
19143
+ 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
19144
  }
19032
19145
  const { runId, runUrl } = correlated;
19033
19146
  const runRow = { workflow: "tenant-deploy.yml", ...runId ? { runId } : {}, ...runUrl ? { runUrl } : {} };
@@ -19057,35 +19170,44 @@ async function appendServedChannelPublish(deps, input, dispatch) {
19057
19170
  if (!isHubControlRepo(input.repo)) return dispatch;
19058
19171
  const channelUrl = input.channelUrl ?? readServedChannelUrl();
19059
19172
  const remedy = getPublishDispatchCommand(input.repo);
19060
- const withNote = (note, deployStatus) => ({
19173
+ const withNote = (note, deployStatus, row) => ({
19061
19174
  ...dispatch,
19062
19175
  note: `${dispatch.note}; ${note}`,
19063
- ...deployStatus ? { deployStatus } : {}
19176
+ ...deployStatus ? { deployStatus } : {},
19177
+ ...row ? { workflowRuns: [...dispatch.workflowRuns ?? [], row] } : {}
19064
19178
  });
19065
19179
  if (input.publishStatus !== "success") {
19066
19180
  return withNote(`served channel NOT moved (publish did not succeed) \u2014 after the publish is green, run \`${remedy}\``);
19067
19181
  }
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
- };
19182
+ const prior = input.releaseSha ? await findPriorGreenDispatch(deps, input.repo, "get-publish.yml", input.releaseSha, [HUB_SERVED_PRODUCT]) : void 0;
19183
+ let runRow;
19184
+ let runStatus;
19185
+ if (prior) {
19186
+ runRow = { workflow: "get-publish.yml", runId: prior.runId, ...prior.runUrl ? { runUrl: prior.runUrl } : {} };
19187
+ runStatus = "success";
19188
+ } else {
19189
+ const since = (deps.now ?? Date.now)();
19190
+ try {
19191
+ await deps.run("gh", ["workflow", "run", "get-publish.yml", "--repo", input.repo, "--ref", "main", "-f", `product=${HUB_SERVED_PRODUCT}`]);
19192
+ } catch (e) {
19193
+ const msg = e instanceof Error ? e.message : String(e);
19194
+ 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}`));
19195
+ }
19196
+ const correlated = await correlateGetPublishRun(deps, since, HUB_SERVED_PRODUCT);
19197
+ if (correlated.read === "failed") {
19198
+ 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`));
19199
+ }
19200
+ const { runId, runUrl } = correlated;
19201
+ runRow = { workflow: "get-publish.yml", ...runId ? { runId } : {}, ...runUrl ? { runUrl } : {} };
19202
+ if (!input.watch) {
19203
+ return {
19204
+ ...dispatch,
19205
+ note: `${dispatch.note}; dispatched get-publish.yml (product=${HUB_SERVED_PRODUCT}) \u2014 NOT watched, so the served channel is not yet proven moved`,
19206
+ workflowRuns: [...dispatch.workflowRuns ?? [], { ...runRow, conclusion: "pending" }]
19207
+ };
19208
+ }
19209
+ runStatus = await watchTenantRun(deps, runId, input.repo);
19087
19210
  }
19088
- const runStatus = await watchTenantRun(deps, runId, input.repo);
19089
19211
  if (runStatus !== "success") {
19090
19212
  return {
19091
19213
  ...dispatch,
@@ -19170,13 +19292,22 @@ var SERVED_CHANNELS = {
19170
19292
  remedy: JERV_GATEWAY_REMEDY
19171
19293
  }
19172
19294
  };
19173
- function servedChannelFor(repo) {
19174
- return SERVED_CHANNELS[repo.toLowerCase()];
19295
+ function servedChannelFor(repo, installer) {
19296
+ const known = SERVED_CHANNELS[repo.toLowerCase()];
19297
+ if (!installer) return known;
19298
+ if (isHubControlRepo(repo)) return void 0;
19299
+ const url = installerVersionUrl(installer);
19300
+ return {
19301
+ product: installer.product,
19302
+ url,
19303
+ mover: known?.mover ?? `this repo's release (${installer.family === "jerv" ? "get.jerv.ai" : "get.mutmut.work"} serves /${installer.product})`,
19304
+ remedy: known?.remedy ?? `re-run the release's served-channel publish for ${installer.product} (\`mmi-cli devops release --resume\`)`
19305
+ };
19175
19306
  }
19176
19307
  var SERVED_CHANNEL_PROBE_ATTEMPTS = 3;
19177
19308
  var SERVED_CHANNEL_PROBE_BACKOFF_MS = 1e4;
19178
19309
  async function appendServedChannelProof(deps, input, dispatch) {
19179
- const channel = servedChannelFor(input.repo);
19310
+ const channel = servedChannelFor(input.repo, input.installer);
19180
19311
  if (!channel) return dispatch;
19181
19312
  const expected = input.tag.replace(/^v/, "");
19182
19313
  const withProof = (conclusion, evidence, note, failed = false) => ({
@@ -19216,6 +19347,15 @@ async function appendServedChannelProof(deps, input, dispatch) {
19216
19347
  }
19217
19348
  return withProof("success", `${channel.url} reports ${probe.version}`, `served channel moved \u2014 ${channel.url} reports ${probe.version}`);
19218
19349
  }
19350
+ async function appendServedChannelLegs(deps, input, dispatch) {
19351
+ const publishStatus = input.publish?.state === "complete" ? "success" : "failure";
19352
+ let out = await appendMmiGetRedeploy(deps, { repo: input.repo, publishStatus, watch: input.watch, releaseSha: input.releaseSha }, dispatch);
19353
+ out = await appendServedChannelPublish(deps, { repo: input.repo, publishStatus, watch: input.watch, releaseSha: input.releaseSha }, out);
19354
+ return appendServedChannelProof(deps, { repo: input.repo, tag: input.tag, shipped: shipLegsGreen(input.deploy, input.publish), installer: input.installer }, out);
19355
+ }
19356
+ function servedChannelRowsAfter(before, after) {
19357
+ return (after.workflowRuns ?? []).slice((before.workflowRuns ?? []).length);
19358
+ }
19219
19359
  var JERV_GATEWAY_RUN_URL_NOTE = "no run URL emitted \u2014 host health leg runs outside GitHub Actions";
19220
19360
  var JERV_GATEWAY_RELEASE_DEPLOY_SCRIPT = "scripts/jerv-gateway-release-deploy.sh";
19221
19361
  async function runTaggedJervGatewayLocalWrapper(deps, tag, tagSha) {
@@ -20852,6 +20992,20 @@ var import_node_crypto5 = require("node:crypto");
20852
20992
  // src/file-lock.ts
20853
20993
  var import_promises2 = require("node:fs/promises");
20854
20994
  var import_node_path21 = require("node:path");
20995
+ function pidIsDead(pid) {
20996
+ if (!Number.isInteger(pid) || pid <= 0) return false;
20997
+ if (pid === process.pid) return false;
20998
+ try {
20999
+ process.kill(pid, 0);
21000
+ return false;
21001
+ } catch (e) {
21002
+ return e.code === "ESRCH";
21003
+ }
21004
+ }
21005
+ function lockTokenPid(token) {
21006
+ const m = /^(\d+)-/.exec(token);
21007
+ return m ? Number(m[1]) : void 0;
21008
+ }
20855
21009
  var sleep = (ms) => new Promise((resolve7) => setTimeout(resolve7, ms));
20856
21010
  var IMMEDIATE_RETRY_BUDGET = 3;
20857
21011
  var FileLockBusyError = class extends Error {
@@ -20867,9 +21021,24 @@ function resolveFileLockOpts(opts = {}) {
20867
21021
  staleMs: opts.staleMs ?? 3e4,
20868
21022
  retryMs: opts.retryMs ?? 50,
20869
21023
  maxWaitMs: opts.maxWaitMs ?? 5e3,
20870
- label: opts.label ?? "file lock"
21024
+ label: opts.label ?? "file lock",
21025
+ deadHolderSteal: opts.deadHolderSteal ?? false,
21026
+ holder: opts.holder ?? ""
20871
21027
  };
20872
21028
  }
21029
+ async function lockIsStealable(lockPath, opts) {
21030
+ const age = Date.now() - (await (0, import_promises2.stat)(lockPath)).mtimeMs;
21031
+ if (age > opts.staleMs) return true;
21032
+ if (!opts.deadHolderSteal) return false;
21033
+ let token;
21034
+ try {
21035
+ token = await (0, import_promises2.readFile)(lockPath, "utf8");
21036
+ } catch {
21037
+ return false;
21038
+ }
21039
+ const pid = lockTokenPid(token);
21040
+ return pid !== void 0 && pidIsDead(pid);
21041
+ }
20873
21042
  async function acquireFileLock(lockPath, opts, deadline) {
20874
21043
  let immediateRetries = 0;
20875
21044
  for (; ; ) {
@@ -20880,12 +21049,12 @@ async function acquireFileLock(lockPath, opts, deadline) {
20880
21049
  const code = e.code;
20881
21050
  if (code !== "EEXIST" && code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw e;
20882
21051
  const retryNow = async () => {
21052
+ if (++immediateRetries <= IMMEDIATE_RETRY_BUDGET) return;
20883
21053
  if (Date.now() >= deadline) throw new FileLockBusyError(opts.label, lockPath, opts.maxWaitMs);
20884
- if (++immediateRetries > IMMEDIATE_RETRY_BUDGET) await sleep(opts.retryMs);
21054
+ await sleep(opts.retryMs);
20885
21055
  };
20886
21056
  try {
20887
- const age = Date.now() - (await (0, import_promises2.stat)(lockPath)).mtimeMs;
20888
- if (age > opts.staleMs) {
21057
+ if (await lockIsStealable(lockPath, opts)) {
20889
21058
  try {
20890
21059
  await (0, import_promises2.unlink)(lockPath);
20891
21060
  await retryNow();
@@ -20911,7 +21080,8 @@ async function acquireFileLock(lockPath, opts, deadline) {
20911
21080
  await sleep(opts.retryMs);
20912
21081
  continue;
20913
21082
  }
20914
- const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
21083
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
21084
+ const token = opts.holder ? `${stamp} ${opts.holder.replace(/\s+/g, " ").trim()}` : stamp;
20915
21085
  try {
20916
21086
  await handle.writeFile(token);
20917
21087
  } catch (e) {
@@ -20935,11 +21105,14 @@ async function releaseFileLock(lockPath, guard) {
20935
21105
  }
20936
21106
  }
20937
21107
  async function withFileLock(lockPath, opts, fn) {
21108
+ return withFencedFileLock(lockPath, opts, () => fn());
21109
+ }
21110
+ async function withFencedFileLock(lockPath, opts, fn) {
20938
21111
  const resolved = resolveFileLockOpts(opts);
20939
21112
  await (0, import_promises2.mkdir)((0, import_node_path21.dirname)(lockPath), { recursive: true }).catch(() => void 0);
20940
21113
  const guard = await acquireFileLock(lockPath, resolved, Date.now() + resolved.maxWaitMs);
20941
21114
  try {
20942
- return await fn();
21115
+ return await fn(() => fileLockHeldBy(lockPath, guard.token));
20943
21116
  } finally {
20944
21117
  await releaseFileLock(lockPath, guard);
20945
21118
  }
@@ -21177,8 +21350,114 @@ function atomicWriteJson(path2, ledger) {
21177
21350
  `, "utf8");
21178
21351
  (0, import_node_fs22.renameSync)(tmp, path2);
21179
21352
  }
21353
+ var LEDGER_LOCK_OPTS = { label: "release ledger", staleMs: 18e4, deadHolderSteal: true };
21354
+ async function withLedgerLock(path2, fn, opts = {}) {
21355
+ return withFencedFileLock(`${path2}.lock`, { ...LEDGER_LOCK_OPTS, ...opts.maxWaitMs != null ? { maxWaitMs: opts.maxWaitMs } : {} }, (stillHeld) => fn(async (write) => {
21356
+ if (!await stillHeld()) {
21357
+ 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)`);
21358
+ }
21359
+ return write();
21360
+ }));
21361
+ }
21362
+ function checkoutRootFrom(cwd) {
21363
+ let dir = (0, import_node_path22.resolve)(cwd);
21364
+ for (; ; ) {
21365
+ if (resolveGitDir(dir)) return dir;
21366
+ const parent = (0, import_node_path22.dirname)(dir);
21367
+ if (parent === dir) throw new ReleaseLedgerError("no-checkout", `train writer lock: no git checkout found above ${cwd}`);
21368
+ dir = parent;
21369
+ }
21370
+ }
21371
+ function trainWriterLockPath(cwd) {
21372
+ return (0, import_node_path22.join)((0, import_node_path22.dirname)(releaseLedgerPath(checkoutRootFrom(cwd))), "train.lock");
21373
+ }
21374
+ var TrainWriterBusyError = class extends Error {
21375
+ lockPath;
21376
+ holder;
21377
+ constructor(lockPath, holder, verb) {
21378
+ super(
21379
+ `${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)`
21380
+ );
21381
+ this.name = "TrainWriterBusyError";
21382
+ this.lockPath = lockPath;
21383
+ this.holder = holder;
21384
+ }
21385
+ };
21386
+ var TRAIN_WRITER_STALE_MS = 12 * 60 * 6e4;
21387
+ function describeTrainWriterToken(token) {
21388
+ const pid = lockTokenPid(token);
21389
+ const [stamp, ...rest] = token.trim().split(" ");
21390
+ const since = Number((stamp ?? "").split("-")[1]);
21391
+ const when = Number.isFinite(since) && since > 0 ? `, since ${new Date(since).toISOString()}` : "";
21392
+ const what = rest.join(" ").trim();
21393
+ return `${what || "a train command"}${pid ? `, pid ${pid}` : ""}${when}`;
21394
+ }
21395
+ function liveTrainWriterHolder(cwd) {
21396
+ let token;
21397
+ try {
21398
+ token = (0, import_node_fs22.readFileSync)(trainWriterLockPath(cwd), "utf8");
21399
+ } catch {
21400
+ return void 0;
21401
+ }
21402
+ const pid = lockTokenPid(token);
21403
+ if (pid === void 0 || pid === process.pid || pidIsDead(pid)) return void 0;
21404
+ return describeTrainWriterToken(token);
21405
+ }
21406
+ var heldTrainWriter;
21407
+ async function acquireTrainWriterLock(cwd, verb) {
21408
+ let lockPath;
21409
+ try {
21410
+ lockPath = trainWriterLockPath(cwd);
21411
+ } catch {
21412
+ return async () => void 0;
21413
+ }
21414
+ if (heldTrainWriter && heldTrainWriter.lockPath === lockPath) {
21415
+ heldTrainWriter.depth += 1;
21416
+ return async () => {
21417
+ if (heldTrainWriter) heldTrainWriter.depth -= 1;
21418
+ };
21419
+ }
21420
+ (0, import_node_fs22.mkdirSync)((0, import_node_path22.dirname)(lockPath), { recursive: true });
21421
+ const opts = resolveFileLockOpts({ label: "train writer", staleMs: TRAIN_WRITER_STALE_MS, deadHolderSteal: true, maxWaitMs: 0, holder: verb });
21422
+ let guard;
21423
+ try {
21424
+ guard = await acquireFileLock(lockPath, opts, Date.now());
21425
+ } catch (e) {
21426
+ if (!(e instanceof FileLockBusyError)) throw e;
21427
+ let token = "";
21428
+ try {
21429
+ token = (0, import_node_fs22.readFileSync)(lockPath, "utf8");
21430
+ } catch {
21431
+ token = "";
21432
+ }
21433
+ throw new TrainWriterBusyError(lockPath, token ? describeTrainWriterToken(token) : "holder unreadable", verb);
21434
+ }
21435
+ const held = {
21436
+ lockPath,
21437
+ depth: 1,
21438
+ release: async () => {
21439
+ heldTrainWriter = void 0;
21440
+ await releaseFileLock(lockPath, guard);
21441
+ }
21442
+ };
21443
+ heldTrainWriter = held;
21444
+ return async () => {
21445
+ held.depth -= 1;
21446
+ if (held.depth <= 0 && heldTrainWriter === held) await held.release();
21447
+ };
21448
+ }
21449
+ async function withTrainWriter(deps, verb, fn) {
21450
+ const seam = "trainWriter" in deps ? deps.trainWriter : void 0;
21451
+ if (!seam) return fn();
21452
+ const release = await seam(verb, process.cwd());
21453
+ try {
21454
+ return await fn();
21455
+ } finally {
21456
+ await release();
21457
+ }
21458
+ }
21180
21459
  async function withReleaseLedger(path2, expected, fn, opts = {}) {
21181
- return withFileLock(`${path2}.lock`, { label: "release ledger", maxWaitMs: opts.maxWaitMs ?? 5e3 }, async () => {
21460
+ return withLedgerLock(path2, async (fence) => {
21182
21461
  let current;
21183
21462
  let raw;
21184
21463
  try {
@@ -21193,9 +21472,9 @@ async function withReleaseLedger(path2, expected, fn, opts = {}) {
21193
21472
  if (expected) validateLedgerAnchors(current, expected);
21194
21473
  }
21195
21474
  const { ledger, value } = await fn(current);
21196
- if (ledger) atomicWriteJson(path2, ledger);
21475
+ if (ledger) await fence(() => atomicWriteJson(path2, ledger));
21197
21476
  return value;
21198
- });
21477
+ }, { maxWaitMs: opts.maxWaitMs ?? 5e3 });
21199
21478
  }
21200
21479
  function openReleaseLedgerWriter(cwd, opts = {}) {
21201
21480
  const now = opts.now ?? (() => /* @__PURE__ */ new Date());
@@ -21810,7 +22089,7 @@ async function reverifyPersistedRunPhases(deps, path2, anchors, input) {
21810
22089
  return result;
21811
22090
  }
21812
22091
  async function clearLedgerFile(path2, anchors, maxWaitMs) {
21813
- return withFileLock(`${path2}.lock`, { label: "release ledger", maxWaitMs }, async () => {
22092
+ return withLedgerLock(path2, async (fence) => {
21814
22093
  let raw;
21815
22094
  try {
21816
22095
  raw = (0, import_node_fs22.readFileSync)(path2, "utf8");
@@ -21829,9 +22108,9 @@ async function clearLedgerFile(path2, anchors, maxWaitMs) {
21829
22108
  } catch (e) {
21830
22109
  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
22110
  }
21832
- (0, import_node_fs22.unlinkSync)(path2);
22111
+ await fence(() => (0, import_node_fs22.unlinkSync)(path2));
21833
22112
  return { cleared: true, note: `cleared the release ledger at ${path2} for the live-proven aborted ${anchors.tag}` };
21834
- });
22113
+ }, { maxWaitMs });
21835
22114
  }
21836
22115
  async function clearReleaseLedger(cwd, anchors, opts = {}) {
21837
22116
  let path2;
@@ -21971,7 +22250,7 @@ async function alignmentLegClosedLive(deps, prior) {
21971
22250
  }
21972
22251
  async function healStaleAlignmentLeg(deps, cwd, expected) {
21973
22252
  const path2 = releaseLedgerPath(cwd);
21974
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22253
+ return withLedgerLock(path2, async (fence) => {
21975
22254
  let prior;
21976
22255
  try {
21977
22256
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -21987,7 +22266,7 @@ async function healStaleAlignmentLeg(deps, cwd, expected) {
21987
22266
  }
21988
22267
  const closed = await alignmentLegClosedLive(deps, prior);
21989
22268
  if (!closed) return void 0;
21990
- atomicWriteJson(path2, { ...prior, phases: { ...prior.phases, alignment: closed }, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
22269
+ await fence(() => atomicWriteJson(path2, { ...prior, phases: { ...prior.phases, alignment: closed }, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
21991
22270
  return prior.tag;
21992
22271
  });
21993
22272
  }
@@ -22060,7 +22339,7 @@ function supersedeableDeployLegShape(rec) {
22060
22339
  }
22061
22340
  async function supersedeContentCausedDeployLeg(deps, cwd) {
22062
22341
  const path2 = releaseLedgerPath(cwd);
22063
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22342
+ return withLedgerLock(path2, async (fence) => {
22064
22343
  let prior;
22065
22344
  try {
22066
22345
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -22101,14 +22380,14 @@ async function supersedeContentCausedDeployLeg(deps, cwd) {
22101
22380
  );
22102
22381
  }
22103
22382
  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, {
22383
+ await fence(() => atomicWriteJson(path2, {
22105
22384
  ...prior,
22106
22385
  phases: {
22107
22386
  ...phases,
22108
22387
  deploy: { state: "skipped", sha: prior.tagSha, superseded: true, note, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
22109
22388
  },
22110
22389
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22111
- });
22390
+ }));
22112
22391
  return {
22113
22392
  command: "release-supersede-deploy",
22114
22393
  repo: prior.repo,
@@ -22124,7 +22403,7 @@ function supersedeablePublishLegShape(rec) {
22124
22403
  function deferredGatewayDeployLegShape(rec) {
22125
22404
  return rec.state === "pending" && rec.runId == null && rec.workflow === "jerv-gateway";
22126
22405
  }
22127
- async function proveOriginPromotionOrArchived(deps, prior, path2, label, ref) {
22406
+ async function proveOriginPromotionOrArchived(deps, prior, path2, label, ref, fence) {
22128
22407
  let liveSha = "";
22129
22408
  try {
22130
22409
  liveSha = (await deps.run("git", ["ls-remote", "origin", `refs/tags/${prior.tag}`])).trim().split(/\s+/)[0] ?? "";
@@ -22163,12 +22442,12 @@ async function proveOriginPromotionOrArchived(deps, prior, path2, label, ref) {
22163
22442
  if ((0, import_node_fs22.existsSync)(archivePath)) {
22164
22443
  throw new Error(`${label}: archive already exists at ${archivePath} \u2014 preserving both ledger records; resolve the collision by hand; nothing was written (${ref})`);
22165
22444
  }
22166
- (0, import_node_fs22.renameSync)(path2, archivePath);
22445
+ await fence(() => (0, import_node_fs22.renameSync)(path2, archivePath));
22167
22446
  return "deleted";
22168
22447
  }
22169
22448
  async function supersedeContentCausedPublishLeg(deps, cwd) {
22170
22449
  const path2 = releaseLedgerPath(cwd);
22171
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22450
+ return withLedgerLock(path2, async (fence) => {
22172
22451
  let prior;
22173
22452
  try {
22174
22453
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -22199,7 +22478,7 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
22199
22478
  `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
22479
  );
22201
22480
  }
22202
- if (await proveOriginPromotionOrArchived(deps, prior, path2, "release --supersede-publish", "#6638") === "deleted") {
22481
+ if (await proveOriginPromotionOrArchived(deps, prior, path2, "release --supersede-publish", "#6638", fence) === "deleted") {
22203
22482
  return {
22204
22483
  command: "release-supersede-publish",
22205
22484
  repo: prior.repo,
@@ -22228,14 +22507,14 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
22228
22507
  gatewayDeploy = { workflow: "jerv-gateway", state: "complete", note: dispatch.note };
22229
22508
  }
22230
22509
  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, {
22510
+ await fence(() => atomicWriteJson(path2, {
22232
22511
  ...prior,
22233
22512
  phases: {
22234
22513
  ...phases,
22235
22514
  publish: { state: "skipped", sha: prior.tagSha, superseded: true, note, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
22236
22515
  },
22237
22516
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22238
- });
22517
+ }));
22239
22518
  return {
22240
22519
  command: "release-supersede-publish",
22241
22520
  repo: prior.repo,
@@ -22250,7 +22529,7 @@ async function supersedeContentCausedDeployAndPublishLegs(deps, cwd) {
22250
22529
  const label = "release --supersede-deploy --supersede-publish";
22251
22530
  const ref = "#6752";
22252
22531
  const path2 = releaseLedgerPath(cwd);
22253
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22532
+ return withLedgerLock(path2, async (fence) => {
22254
22533
  let prior;
22255
22534
  try {
22256
22535
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -22298,7 +22577,7 @@ async function supersedeContentCausedDeployAndPublishLegs(deps, cwd) {
22298
22577
  `${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
22578
  );
22300
22579
  }
22301
- if (await proveOriginPromotionOrArchived(deps, prior, path2, label, ref) === "deleted") {
22580
+ if (await proveOriginPromotionOrArchived(deps, prior, path2, label, ref, fence) === "deleted") {
22302
22581
  return {
22303
22582
  command: "release-supersede-deploy-and-publish",
22304
22583
  repo: prior.repo,
@@ -22344,7 +22623,7 @@ async function supersedeContentCausedDeployAndPublishLegs(deps, cwd) {
22344
22623
  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
22624
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
22346
22625
  };
22347
- atomicWriteJson(path2, { ...prior, phases, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
22626
+ await fence(() => atomicWriteJson(path2, { ...prior, phases, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }));
22348
22627
  return {
22349
22628
  command: "release-supersede-deploy-and-publish",
22350
22629
  repo: prior.repo,
@@ -22375,7 +22654,7 @@ async function beginReleaseLedger(deps, cwd, targetTag) {
22375
22654
  return { ok: true };
22376
22655
  }
22377
22656
  await reverifyPriorRunLegs(deps, path2, targetTag);
22378
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22657
+ return withLedgerLock(path2, async (fence) => {
22379
22658
  let raw;
22380
22659
  try {
22381
22660
  raw = (0, import_node_fs22.readFileSync)(path2, "utf8");
@@ -22395,7 +22674,7 @@ async function beginReleaseLedger(deps, cwd, targetTag) {
22395
22674
  const closed = await alignmentLegClosedLive(deps, prior);
22396
22675
  if (closed) {
22397
22676
  prior = { ...prior, phases: { ...prior.phases, alignment: closed }, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
22398
- atomicWriteJson(path2, prior);
22677
+ await fence(() => atomicWriteJson(path2, prior));
22399
22678
  derived = deriveReleasePhaseStatus(prior);
22400
22679
  }
22401
22680
  }
@@ -22418,13 +22697,13 @@ async function beginReleaseLedger(deps, cwd, targetTag) {
22418
22697
  }
22419
22698
  const archivePath = (0, import_node_path22.join)((0, import_node_path22.dirname)(path2), `phases.archive-${prior.tag}.json`);
22420
22699
  try {
22421
- (0, import_node_fs22.unlinkSync)(archivePath);
22700
+ await fence(() => (0, import_node_fs22.unlinkSync)(archivePath));
22422
22701
  } catch (e) {
22423
22702
  if (e.code !== "ENOENT") {
22424
22703
  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
22704
  }
22426
22705
  }
22427
- (0, import_node_fs22.renameSync)(path2, archivePath);
22706
+ await fence(() => (0, import_node_fs22.renameSync)(path2, archivePath));
22428
22707
  return { ok: true, note: `archived the completed release ledger ${prior.tag} to ${archivePath} (live-proven at ${prior.tagSha.slice(0, 12)})` };
22429
22708
  });
22430
22709
  }
@@ -22449,7 +22728,7 @@ async function defaultRetryDeploy(deps, prior) {
22449
22728
  }
22450
22729
  async function retryHotfixDeployLegs(deps, cwd, opts = {}) {
22451
22730
  const path2 = releaseLedgerPath(cwd);
22452
- return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
22731
+ return withLedgerLock(path2, async (fence) => {
22453
22732
  let prior;
22454
22733
  try {
22455
22734
  prior = parseReleaseLedger((0, import_node_fs22.readFileSync)(path2, "utf8"));
@@ -22530,7 +22809,7 @@ async function retryHotfixDeployLegs(deps, cwd, opts = {}) {
22530
22809
  );
22531
22810
  }
22532
22811
  }
22533
- atomicWriteJson(path2, { ...prior, phases, updatedAt: now().toISOString() });
22812
+ await fence(() => atomicWriteJson(path2, { ...prior, phases, updatedAt: now().toISOString() }));
22534
22813
  const stillFailed = retried.filter((phase) => phases[phase].state === "failed");
22535
22814
  const status = stillFailed.length === 0 ? "success" : "failed";
22536
22815
  return {
@@ -22683,6 +22962,7 @@ var defaultDeps2 = {
22683
22962
  },
22684
22963
  healAlignmentLeg: healStaleAlignmentLeg,
22685
22964
  healRunLegs: healUnfoldedRunLegs,
22965
+ trainWriterHolder: liveTrainWriterHolder,
22686
22966
  readSurfacesRaw: (cwd) => {
22687
22967
  try {
22688
22968
  return (0, import_node_fs23.readFileSync)((0, import_node_path23.join)(cwd, "surfaces.json"), "utf8");
@@ -23025,12 +23305,17 @@ async function runTrainDoctor(input) {
23025
23305
  break;
23026
23306
  }
23027
23307
  }
23308
+ const runningTrain = deps.trainWriterHolder(cwd);
23309
+ if (runningTrain) {
23310
+ 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)" });
23311
+ }
23312
+ const healLedger = heal && !runningTrain;
23028
23313
  if (ledgerRaw !== void 0 && ledgerPath) {
23029
23314
  try {
23030
23315
  let ledger = parseReleaseLedger(ledgerRaw);
23031
23316
  let derived = deriveReleasePhaseStatus(ledger);
23032
23317
  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")) {
23318
+ if (healLedger && deployModel && derived.legs.some((l) => (l.phase === "deploy" || l.phase === "publish") && l.state === "pending")) {
23034
23319
  const run = await deps.healRunLegs(train, cwd, deployModel);
23035
23320
  if (run) {
23036
23321
  add({
@@ -23044,7 +23329,7 @@ async function runTrainDoctor(input) {
23044
23329
  derived = deriveReleasePhaseStatus(ledger);
23045
23330
  }
23046
23331
  }
23047
- if (heal && derived.phaseStatus === "phases-pending" && derived.legs.every((l) => l.state !== "pending" || l.phase === "alignment")) {
23332
+ if (healLedger && derived.phaseStatus === "phases-pending" && derived.legs.every((l) => l.state !== "pending" || l.phase === "alignment")) {
23048
23333
  const closedTag = await deps.healAlignmentLeg(train, cwd);
23049
23334
  if (closedTag) {
23050
23335
  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 +24355,11 @@ ${recovery.note}`), recoveryInput);
24070
24355
  releaseAnnounceDeferral(deployModel, options.announceSummaryFile)
24071
24356
  );
24072
24357
  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(
24358
+ const dispatch = await appendServedChannelLegs(
24080
24359
  deps,
24081
- { repo: ctx.repo, publishStatus: publishLeg.state === "complete" ? "success" : "failure", watch },
24082
- dispatch
24360
+ { repo: ctx.repo, tag, releaseSha, watch, deploy: deployLeg, publish: publishLeg, installer: meta.installer },
24361
+ appendPublishDispatch(deployDispatch, publishDispatch)
24083
24362
  );
24084
- dispatch = await appendServedChannelProof(deps, { repo: ctx.repo, tag, shipped: shipLegsGreen(deployLeg, publishLeg) }, dispatch);
24085
24363
  alignment = await aligning;
24086
24364
  const announceNote = await announcing;
24087
24365
  return { checks, releaseUrl, announceNote, dispatch, ledgerAnchors, ...alignment ? { alignment } : {} };
@@ -24150,6 +24428,9 @@ async function probeAncestor(deps, ancestor, descendant, label) {
24150
24428
  }
24151
24429
  }
24152
24430
  async function runRcandResume(deps, options = {}) {
24431
+ return withTrainWriter(deps, "rcand --resume", () => runRcandResumeLocked(deps, options));
24432
+ }
24433
+ async function runRcandResumeLocked(deps, options) {
24153
24434
  const watch = options.watch ?? false;
24154
24435
  const ctx = await buildTrainApplyContext(deps);
24155
24436
  await requireCleanTree(deps);
@@ -24410,6 +24691,12 @@ Nothing was written. Inspect ${ledger.path} (or clear it only after proving the
24410
24691
  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
24692
  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
24693
  }
24694
+ const shippedDispatch = await appendServedChannelLegs(
24695
+ deps,
24696
+ { repo: ctx.repo, tag, releaseSha: tagSha, watch, deploy: phaseInputs.deploy, publish: phaseInputs.publish, installer: meta.installer },
24697
+ dispatch2
24698
+ );
24699
+ Object.assign(dispatch2, shippedDispatch);
24413
24700
  if (isJervHubRepo(ctx.repo)) steps2.push(dispatch2.note);
24414
24701
  const settledBySupersede = persisted?.phases.deploy.superseded === true || persisted?.phases.publish.superseded === true;
24415
24702
  const announceNote2 = persisted && !shippedBefore && !settledBySupersede && (prodReleaseTargets(deployModel).length > 0 || companion !== null) ? await announceShippedRelease(
@@ -24543,7 +24830,11 @@ ${recovery.note}`), recoveryInput);
24543
24830
  landed: "the immutable tag, the re-proven wall, origin/main, the verified GitHub Release, and the dispatched deploy path"
24544
24831
  });
24545
24832
  }
24546
- const dispatch = appendPublishDispatch(deployDispatch, publishDispatch);
24833
+ const dispatch = await appendServedChannelLegs(
24834
+ deps,
24835
+ { repo: ctx.repo, tag, releaseSha: tagSha, watch, deploy: deployLeg, publish: publishLeg, installer: meta.installer },
24836
+ appendPublishDispatch(deployDispatch, publishDispatch)
24837
+ );
24547
24838
  const announcing = announceShippedRelease(
24548
24839
  deps,
24549
24840
  { repo: ctx.repo, tag, summaryFile: options.announceSummaryFile },
@@ -24600,6 +24891,9 @@ async function withCheckoutRestoredAfterReleaseResume(deps, startBranch, operati
24600
24891
  return withCheckoutRestoredAfterRelease(deps, result, startBranch);
24601
24892
  }
24602
24893
  async function runReleaseResume(deps, options = {}) {
24894
+ return withTrainWriter(deps, "release --resume", () => runReleaseResumeLocked(deps, options));
24895
+ }
24896
+ async function runReleaseResumeLocked(deps, options) {
24603
24897
  const ctx = await buildTrainApplyContext(deps);
24604
24898
  await requireCleanTree(deps);
24605
24899
  await runGitRemoteRead(deps, ["fetch", "origin", "--tags"]);
@@ -24615,6 +24909,10 @@ async function runReleaseResume(deps, options = {}) {
24615
24909
  );
24616
24910
  }
24617
24911
  async function runReleaseAbort(deps, options = {}) {
24912
+ if (!options.approved) return runReleaseAbortLocked(deps, options);
24913
+ return withTrainWriter(deps, "release --abort", () => runReleaseAbortLocked(deps, options));
24914
+ }
24915
+ async function runReleaseAbortLocked(deps, options) {
24618
24916
  if (!options.approved) {
24619
24917
  throw new Error("release --abort requires --apply after explicit approval; nothing was written");
24620
24918
  }
@@ -24689,7 +24987,7 @@ async function runReleaseSupersedeDeploy(deps, options = {}) {
24689
24987
  throw new Error("release --supersede-deploy requires --apply after explicit master approval; nothing was written");
24690
24988
  }
24691
24989
  try {
24692
- return await supersedeContentCausedDeployLeg(deps, process.cwd());
24990
+ return await withTrainWriter(deps, "release --supersede-deploy", () => supersedeContentCausedDeployLeg(deps, process.cwd()));
24693
24991
  } catch (e) {
24694
24992
  const message2 = e instanceof Error ? e.message : String(e);
24695
24993
  throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
@@ -24700,7 +24998,7 @@ async function runReleaseSupersedePublish(deps, options = {}) {
24700
24998
  throw new Error("release --supersede-publish requires --apply after explicit master approval; nothing was written");
24701
24999
  }
24702
25000
  try {
24703
- return await supersedeContentCausedPublishLeg(deps, process.cwd());
25001
+ return await withTrainWriter(deps, "release --supersede-publish", () => supersedeContentCausedPublishLeg(deps, process.cwd()));
24704
25002
  } catch (e) {
24705
25003
  const message2 = e instanceof Error ? e.message : String(e);
24706
25004
  throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
@@ -24711,13 +25009,17 @@ async function runReleaseSupersedeDeployAndPublish(deps, options = {}) {
24711
25009
  throw new Error("release --supersede-deploy --supersede-publish requires --apply after explicit master approval; nothing was written");
24712
25010
  }
24713
25011
  try {
24714
- return await supersedeContentCausedDeployAndPublishLegs(deps, process.cwd());
25012
+ return await withTrainWriter(deps, "release --supersede-deploy --supersede-publish", () => supersedeContentCausedDeployAndPublishLegs(deps, process.cwd()));
24715
25013
  } catch (e) {
24716
25014
  const message2 = e instanceof Error ? e.message : String(e);
24717
25015
  throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
24718
25016
  }
24719
25017
  }
24720
25018
  async function runReleasePublishRetry(deps, runId, options = {}) {
25019
+ if (!options.approved) return runReleasePublishRetryLocked(deps, runId, options);
25020
+ return withTrainWriter(deps, "release --retry-publish", () => runReleasePublishRetryLocked(deps, runId, options));
25021
+ }
25022
+ async function runReleasePublishRetryLocked(deps, runId, options) {
24721
25023
  if (!options.approved) {
24722
25024
  throw new Error("release --retry-publish requires --apply after explicit approval; nothing was written");
24723
25025
  }
@@ -25162,6 +25464,9 @@ async function resolveHotfixVersion(deps, env = process.env) {
25162
25464
  return { tag: `v${version}`, version, baseTag };
25163
25465
  }
25164
25466
  async function runTrainApply(command, deps, options = {}) {
25467
+ return withTrainWriter(deps, `${command} --apply`, () => runTrainApplyLocked(command, deps, options));
25468
+ }
25469
+ async function runTrainApplyLocked(command, deps, options = {}) {
25165
25470
  const watch = options.watch ?? false;
25166
25471
  await runTrainDoctor({ lane: command === "rcand" ? "rcand" : "release", dev: command === "release" && options.dev === true, heal: true, train: deps, refuse: true });
25167
25472
  const ctx = await buildTrainApplyContext(deps);
@@ -30588,7 +30893,7 @@ function registerDeployCommands(program3) {
30588
30893
  init_cli_shared();
30589
30894
 
30590
30895
  // 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"];
30896
+ var UNSET_KEYS = ["installer", "oauth", "requiredRuntimeSecrets", "requiredBuildSecrets", "tenantTasks", "secrets", "edgeDomains", "requiredGcpApis", "publishRequired", "publishDir", "releaseChannel", "releaseLanguage", "dsManifestPath", "consumesDesignSystem", "ci", "requiredChecks", "requiredCheckBranches", "ciExemptReason", "gate", "seedCanary"];
30592
30897
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
30593
30898
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
30594
30899
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
@@ -30846,6 +31151,17 @@ function parseRuntimeVaultOnlyVar(raw) {
30846
31151
  if (raw === "false") return false;
30847
31152
  throw new Error("org project set: runtimeVaultOnly must be true or false");
30848
31153
  }
31154
+ function parseInstallerVar(raw) {
31155
+ let value;
31156
+ try {
31157
+ value = JSON.parse(raw);
31158
+ } catch {
31159
+ throw new Error('org project set: installer must be JSON, e.g. {"product":"mm-strategy","family":"mmi"}');
31160
+ }
31161
+ const error = installerDeclarationError(value, "mmi");
31162
+ if (error && !error.includes("breaks the sign-in rule")) throw new Error(`org project set: ${error}`);
31163
+ return value;
31164
+ }
30849
31165
  function parseSeedCanaryVar(raw) {
30850
31166
  if (raw === "true") return true;
30851
31167
  if (raw === "false") return false;
@@ -31016,7 +31332,8 @@ var SETTABLE_VAR_KEYS = [
31016
31332
  "ciExemptReason",
31017
31333
  "gate",
31018
31334
  "secrets",
31019
- "seedCanary"
31335
+ "seedCanary",
31336
+ "installer"
31020
31337
  ];
31021
31338
  var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
31022
31339
  var SETTABLE_VAR_HINTS = {
@@ -31029,6 +31346,7 @@ var SETTABLE_VAR_HINTS = {
31029
31346
  consumesDesignSystem: '"fofu"',
31030
31347
  runtimeVaultOnly: "true|false",
31031
31348
  seedCanary: "true|false",
31349
+ installer: "JSON {product,family:jerv|mmi,login?:github|google} \u2014 the repo ships a one-line installer on get.jerv.ai or get.mutmut.work",
31032
31350
  repos: 'JSON array, e.g. ["mutmutco/mm-foo"]',
31033
31351
  oauth: "JSON {subdomains,domains,callbackPath,extraCallbackPaths}",
31034
31352
  requiredGcpApis: "comma-string",
@@ -31164,6 +31482,8 @@ function buildProjectSetPatch(input) {
31164
31482
  patch[key] = parseRuntimeVaultOnlyVar(raw);
31165
31483
  } else if (key === "seedCanary") {
31166
31484
  patch[key] = parseSeedCanaryVar(raw);
31485
+ } else if (key === "installer") {
31486
+ patch[key] = parseInstallerVar(raw);
31167
31487
  } else if (key === "consumesDesignSystem") {
31168
31488
  patch[key] = parseConsumesDesignSystemVar(raw);
31169
31489
  } else if (key === "publishDir") {
@@ -44403,6 +44723,9 @@ async function hotfixReleaseExists(deps, ctx, tag) {
44403
44723
  }
44404
44724
  }
44405
44725
  async function runHotfixStart(deps, options, doctor = runTrainDoctor) {
44726
+ return withTrainWriter(deps, "hotfix start", () => runHotfixStartLocked(deps, options, doctor));
44727
+ }
44728
+ async function runHotfixStartLocked(deps, options, doctor) {
44406
44729
  await doctor({ lane: "hotfix", heal: true, train: deps, refuse: true });
44407
44730
  const ctx = await buildTrainApplyContext(deps);
44408
44731
  const status = await deps.run("git", ["status", "--porcelain"]);
@@ -44700,6 +45023,24 @@ async function appendHotfixGatewayRun(deps, repo, tag, sha, runs, note) {
44700
45023
  }
44701
45024
  return gateway.note;
44702
45025
  }
45026
+ async function appendHotfixServedChannel(deps, input, runs, note) {
45027
+ const before = hotfixDispatchFromRuns(runs, note);
45028
+ const after = await appendServedChannelLegs(
45029
+ deps,
45030
+ { repo: input.repo, tag: input.tag, releaseSha: input.sha, watch: true, deploy: input.deploy, publish: input.publish, installer: input.installer },
45031
+ before
45032
+ );
45033
+ for (const row of servedChannelRowsAfter(before, after)) {
45034
+ runs.push({
45035
+ workflow: row.workflow,
45036
+ ...row.runId != null ? { runId: row.runId } : {},
45037
+ ...row.runUrl ? { url: row.runUrl } : {},
45038
+ ...row.runUrlNote ? { runUrlNote: row.runUrlNote } : {},
45039
+ conclusion: row.conclusion
45040
+ });
45041
+ }
45042
+ return after.note;
45043
+ }
44703
45044
  function hotfixPhaseInputsFromRuns(deployModel, runs, note, publishDispatch, publishRequired, opts) {
44704
45045
  const dispatch = hotfixDispatchFromRuns(runs, note);
44705
45046
  return {
@@ -44822,6 +45163,9 @@ async function assertConsumerBomLineage(deps, mergedSha, tag) {
44822
45163
  }
44823
45164
  }
44824
45165
  async function runHotfixRelease(deps, versionInput, options = {}, doctor = runTrainDoctor) {
45166
+ return withTrainWriter(deps, `hotfix release ${versionInput}`, () => runHotfixReleaseLocked(deps, versionInput, options, doctor));
45167
+ }
45168
+ async function runHotfixReleaseLocked(deps, versionInput, options, doctor) {
44825
45169
  await doctor({ lane: "hotfix", heal: true, train: deps, refuse: true });
44826
45170
  const ctx = await buildTrainApplyContext(deps);
44827
45171
  const { tag, version } = normalizeHotfixVersion(versionInput);
@@ -44968,6 +45312,7 @@ ${decision.note}`);
44968
45312
  );
44969
45313
  await recordHotfixPhase(ledger, anchors, "deploy", ledgerInputs.deploy, "the immutable tag, the green wall, origin/main, and the GitHub Release");
44970
45314
  await recordHotfixPhase(ledger, anchors, "publish", ledgerInputs.publish, "the immutable tag, the green wall, origin/main, the GitHub Release, and the deploy path");
45315
+ deployNote = await appendHotfixServedChannel(deps, { repo: ctx.repo, tag, sha: mergedSha, deploy: ledgerInputs.deploy, publish: ledgerInputs.publish, installer: meta.installer }, runs, deployNote);
44971
45316
  const announceNote = releaseExists ? void 0 : await announceShippedRelease(
44972
45317
  deps,
44973
45318
  { repo: ctx.repo, tag, summaryFile: options.announceSummaryFile },
@@ -45036,6 +45381,9 @@ ${decision.note}`);
45036
45381
  return { ...result, ...localSync ? { localSync } : {} };
45037
45382
  }
45038
45383
  async function runHotfixRetryDeploy(deps, versionInput) {
45384
+ return withTrainWriter(deps, `hotfix release ${versionInput} --retry-deploy`, () => runHotfixRetryDeployLocked(deps, versionInput));
45385
+ }
45386
+ async function runHotfixRetryDeployLocked(deps, versionInput) {
45039
45387
  const ctx = await buildTrainApplyContext(deps);
45040
45388
  const { tag } = normalizeHotfixVersion(versionInput);
45041
45389
  const status = await deps.run("git", ["status", "--porcelain"]);
@@ -45651,6 +45999,8 @@ async function announceSummaryRefusal(path2) {
45651
45999
  }
45652
46000
  function trainApplyDeps() {
45653
46001
  return {
46002
+ // #7027: every mutating train verb holds this checkout's train writer lock for its whole run.
46003
+ trainWriter: (verb, cwd) => acquireTrainWriterLock(cwd, `mmi-cli devops ${verb}`),
45654
46004
  run: async (file, args) => {
45655
46005
  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
46006
  try {
@@ -47866,10 +48216,9 @@ function resolveHouseShim(argv) {
47866
48216
  const flatPath = flatTokens.join(" ");
47867
48217
  const flatHouse = houseForPath(flatPath);
47868
48218
  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
- );
48219
+ process.stderr.write(`mmi-cli: '${flatPath}' is the old flat form \u2014 ran it as \`mmi-cli ${canonicalPathFor(registeredPathPrefix(flatPath))}\` (use that spelling)
48220
+ `);
48221
+ return;
47873
48222
  }
47874
48223
  const remainder = argv.slice(3);
47875
48224
  const first = remainder[0];
@@ -47891,6 +48240,14 @@ function resolveHouseShim(argv) {
47891
48240
  if (actual && isDeclaredHouseAlias(houseToken, lookupPath)) {
47892
48241
  process.stderr.write(
47893
48242
  `mmi-cli: '${lookupPath}' lives in house '${actual}', not '${houseToken}' \u2014 routed to \`mmi-cli ${canonicalPathFor(lookupPath)}\`
48243
+ `
48244
+ );
48245
+ argv.splice(2, 1);
48246
+ return;
48247
+ }
48248
+ if (actual && actual !== "core" && registeredPathPrefix(lookupPath) === lookupPath) {
48249
+ process.stderr.write(
48250
+ `mmi-cli: '${lookupPath}' lives in house '${actual}', not '${houseToken}' \u2014 ran it as \`mmi-cli ${canonicalPathFor(lookupPath)}\`
47894
48251
  `
47895
48252
  );
47896
48253
  argv.splice(2, 1);
@@ -47919,8 +48276,8 @@ function printHouseRootHelp(house) {
47919
48276
  }
47920
48277
  lines2.push(
47921
48278
  "",
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.`
48279
+ `Canonical: \`mmi-cli ${house} <command> \u2026\`. The old flat \`mmi-cli <command> \u2026\` form still runs`,
48280
+ "with a notice naming the canonical spelling (#7034), so an out-of-date plugin keeps working."
47924
48281
  );
47925
48282
  consoleIo.log(lines2.join("\n"));
47926
48283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.5.0",
3
+ "version": "4.5.1",
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",