@mutmutco/cli 4.4.0 → 4.4.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 +287 -57
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -15870,10 +15870,10 @@ var rollout_plan_default = {
15870
15870
  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)."
15871
15871
  },
15872
15872
  baseline: {
15873
- version: "4.4.0",
15874
- tag: "v4.4.0",
15875
- commit: "8e1fae5ff484",
15876
- npm: "@mutmutco/cli@4.4.0"
15873
+ version: "4.4.2",
15874
+ tag: "v4.4.2",
15875
+ commit: "678e071c6859",
15876
+ npm: "@mutmutco/cli@4.4.2"
15877
15877
  },
15878
15878
  exitCriterion: "fleet-n-of-n",
15879
15879
  hubOnlyShortcut: "forbidden",
@@ -15890,14 +15890,14 @@ var rollout_plan_default = {
15890
15890
  repo: "mutmutco/mmi-hub",
15891
15891
  role: "canary",
15892
15892
  schedule: "train",
15893
- v3Target: "v4.4.0"
15893
+ v3Target: "v4.4.2"
15894
15894
  }
15895
15895
  ],
15896
15896
  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.",
15897
15897
  rollback: {
15898
15898
  independent: true,
15899
- mechanism: "npm dist-tag latest -> 4.4.0 and redeploy the Hub Lambda from tag v4.4.0 (8e1fae5ff484); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15900
- v3Target: "v4.4.0 (@mutmutco/cli@4.4.0, tag commit 8e1fae5ff484 \u2014 last known-good release carrying the repo-index v4-only contract)"
15899
+ mechanism: "npm dist-tag latest -> 4.4.2 and redeploy the Hub Lambda from tag v4.4.2 (678e071c6859); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15900
+ v3Target: "v4.4.2 (@mutmutco/cli@4.4.2, tag commit 678e071c6859 \u2014 last known-good release carrying the repo-index v4-only contract)"
15901
15901
  }
15902
15902
  },
15903
15903
  {
@@ -21628,6 +21628,51 @@ async function supersedeContentCausedDeployLeg(deps, cwd) {
21628
21628
  function supersedeablePublishLegShape(rec) {
21629
21629
  return rec.state === "failed" || rec.state === "pending" && rec.runId == null;
21630
21630
  }
21631
+ function deferredGatewayDeployLegShape(rec) {
21632
+ return rec.state === "pending" && rec.runId == null && rec.workflow === "jerv-gateway";
21633
+ }
21634
+ async function proveOriginPromotionOrArchived(deps, prior, path2, label, ref) {
21635
+ let liveSha = "";
21636
+ try {
21637
+ liveSha = (await deps.run("git", ["ls-remote", "origin", `refs/tags/${prior.tag}`])).trim().split(/\s+/)[0] ?? "";
21638
+ } catch (e) {
21639
+ throw new Error(`${label}: could not prove ${prior.tag} live on origin (${e instanceof Error ? e.message : String(e)}) \u2014 refusing to close the leg on an unproven state; retry when origin answers (${ref})`);
21640
+ }
21641
+ if (liveSha.toLowerCase() === prior.tagSha.toLowerCase()) return "live";
21642
+ if (liveSha) {
21643
+ throw new Error(
21644
+ `${label}: origin reports ${prior.tag} at ${liveSha.slice(0, 12)} but the ledger anchors ${prior.tagSha.slice(0, 12)} \u2014 a divergent release is NOT supersedeable; nothing was written (${ref})`
21645
+ );
21646
+ }
21647
+ let repository;
21648
+ try {
21649
+ repository = JSON.parse(await deps.run("gh", ["repo", "view", prior.repo, "--json", "nameWithOwner"]));
21650
+ } catch (e) {
21651
+ throw new Error(`${label}: could not prove repository access for ${prior.repo} (${e instanceof Error ? e.message.split("\n")[0] : String(e)}) \u2014 a Release 404 is untrusted without that proof; nothing was written (${ref})`);
21652
+ }
21653
+ if (repository.nameWithOwner !== prior.repo) {
21654
+ throw new Error(`${label}: repository access proof named ${String(repository.nameWithOwner || "(missing)")}, not ${prior.repo} \u2014 nothing was written (${ref})`);
21655
+ }
21656
+ let releaseAbsent = false;
21657
+ try {
21658
+ await deps.run("gh", ["release", "view", prior.tag, "--repo", prior.repo, "--json", "tagName"]);
21659
+ } catch (e) {
21660
+ const detail = `${e instanceof Error ? e.message : String(e)} ${String(e.stderr ?? "")}`;
21661
+ releaseAbsent = /release not found|HTTP 404|\(404\)/i.test(detail);
21662
+ if (!releaseAbsent) {
21663
+ throw new Error(`${label}: could not prove the GitHub Release for ${prior.tag} absent (${detail.split("\n")[0]}) \u2014 refusing to close on an unproven state; nothing was written (${ref})`);
21664
+ }
21665
+ }
21666
+ if (!releaseAbsent) {
21667
+ throw new Error(`${label}: origin no longer has ${prior.tag} but its GitHub Release still exists \u2014 resolve the mixed external state before closing the ledger; nothing was written (${ref})`);
21668
+ }
21669
+ const archivePath = (0, import_node_path21.join)((0, import_node_path21.dirname)(path2), `phases.archive-${prior.tag}.json`);
21670
+ if ((0, import_node_fs21.existsSync)(archivePath)) {
21671
+ throw new Error(`${label}: archive already exists at ${archivePath} \u2014 preserving both ledger records; resolve the collision by hand; nothing was written (${ref})`);
21672
+ }
21673
+ (0, import_node_fs21.renameSync)(path2, archivePath);
21674
+ return "deleted";
21675
+ }
21631
21676
  async function supersedeContentCausedPublishLeg(deps, cwd) {
21632
21677
  const path2 = releaseLedgerPath(cwd);
21633
21678
  return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
@@ -21653,51 +21698,15 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
21653
21698
  const closed = await alignmentLegClosedLive(deps, prior);
21654
21699
  if (closed) phases.alignment = closed;
21655
21700
  }
21656
- const unresolved = ["promotion", "ordinaryCi", "githubRelease", "deploy", "alignment"].filter((phase) => phases[phase].state === "pending" || phases[phase].state === "failed");
21701
+ let gatewayDeploy;
21702
+ const deferredGateway = isJervHubRepo(prior.repo) && deferredGatewayDeployLegShape(phases.deploy);
21703
+ const unresolved = ["promotion", "ordinaryCi", "githubRelease", "deploy", "alignment"].filter((phase) => !(phase === "deploy" && deferredGateway)).filter((phase) => phases[phase].state === "pending" || phases[phase].state === "failed");
21657
21704
  if (unresolved.length > 0) {
21658
21705
  throw new Error(
21659
21706
  `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)`
21660
21707
  );
21661
21708
  }
21662
- let liveSha = "";
21663
- try {
21664
- liveSha = (await deps.run("git", ["ls-remote", "origin", `refs/tags/${prior.tag}`])).trim().split(/\s+/)[0] ?? "";
21665
- } catch (e) {
21666
- throw new Error(`release --supersede-publish: could not prove ${prior.tag} live on origin (${e instanceof Error ? e.message : String(e)}) \u2014 refusing to close the leg on an unproven state; retry when origin answers (#6638)`);
21667
- }
21668
- if (liveSha.toLowerCase() !== prior.tagSha.toLowerCase()) {
21669
- if (liveSha) {
21670
- throw new Error(
21671
- `release --supersede-publish: origin reports ${prior.tag} at ${liveSha.slice(0, 12)} but the ledger anchors ${prior.tagSha.slice(0, 12)} \u2014 a divergent release is NOT supersedeable; nothing was written (#6638)`
21672
- );
21673
- }
21674
- let repository;
21675
- try {
21676
- repository = JSON.parse(await deps.run("gh", ["repo", "view", prior.repo, "--json", "nameWithOwner"]));
21677
- } catch (e) {
21678
- throw new Error(`release --supersede-publish: could not prove repository access for ${prior.repo} (${e instanceof Error ? e.message.split("\n")[0] : String(e)}) \u2014 a Release 404 is untrusted without that proof; nothing was written (#6638)`);
21679
- }
21680
- if (repository.nameWithOwner !== prior.repo) {
21681
- throw new Error(`release --supersede-publish: repository access proof named ${String(repository.nameWithOwner || "(missing)")}, not ${prior.repo} \u2014 nothing was written (#6638)`);
21682
- }
21683
- let releaseAbsent = false;
21684
- try {
21685
- await deps.run("gh", ["release", "view", prior.tag, "--repo", prior.repo, "--json", "tagName"]);
21686
- } catch (e) {
21687
- const detail = `${e instanceof Error ? e.message : String(e)} ${String(e.stderr ?? "")}`;
21688
- releaseAbsent = /release not found|HTTP 404|\(404\)/i.test(detail);
21689
- if (!releaseAbsent) {
21690
- throw new Error(`release --supersede-publish: could not prove the GitHub Release for ${prior.tag} absent (${detail.split("\n")[0]}) \u2014 refusing to close on an unproven state; nothing was written (#6638)`);
21691
- }
21692
- }
21693
- if (!releaseAbsent) {
21694
- throw new Error(`release --supersede-publish: origin no longer has ${prior.tag} but its GitHub Release still exists \u2014 resolve the mixed external state before closing the ledger; nothing was written (#6638)`);
21695
- }
21696
- const archivePath = (0, import_node_path21.join)((0, import_node_path21.dirname)(path2), `phases.archive-${prior.tag}.json`);
21697
- if ((0, import_node_fs21.existsSync)(archivePath)) {
21698
- throw new Error(`release --supersede-publish: archive already exists at ${archivePath} \u2014 preserving both ledger records; resolve the collision by hand; nothing was written (#6638)`);
21699
- }
21700
- (0, import_node_fs21.renameSync)(path2, archivePath);
21709
+ if (await proveOriginPromotionOrArchived(deps, prior, path2, "release --supersede-publish", "#6638") === "deleted") {
21701
21710
  return {
21702
21711
  command: "release-supersede-publish",
21703
21712
  repo: prior.repo,
@@ -21706,6 +21715,25 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
21706
21715
  note: `closed the deleted ${prior.tag} publish failure by archiving its ledger after origin and GitHub both proved absent; start a fresh release train to carry the cause fix`
21707
21716
  };
21708
21717
  }
21718
+ if (deferredGateway) {
21719
+ const dispatch = await appendJervGatewayReleaseDeploy(
21720
+ deps,
21721
+ prior.repo,
21722
+ prior.tag,
21723
+ prior.tagSha,
21724
+ { note: `Jerv Gateway operator-host deploy leg for ${prior.tag} run during the publish supersession`, deployStatus: "success" }
21725
+ );
21726
+ phases.deploy = {
21727
+ ...phaseEntry(deployPhaseInput("registry-publish", dispatch, { releaseSha: prior.tagSha })),
21728
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
21729
+ };
21730
+ if (dispatch.deployStatus !== "success") {
21731
+ throw new Error(
21732
+ `release --supersede-publish: the deferred ${prior.tag} Jerv Gateway operator-host deploy leg did not verify (${dispatch.note}) \u2014 run \`scripts/jerv-gateway-release-deploy.sh --tag ${prior.tag}\` to a healthy receipt naming the released tag and commit, then retry; nothing was written (#6742)`
21733
+ );
21734
+ }
21735
+ gatewayDeploy = { workflow: "jerv-gateway", state: "complete", note: dispatch.note };
21736
+ }
21709
21737
  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)`;
21710
21738
  atomicWriteJson(path2, {
21711
21739
  ...prior,
@@ -21720,7 +21748,117 @@ async function supersedeContentCausedPublishLeg(deps, cwd) {
21720
21748
  repo: prior.repo,
21721
21749
  tag: prior.tag,
21722
21750
  tagSha: prior.tagSha,
21723
- note: `closed the ${prior.tag} publish leg as superseded (skipped) \u2014 the ledger is phases-green; rerun the train doctor to confirm, then run the next patch train (hotfix or release) to carry the cause fix`
21751
+ note: `closed the ${prior.tag} publish leg as superseded (skipped) \u2014 the ledger is phases-green; rerun the train doctor to confirm, then run the next patch train (hotfix or release) to carry the cause fix`,
21752
+ ...gatewayDeploy ? { gatewayDeploy } : {}
21753
+ };
21754
+ });
21755
+ }
21756
+ async function supersedeContentCausedDeployAndPublishLegs(deps, cwd) {
21757
+ const label = "release --supersede-deploy --supersede-publish";
21758
+ const ref = "#6752";
21759
+ const path2 = releaseLedgerPath(cwd);
21760
+ return withFileLock(`${path2}.lock`, { label: "release ledger" }, async () => {
21761
+ let prior;
21762
+ try {
21763
+ prior = parseReleaseLedger((0, import_node_fs21.readFileSync)(path2, "utf8"));
21764
+ } catch (e) {
21765
+ throw new ReleaseLedgerError(
21766
+ "unreadable",
21767
+ `${label}: the active release ledger at ${path2} could not be read (${e instanceof Error ? e.message : String(e)}) \u2014 resolve it by hand before any train; nothing was written (${ref})`
21768
+ );
21769
+ }
21770
+ const deploy = prior.phases.deploy;
21771
+ const publish = prior.phases.publish;
21772
+ const unresolvedLeg = (rec) => rec.superseded !== true && (rec.state === "pending" || rec.state === "failed");
21773
+ const deployUnresolved = unresolvedLeg(deploy);
21774
+ const publishUnresolved = unresolvedLeg(publish);
21775
+ if (!deployUnresolved && !publishUnresolved) {
21776
+ throw new Error(
21777
+ deploy.superseded === true && publish.superseded === true ? `${label}: the ${prior.tag} deploy and publish legs are already superseded \u2014 nothing to do; the next train runs normally` : `${label}: the ${prior.tag} deploy and publish legs are both already resolved (deploy=${deploy.state}, publish=${publish.state}) \u2014 nothing to close; nothing was written (${ref})`
21778
+ );
21779
+ }
21780
+ if (!deployUnresolved) {
21781
+ throw new Error(`${label}: only the ${prior.tag} publish leg is unresolved (deploy=${deploy.state}) \u2014 use --supersede-publish for a single content-caused leg; nothing was written (${ref})`);
21782
+ }
21783
+ if (!publishUnresolved) {
21784
+ throw new Error(`${label}: only the ${prior.tag} deploy leg is unresolved (publish=${publish.state}) \u2014 use --supersede-deploy for a single content-caused leg; nothing was written (${ref})`);
21785
+ }
21786
+ if (!supersedeablePublishLegShape(publish)) {
21787
+ throw new Error(
21788
+ `${label}: the ${prior.tag} publish leg is ${publish.state}` + (publish.runId != null ? ` with run ${publish.runId} \u2014 a run-backed leg is resolved by watching/reverifying its run, never superseded` : "") + `; the combined closure closes only a FAILED publish leg, or a PENDING one no run correlates (the uncorrelatable historical shape); nothing was written (${ref})`
21789
+ );
21790
+ }
21791
+ const deferredGateway = isJervHubRepo(prior.repo) && deferredGatewayDeployLegShape(deploy);
21792
+ if (!(deploy.state === "failed" && deploy.runId != null || deferredGateway)) {
21793
+ throw new Error(
21794
+ `${label}: the ${prior.tag} deploy leg is ${deploy.state}` + (deploy.runId != null ? ` with run ${deploy.runId} \u2014 a run-backed leg is resolved by watching/reverifying its run, never superseded` : "") + `; the combined closure closes only a content-caused FAILED deploy leg with its run id, or the deferred Jerv-Hub operator-host Gateway leg (#6742); nothing was written (${ref})`
21795
+ );
21796
+ }
21797
+ const phases = { ...prior.phases };
21798
+ if (phases.alignment.state === "pending") {
21799
+ const closed = await alignmentLegClosedLive(deps, prior);
21800
+ if (closed) phases.alignment = closed;
21801
+ }
21802
+ const unresolved = ["promotion", "ordinaryCi", "githubRelease", "alignment"].filter((phase) => phases[phase].state === "pending" || phases[phase].state === "failed");
21803
+ if (unresolved.length > 0) {
21804
+ throw new Error(
21805
+ `${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})`
21806
+ );
21807
+ }
21808
+ if (await proveOriginPromotionOrArchived(deps, prior, path2, label, ref) === "deleted") {
21809
+ return {
21810
+ command: "release-supersede-deploy-and-publish",
21811
+ repo: prior.repo,
21812
+ tag: prior.tag,
21813
+ tagSha: prior.tagSha,
21814
+ note: `closed the deleted ${prior.tag} deploy and publish failures by archiving its ledger after origin and GitHub both proved absent; start a fresh release train to carry the cause fix`
21815
+ };
21816
+ }
21817
+ let deployEntry;
21818
+ let gatewayDeploy;
21819
+ if (deferredGateway) {
21820
+ const dispatch = await appendJervGatewayReleaseDeploy(
21821
+ deps,
21822
+ prior.repo,
21823
+ prior.tag,
21824
+ prior.tagSha,
21825
+ { note: `Jerv Gateway operator-host deploy leg for ${prior.tag} run during the deploy-and-publish supersession`, deployStatus: "success" }
21826
+ );
21827
+ deployEntry = {
21828
+ ...phaseEntry(deployPhaseInput("registry-publish", dispatch, { releaseSha: prior.tagSha })),
21829
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
21830
+ };
21831
+ if (dispatch.deployStatus !== "success") {
21832
+ throw new Error(
21833
+ `${label}: the deferred ${prior.tag} Jerv Gateway operator-host deploy leg did not verify (${dispatch.note}) \u2014 run \`scripts/jerv-gateway-release-deploy.sh --tag ${prior.tag}\` to a healthy receipt naming the released tag and commit, then retry; nothing was written (#6742)`
21834
+ );
21835
+ }
21836
+ gatewayDeploy = { workflow: "jerv-gateway", state: "complete", note: dispatch.note };
21837
+ } else {
21838
+ deployEntry = {
21839
+ state: "skipped",
21840
+ sha: prior.tagSha,
21841
+ superseded: true,
21842
+ note: `superseded: content-caused deploy failure on the immutable promoted ${prior.tag} \u2014 the cause fix rides the next patch train (master-approved closure, ${ref})`,
21843
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
21844
+ };
21845
+ }
21846
+ phases.deploy = deployEntry;
21847
+ phases.publish = {
21848
+ state: "skipped",
21849
+ sha: prior.tagSha,
21850
+ superseded: true,
21851
+ 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})`,
21852
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
21853
+ };
21854
+ atomicWriteJson(path2, { ...prior, phases, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
21855
+ return {
21856
+ command: "release-supersede-deploy-and-publish",
21857
+ repo: prior.repo,
21858
+ tag: prior.tag,
21859
+ tagSha: prior.tagSha,
21860
+ note: `closed the ${prior.tag} deploy and publish legs as superseded (skipped) \u2014 the ledger is phases-green; rerun the train doctor to confirm, then run the next patch train (hotfix or release) to carry the cause fix`,
21861
+ ...gatewayDeploy ? { gatewayDeploy } : {}
21724
21862
  };
21725
21863
  });
21726
21864
  }
@@ -23810,6 +23948,17 @@ async function runReleaseSupersedePublish(deps, options = {}) {
23810
23948
  throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
23811
23949
  }
23812
23950
  }
23951
+ async function runReleaseSupersedeDeployAndPublish(deps, options = {}) {
23952
+ if (!options.approved) {
23953
+ throw new Error("release --supersede-deploy --supersede-publish requires --apply after explicit master approval; nothing was written");
23954
+ }
23955
+ try {
23956
+ return await supersedeContentCausedDeployAndPublishLegs(deps, process.cwd());
23957
+ } catch (e) {
23958
+ const message2 = e instanceof Error ? e.message : String(e);
23959
+ throw new Error(message2.includes("nothing was written") || message2.includes("nothing to do") ? message2 : `${message2}; nothing was written`);
23960
+ }
23961
+ }
23813
23962
  async function runReleasePublishRetry(deps, runId, options = {}) {
23814
23963
  if (!options.approved) {
23815
23964
  throw new Error("release --retry-publish requires --apply after explicit approval; nothing was written");
@@ -29674,6 +29823,7 @@ var UNSET_KEY_SET = new Set(UNSET_KEYS);
29674
29823
  var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
29675
29824
  var SECRET_CONSUMERS = ["runtime", "build", "lambda", "actions", "agent", "box"];
29676
29825
  var SECRET_ENV_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
29826
+ var SECRET_CATALOG_MAP_KEY_RE = /^[A-Za-z][A-Za-z0-9_]*(?:@(?:dev|rc|main))?$/;
29677
29827
  var BUILD_SECRET_REF_RE = /^(?:[A-Z_][A-Z0-9_]*=)?(?:[A-Z_][A-Z0-9_]*|(?:_org\/[a-z0-9][a-z0-9-]*):[A-Z_][A-Z0-9_]*|@github-packages-token)$/;
29678
29828
  function previewRegistryMetaMerge(existing, patch) {
29679
29829
  const out = { ...existing ?? {} };
@@ -30115,7 +30265,7 @@ var SETTABLE_VAR_HINTS = {
30115
30265
  requiredRuntimeSecrets: 'JSON stage map, e.g. {"dev":["KEY"],"rc":["KEY"],"main":["KEY"]}',
30116
30266
  requiredBuildSecrets: 'JSON flat array, e.g. ["NODE_AUTH_TOKEN=@github-packages-token"]',
30117
30267
  tenantTasks: 'JSON map {name:{service,command[],stages[],timeoutSeconds?,artifact?:"required"}}; use {artifact} in argv when required',
30118
- secrets: "JSON catalog map keyed by KEY {key,purpose,group,owner,stages[],consumers[]} \u2014 merged per entry; clear with --unset secrets; prefer --secrets-file",
30268
+ secrets: "JSON catalog map keyed by KEY {key,purpose,group,owner,stages[],consumers[]} \u2014 merged per entry; drop ONE entry with --unset-secret KEY; clear ALL with --unset secrets --confirm-clear-secrets; prefer --secrets-file",
30119
30269
  edgeDomains: "JSON {dev,rc,main} domain map",
30120
30270
  statusOptions: "JSON name\u2192id map",
30121
30271
  priorityOptions: "JSON {Urgent,High,Medium,Low}\u2192id map",
@@ -30142,6 +30292,40 @@ function duplicateVarKeyAcrossFlags(vars, sets) {
30142
30292
  }
30143
30293
  return null;
30144
30294
  }
30295
+ function secretRemovalRefusal(existing, patch) {
30296
+ const remove2 = Array.isArray(patch.secretsRemove) ? patch.secretsRemove : [];
30297
+ if (remove2.length === 0) return null;
30298
+ const declared = new Set(Object.keys(existing?.secrets ?? {}));
30299
+ const runtime = patch.requiredRuntimeSecrets ?? existing?.requiredRuntimeSecrets ?? {};
30300
+ const build = patch.requiredBuildSecrets ?? existing?.requiredBuildSecrets ?? [];
30301
+ for (const mapKey of remove2) {
30302
+ if (!declared.has(mapKey)) {
30303
+ return `org project set: --unset-secret ${mapKey} is not declared in ${existing?.slug ?? "this project"}'s secrets catalog \u2014 nothing to remove`;
30304
+ }
30305
+ const at = mapKey.indexOf("@");
30306
+ const canonical = at === -1 ? mapKey : mapKey.slice(0, at);
30307
+ for (const [stage, names] of Object.entries(runtime)) {
30308
+ if (Array.isArray(names) && names.includes(canonical)) {
30309
+ return `org project set: ${canonical} is still required by stage ${stage} \u2014 drop it there first (--var requiredRuntimeSecrets=...)`;
30310
+ }
30311
+ }
30312
+ for (const raw of build) {
30313
+ if (typeof raw !== "string") continue;
30314
+ const eq = raw.indexOf("=");
30315
+ const ref = eq === -1 ? raw : raw.slice(eq + 1);
30316
+ if (ref === canonical) {
30317
+ return `org project set: ${canonical} is still required by the build secret contract \u2014 drop it there first (--var requiredBuildSecrets=...)`;
30318
+ }
30319
+ }
30320
+ }
30321
+ return null;
30322
+ }
30323
+ function clearSecretsConfirmationError(unsets, existing, confirmed) {
30324
+ if (!unsets.includes("secrets")) return null;
30325
+ const count = Object.keys(existing?.secrets ?? {}).length;
30326
+ if (confirmed) return null;
30327
+ return `org project set: --unset secrets clears the WHOLE catalog for ${existing?.slug ?? "this project"} (${count} declared entr${count === 1 ? "y" : "ies"} would be removed). To drop ONE entry use --unset-secret <KEY>; re-run with --confirm-clear-secrets to wipe all ${count}.`;
30328
+ }
30145
30329
  function buildProjectSetPatch(input) {
30146
30330
  const patch = {};
30147
30331
  if (input.class) {
@@ -30241,12 +30425,26 @@ function buildProjectSetPatch(input) {
30241
30425
  }
30242
30426
  patch[key] = null;
30243
30427
  }
30428
+ if (input.unsetSecrets?.length) {
30429
+ if (patch.secrets === null) {
30430
+ throw new Error("org project set: --unset secrets clears the WHOLE catalog and cannot be combined with --unset-secret \u2014 pass one or the other");
30431
+ }
30432
+ const keys = [];
30433
+ for (const raw of input.unsetSecrets) {
30434
+ const key = raw.trim();
30435
+ if (!SECRET_CATALOG_MAP_KEY_RE.test(key)) {
30436
+ throw new Error(`org project set: --unset-secret must be a catalog KEY (UPPER_SNAKE, optionally KEY@dev|rc|main) \u2014 got ${JSON.stringify(raw)}`);
30437
+ }
30438
+ if (!keys.includes(key)) keys.push(key);
30439
+ }
30440
+ patch.secretsRemove = keys;
30441
+ }
30244
30442
  if (input.clearWebProfile) {
30245
30443
  patch.oauth = null;
30246
30444
  patch.edgeDomains = null;
30247
30445
  }
30248
30446
  if (Object.keys(patch).length === 0) {
30249
- throw new Error("org project set: nothing to set - pass --class, --project-type, --deploy-model, --release-track, --var KEY=VALUE, --unset KEY, and/or --clear-web-profile");
30447
+ throw new Error("org project set: nothing to set - pass --class, --project-type, --deploy-model, --release-track, --var KEY=VALUE, --unset KEY, --unset-secret KEY, and/or --clear-web-profile");
30250
30448
  }
30251
30449
  return patch;
30252
30450
  }
@@ -44613,7 +44811,12 @@ function renderReleaseSupersedeDeploy(r) {
44613
44811
  return `mmi-cli devops release --supersede-deploy --apply: ${r.repo} ${r.tag} (${r.tagSha.slice(0, 12)}) \u2014 ${r.note}`;
44614
44812
  }
44615
44813
  function renderReleaseSupersedePublish(r) {
44616
- return `mmi-cli devops release --supersede-publish --apply: ${r.repo} ${r.tag} (${r.tagSha.slice(0, 12)}) \u2014 ${r.note}`;
44814
+ const base = `mmi-cli devops release --supersede-publish --apply: ${r.repo} ${r.tag} (${r.tagSha.slice(0, 12)}) \u2014 ${r.note}`;
44815
+ return r.gatewayDeploy ? `${base}; the deferred Jerv Gateway operator-host deploy leg was run and verified during the supersession (jerv-gateway=complete): ${r.gatewayDeploy.note}` : base;
44816
+ }
44817
+ function renderReleaseSupersedeDeployAndPublish(r) {
44818
+ const base = `mmi-cli devops release --supersede-deploy --supersede-publish --apply: ${r.repo} ${r.tag} (${r.tagSha.slice(0, 12)}) \u2014 ${r.note}`;
44819
+ return r.gatewayDeploy ? `${base}; the deferred Jerv Gateway operator-host deploy leg was run and verified during the closure (jerv-gateway=complete): ${r.gatewayDeploy.note}` : base;
44617
44820
  }
44618
44821
  function renderReleasePublishRetry(r) {
44619
44822
  return `mmi-cli devops release --retry-publish: ${r.repo} ${r.tag} (${r.tagSha.slice(0, 12)}) \u2014 ${r.note}; ${r.runUrl}`;
@@ -44844,8 +45047,8 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44844
45047
  { flags: "--ack <shas>", description: "comma-separated dev shas a human verified are in the candidate, overriding the hotfix-coverage guard for a conflicted port whose -x trailer was lost (#958)" },
44845
45048
  { flags: "--dev", description: "full-track repos release development -> main directly, skipping rc (refuses if rc carries content not in development; no-op on direct-track repos) (#1062)" },
44846
45049
  { flags: "--abort", description: "with --apply, delete only a proven unpublished failed release candidate on any registered deploy model and restore local main for a same-version recut (#3944/#5650/#5657)" },
44847
- { flags: "--supersede-deploy", description: "with --apply, master-approved closure of a content-caused failed deploy leg on a PUBLISHED release whose immutable tag can never deploy green \u2014 the next patch train then carries the fix (#6553)" },
44848
- { flags: "--supersede-publish", description: "with --apply, master-approved closure of a content-caused failed publish leg on a fully PROMOTED release whose immutable tag can never publish green \u2014 the next patch train then carries the fix (#6638)" },
45050
+ { flags: "--supersede-deploy", description: "with --apply, master-approved closure of a content-caused failed deploy leg on a PUBLISHED release whose immutable tag can never deploy green \u2014 the next patch train then carries the fix (#6553); combined with --supersede-publish it closes BOTH content-caused legs in one approved closure (#6752)" },
45051
+ { flags: "--supersede-publish", description: "with --apply, master-approved closure of a content-caused failed publish leg on a fully PROMOTED release whose immutable tag can never publish green \u2014 a deferred Jerv-Hub operator-host Gateway deploy leg is run and verified as part of the closure \u2014 the next patch train then carries the fix (#6638/#6742); combined with --supersede-deploy it closes BOTH content-caused legs in one approved closure (#6752)" },
44849
45052
  { flags: "--retry-publish <run-id>", description: "with --apply, retry one proven failed or cancelled Hub publish release run (#3949/#5797)" }
44850
45053
  ];
44851
45054
  for (const f of RELEASE_ONLY_FLAGS) {
@@ -44885,6 +45088,25 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44885
45088
  if (o.out && !o.apply && !o.resume) {
44886
45089
  return fail(`${commandName}: --out writes a terminal result receipt (--apply, --resume, --abort, --retry-publish) \u2014 the dry-run plan keeps its stdout/JSON output. Drop --out or rerun with --apply`);
44887
45090
  }
45091
+ if (o.supersedeDeploy && o.supersedePublish) {
45092
+ if (o.resume) return fail("release: --supersede-deploy --supersede-publish and --resume are mutually exclusive \u2014 the closure settles the ledger so a NEW train can run, resume re-derives the very legs it closes");
45093
+ if (o.abort) return fail("release: --supersede-deploy --supersede-publish and --abort are mutually exclusive \u2014 the closure is for a PUBLISHED release, abort only ever deletes a proven unpublished candidate");
45094
+ if (o.retryPublish) return fail("release: --supersede-deploy --supersede-publish cannot be combined with --retry-publish \u2014 a content-caused failure on the immutable tag cannot be retried green");
45095
+ if (!o.apply) return fail("release: --supersede-deploy --supersede-publish requires --apply after explicit master approval; nothing was written");
45096
+ if (o.watch || o.announceSummaryFile || o.ack || o.dev) {
45097
+ return fail("release: --supersede-deploy --supersede-publish accepts only --apply, --repo, --json and --out; it closes ledger legs and dispatches nothing");
45098
+ }
45099
+ if (o.repo) {
45100
+ const guard = planTrainApplyRepoGuard(o.repo, await resolveRepo(), "mmi-cli devops release --supersede-deploy --supersede-publish --apply");
45101
+ if (!guard.ok) return fail(`release: ${guard.message}`);
45102
+ }
45103
+ try {
45104
+ const result = await runReleaseSupersedeDeployAndPublish(trainApplyDeps(), { approved: true });
45105
+ return emitTrainResult("release --supersede-deploy --supersede-publish", o.json ? JSON.stringify(result, null, 2) : renderReleaseSupersedeDeployAndPublish(result), o.out);
45106
+ } catch (e) {
45107
+ return failGraceful(`release --supersede-deploy --supersede-publish: ${e.message}`);
45108
+ }
45109
+ }
44888
45110
  if (o.retryPublish) {
44889
45111
  if (o.resume || o.abort) return fail("release: --retry-publish cannot be combined with --resume or --abort");
44890
45112
  if (!o.apply) return fail("release: --retry-publish requires --apply after explicit approval; nothing was written");
@@ -44926,9 +45148,6 @@ function registerTrainOperationsCommands(program3, runProjectInfoSyncCallback) {
44926
45148
  return failGraceful(`release --abort: ${e.message}`);
44927
45149
  }
44928
45150
  }
44929
- if (o.supersedeDeploy && o.supersedePublish) {
44930
- return fail("release: --supersede-deploy and --supersede-publish are mutually exclusive \u2014 close the one leg that failed content-caused; the other leg has its own resolution paths");
44931
- }
44932
45151
  if (o.supersedeDeploy) {
44933
45152
  if (o.resume) return fail("release: --supersede-deploy and --resume are mutually exclusive \u2014 the closure settles the ledger so a NEW train can run, resume re-derives the very leg it closes");
44934
45153
  if (o.abort) return fail("release: --supersede-deploy and --abort are mutually exclusive \u2014 the closure is for a PUBLISHED release, abort only ever deletes a proven unpublished candidate");
@@ -46026,7 +46245,7 @@ projectDeploy.command("doctor").description("read-only estate scan for duplicate
46026
46245
  if (report.error && report.status !== 403) return failGraceful(`org project deploy doctor: ${report.error}`);
46027
46246
  if (!report.ok && !report.error) process.exitCode = 1;
46028
46247
  });
46029
- project.command("set [owner/repo]").description("upsert project META (idempotent merge; master-gated)").addOption(new Option("--class <class>", "deployable | content").choices(["deployable", "content"])).addOption(new Option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (capability shape)`).choices([...PROJECT_TYPES])).addOption(new Option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).choices([...DEPLOY_MODELS])).addOption(new Option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).choices([...RELEASE_TRACKS])).option("--var <KEY=VALUE...>", settableVarHelp()).option("--set <KEY=VALUE...>", "alias of --var (one KEY=VALUE per flag; repeat the flag to set several)").option("--secrets-file <path>", 'read the #2244 secrets catalog map (JSON) from a file and merge it per entry into the existing catalog (clear all with --unset secrets). SHAPE: a JSON object keyed by env name, each entry {"key":"UPPER_SNAKE" (repeat the env name), "purpose":"<non-empty>", "group":"<e.g. auth|database>", "owner":"<github login>", "stages":[] (empty = the one stageless shared value; else any of dev/rc/main), "consumers":["runtime"|"box"|?], "provider":"<optional>"}').option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, requiredBuildSecrets, secrets, edgeDomains, requiredGcpApis, publishRequired, releaseChannel, releaseLanguage").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
46248
+ project.command("set [owner/repo]").description("upsert project META (idempotent merge; master-gated)").addOption(new Option("--class <class>", "deployable | content").choices(["deployable", "content"])).addOption(new Option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (capability shape)`).choices([...PROJECT_TYPES])).addOption(new Option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).choices([...DEPLOY_MODELS])).addOption(new Option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).choices([...RELEASE_TRACKS])).option("--var <KEY=VALUE...>", settableVarHelp()).option("--set <KEY=VALUE...>", "alias of --var (one KEY=VALUE per flag; repeat the flag to set several)").option("--secrets-file <path>", 'read the #2244 secrets catalog map (JSON) from a file and merge it per entry into the existing catalog (drop ONE entry with --unset-secret KEY; clear ALL with --unset secrets --confirm-clear-secrets). SHAPE: a JSON object keyed by env name, each entry {"key":"UPPER_SNAKE" (repeat the env name), "purpose":"<non-empty>", "group":"<e.g. auth|database>", "owner":"<github login>", "stages":[] (empty = the one stageless shared value; else any of dev/rc/main), "consumers":["runtime"|"box"|?], "provider":"<optional>"}').option("--unset-secret <KEY...>", "remove exactly these #2244 catalog entries (repeatable; KEY or KEY@dev|rc|main). Refuses while a stage still requires the key \u2014 drop it from requiredRuntimeSecrets/requiredBuildSecrets first").option("--confirm-clear-secrets", "required to let --unset secrets wipe the WHOLE catalog (the refusal names how many entries that removes)").option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, requiredBuildSecrets, secrets (the WHOLE catalog \u2014 needs --confirm-clear-secrets), edgeDomains, requiredGcpApis, publishRequired, releaseChannel, releaseLanguage").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
46030
46249
  const cfg = await loadConfig();
46031
46250
  let target;
46032
46251
  try {
@@ -46056,15 +46275,26 @@ project.command("set [owner/repo]").description("upsert project META (idempotent
46056
46275
  releaseTrack: o.releaseTrack,
46057
46276
  vars,
46058
46277
  unsets: o.unset ?? [],
46278
+ unsetSecrets: o.unsetSecret ?? [],
46059
46279
  clearWebProfile: Boolean(o.clearWebProfile)
46060
46280
  });
46061
46281
  } catch (e) {
46062
46282
  return fail(e.message.replace(/^org project set: /, "org project set: "));
46063
46283
  }
46064
46284
  const existing = await fetchProjectBySlug(slug, registryClientDeps(cfg));
46285
+ const clearRefusal = clearSecretsConfirmationError(o.unset ?? [], existing, Boolean(o.confirmClearSecrets));
46286
+ if (clearRefusal) return fail(clearRefusal);
46287
+ const removalRefusal = secretRemovalRefusal(existing, patch);
46288
+ if (removalRefusal) return fail(removalRefusal);
46065
46289
  const boardError = boardLinkWriteError(patch, existing);
46066
46290
  if (boardError) return fail(`org project set: ${boardError}`);
46067
46291
  const res = await upsertProject(slug, { ...patch, repo }, registryClientDeps(cfg));
46292
+ const removedKeys = Array.isArray(patch.secretsRemove) ? patch.secretsRemove : [];
46293
+ if (removedKeys.length && res.ok) {
46294
+ const body = res.body;
46295
+ const remaining = Object.keys(body?.project?.secrets ?? {}).sort();
46296
+ printLine(`org project set: removed ${removedKeys.join(", ")} \u2014 catalog now declares ${remaining.length}: ${remaining.join(", ") || "(none)"}`);
46297
+ }
46068
46298
  return reportWrite("org project set", res);
46069
46299
  });
46070
46300
  project.command("retire [owner/repo]").description("retire an orphaned registry slug (master-only): soft-delete the PROJECT# META row + drop the repo's board items; secrets/vault left alone. DRY-RUN by default \u2014 pass --apply to actually delete; defaults to the current repo").option("--apply", "actually delete (default is a dry-run preview of what would be removed)").option("--skip-board", "retire the registry META only \u2014 leave board items in place").option("--json", "machine-readable {slug, removedMeta, removedBoardItem} output").action(async (repoOrSlug, o) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.4.0",
3
+ "version": "4.4.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",