@mutmutco/cli 2.21.0 → 2.23.0

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 +223 -6
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -6172,6 +6172,7 @@ function derivedStagePlan(derived, shell2, stops = true) {
6172
6172
  function stageLivePlan() {
6173
6173
  return [
6174
6174
  { label: "stage-live is not an org command; /stage is local only", command: "mmi-cli stage run --apply" },
6175
+ { label: "personal IP-gated cloud dev stage (on-demand, #1060)", command: "mmi-cli stage --live --apply" },
6175
6176
  { label: "remote rc/live environments move through the gated promotion train", command: "mmi-cli rcand && mmi-cli release && mmi-cli hotfix", gated: true }
6176
6177
  ];
6177
6178
  }
@@ -6218,6 +6219,21 @@ function trainPlan(command, options = {}) {
6218
6219
  { label: "roll development forward", gated: true }
6219
6220
  ];
6220
6221
  }
6222
+ if (options.dev) {
6223
+ return [
6224
+ { label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
6225
+ { label: "verify current branch is development", gated: true },
6226
+ { label: "guard: refuse if origin/rc carries content not in development (a dev -> main release would drop it)", command: "git rev-list --count --right-only --cherry-pick --no-merges origin/development...origin/rc", gated: true },
6227
+ { label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
6228
+ { label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
6229
+ { label: "merge development to main (rc skipped)", gated: true },
6230
+ { label: "fold the version bump into the release commit \u2014 runs inside the apply step, no separate bump PR", gated: true },
6231
+ { label: "tag release and publish GitHub Release", gated: true },
6232
+ { label: "trigger the deploy path for this repo model, returning Hub Actions run id/url data (and, with --watch, its outcome)", command: "tenant-container: gh workflow run tenant-deploy.yml ... then gh run list/watch", gated: true },
6233
+ { label: "retire the rc runtime (rc is ephemeral \u2014 non-fatal, reported as rcRetirement)", command: "mmi-cli tenant control <owner/repo> rc retire", gated: true },
6234
+ { label: "roll development forward and align rc to the released main", gated: true }
6235
+ ];
6236
+ }
6221
6237
  return [
6222
6238
  { label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
6223
6239
  { label: "verify current branch is rc", gated: true },
@@ -6862,11 +6878,92 @@ function buildHubCompatCheck(input) {
6862
6878
  return { ok: versionAtLeast(input.installedVersion, min), label: `${label}: requires >= ${min}`, fix: HUB_COMPAT_FIX };
6863
6879
  }
6864
6880
 
6881
+ // src/stage-live.ts
6882
+ var import_node_net = require("node:net");
6883
+ var STAGE_LIVE_HUB_REPO = "mutmutco/MMI-Hub";
6884
+ var IP_ECHO_URL = "https://api.ipify.org";
6885
+ var IP_DETECT_TIMEOUT_MS = 1e4;
6886
+ function validStageLiveIp(ip) {
6887
+ return (0, import_node_net.isIP)(ip.trim()) !== 0;
6888
+ }
6889
+ async function detectPublicIp(fetchImpl = fetch) {
6890
+ let res;
6891
+ try {
6892
+ res = await fetchImpl(IP_ECHO_URL, { signal: AbortSignal.timeout(IP_DETECT_TIMEOUT_MS) });
6893
+ } catch (e) {
6894
+ throw new Error(`public IP detection failed (${IP_ECHO_URL}): ${e.message}`);
6895
+ }
6896
+ if (!res.ok) throw new Error(`public IP detection failed: HTTP ${res.status} from ${IP_ECHO_URL}`);
6897
+ const ip = (await res.text()).trim();
6898
+ if (!validStageLiveIp(ip)) throw new Error(`public IP detection returned a non-IP body from ${IP_ECHO_URL}: "${ip.slice(0, 80)}"`);
6899
+ return ip;
6900
+ }
6901
+ function ghDispatchArgs(workflow, inputs) {
6902
+ const args = ["workflow", "run", workflow, "--repo", STAGE_LIVE_HUB_REPO];
6903
+ for (const [key, value] of Object.entries(inputs)) args.push("-f", `${key}=${value}`);
6904
+ return args;
6905
+ }
6906
+ function stageLiveUpSteps(t) {
6907
+ return [
6908
+ { label: `detect your public IP (${IP_ECHO_URL}, bounded)` },
6909
+ {
6910
+ label: `deploy ${t.ref ?? "<current branch>"} to the ${t.slug} dev stage via the central deployer`,
6911
+ command: `gh ${ghDispatchArgs("tenant-deploy.yml", { slug: t.slug, repo: t.repo, ref: t.ref ?? "<branch>", stage: "dev" }).join(" ")}`
6912
+ },
6913
+ {
6914
+ label: "record your IP as the dev allowlist (box writes /opt/mmi/<slug>/dev/allowlist + reloads Caddy)",
6915
+ command: `gh ${ghDispatchArgs("tenant-control.yml", { slug: t.slug, stage: "dev", action: "allow-ip", ip: "<your ip>" }).join(" ")}`
6916
+ },
6917
+ { label: "tear down when done", command: "mmi-cli stage --live --down --apply" }
6918
+ ];
6919
+ }
6920
+ function stageLiveDownSteps(t) {
6921
+ return [
6922
+ {
6923
+ label: `stop the ${t.slug} dev runtime`,
6924
+ command: `gh ${ghDispatchArgs("tenant-control.yml", { slug: t.slug, stage: "dev", action: "stop" }).join(" ")}`
6925
+ },
6926
+ {
6927
+ label: "clear the dev allowlist (the stage goes dark even if restarted)",
6928
+ command: `gh ${ghDispatchArgs("tenant-control.yml", { slug: t.slug, stage: "dev", action: "allow-ip", ip: "clear" }).join(" ")}`
6929
+ }
6930
+ ];
6931
+ }
6932
+ async function runStageLiveUp(deps, t) {
6933
+ if (!t.ref?.trim()) throw new Error("stage --live: cannot resolve the current branch to deploy");
6934
+ const ip = (await deps.detectIp()).trim();
6935
+ if (!validStageLiveIp(ip)) throw new Error(`stage --live: detected public IP is not a literal IPv4/IPv6 address: "${ip.slice(0, 80)}"`);
6936
+ await deps.run("gh", ghDispatchArgs("tenant-deploy.yml", { slug: t.slug, repo: t.repo, ref: t.ref, stage: "dev" }));
6937
+ await deps.run("gh", ghDispatchArgs("tenant-control.yml", { slug: t.slug, stage: "dev", action: "allow-ip", ip }));
6938
+ return {
6939
+ command: "stage --live",
6940
+ mode: "up",
6941
+ slug: t.slug,
6942
+ repo: t.repo,
6943
+ ref: t.ref,
6944
+ ip,
6945
+ dispatched: ["tenant-deploy.yml", "tenant-control.yml"],
6946
+ message: `dispatched the dev deploy of ${t.ref} and the allowlist update for ${ip}; watch the runs in ${STAGE_LIVE_HUB_REPO} Actions \u2014 tear down with: mmi-cli stage --live --down --apply`
6947
+ };
6948
+ }
6949
+ async function runStageLiveDown(deps, t) {
6950
+ await deps.run("gh", ghDispatchArgs("tenant-control.yml", { slug: t.slug, stage: "dev", action: "stop" }));
6951
+ await deps.run("gh", ghDispatchArgs("tenant-control.yml", { slug: t.slug, stage: "dev", action: "allow-ip", ip: "clear" }));
6952
+ return {
6953
+ command: "stage --live",
6954
+ mode: "down",
6955
+ slug: t.slug,
6956
+ repo: t.repo,
6957
+ dispatched: ["tenant-control.yml", "tenant-control.yml"],
6958
+ message: `dispatched the dev stop and allowlist clear for ${t.slug}; the dev stage is dark until the next mmi-cli stage --live --apply`
6959
+ };
6960
+ }
6961
+
6865
6962
  // src/stage-runner.ts
6866
6963
  var import_node_child_process6 = require("node:child_process");
6867
6964
  var import_node_fs8 = require("node:fs");
6868
6965
  var import_node_path8 = require("node:path");
6869
- var import_node_net = require("node:net");
6966
+ var import_node_net2 = require("node:net");
6870
6967
  var import_node_util6 = require("node:util");
6871
6968
  var execFileP4 = (0, import_node_util6.promisify)(import_node_child_process6.execFile);
6872
6969
  function stageStatePath(cwd = process.cwd()) {
@@ -6916,7 +7013,7 @@ function pickStagePort(range, isFree) {
6916
7013
  }
6917
7014
  function isPortFree(port) {
6918
7015
  return new Promise((resolve) => {
6919
- const srv = (0, import_node_net.createServer)();
7016
+ const srv = (0, import_node_net2.createServer)();
6920
7017
  srv.once("error", () => resolve(false));
6921
7018
  srv.once("listening", () => srv.close(() => resolve(true)));
6922
7019
  srv.listen(port, "127.0.0.1");
@@ -7642,9 +7739,93 @@ async function runTrainApply(command, deps, options = {}) {
7642
7739
  rcRetirement: "not-applicable",
7643
7740
  rcRetirementNote: "direct-track release skips rc; no rc runtime to retire",
7644
7741
  announceNote: announceNote2,
7742
+ // #1062: --dev on a direct-track repo is a friendly no-op — it already releases from development.
7743
+ devNote: options.dev ? "--dev is a no-op on a direct-track repo \u2014 it already releases development -> main" : void 0,
7645
7744
  release: { tag: tag2, url: releaseUrl2, targetSha: releaseSha2 }
7646
7745
  };
7647
7746
  }
7747
+ if (command === "release" && options.dev) {
7748
+ await requireBranch(deps, "development");
7749
+ await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
7750
+ const rcOnlyOut = (await deps.run("git", ["rev-list", "--count", "--right-only", "--cherry-pick", "--no-merges", "origin/development...origin/rc"])).trim();
7751
+ const rcOnly = Number.parseInt(rcOnlyOut, 10);
7752
+ if (!Number.isFinite(rcOnly)) throw new Error(`release --dev: could not count rc-only commits for the guard: ${rcOnlyOut || "(empty)"}`);
7753
+ if (rcOnly > 0) {
7754
+ throw new Error(
7755
+ `release --dev refused: origin/rc carries ${rcOnly} commit(s) not in origin/development \u2014 a development -> main release would drop that rc-only content. Land it on development first, or release the candidate via the default rc -> main path, then rerun.`
7756
+ );
7757
+ }
7758
+ ensurePositiveCount(
7759
+ await deps.run("git", ["rev-list", "--count", "origin/main..origin/development"]),
7760
+ "nothing to release: origin/development is not ahead of origin/main"
7761
+ );
7762
+ const deployModel2 = await preflight(deps, ctx, "main", meta);
7763
+ const tag2 = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release tag");
7764
+ const rcShaAtRelease = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
7765
+ const foldPaths2 = await resolveFoldPaths(deps, deployModel2);
7766
+ const tolerated2 = [...foldPaths2, ...RELEASE_TOLERATED_PATHS];
7767
+ const predicted2 = await predictMergeConflicts(deps, "origin/main", "origin/development");
7768
+ const predictedBlocking2 = predicted2.filter((f) => !isSpinePath(f) && !tolerated2.includes(f));
7769
+ if (predictedBlocking2.length > 0) {
7770
+ throw new Error(
7771
+ `development -> main merge would conflict on non-spine path(s): ${predictedBlocking2.join(", ")} \u2014 no merge was started. The train is misaligned: reconcile main and development via an approved alignment PR, then rerun release.`
7772
+ );
7773
+ }
7774
+ await deps.run("git", ["checkout", "main"]);
7775
+ await deps.run("git", ["pull", "--ff-only", "origin", "main"]);
7776
+ if (predicted2.length === 0) {
7777
+ await deps.run("git", ["merge", "development", "--no-edit"]);
7778
+ } else {
7779
+ await mergeWithSpineResolution(deps, "development", "development -> main", "theirs", tolerated2);
7780
+ }
7781
+ const versionFold2 = await foldReleaseVersion(deps, deployModel2, tag2, foldPaths2);
7782
+ const releaseSha2 = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
7783
+ await ensureTagPushed(deps, tag2, releaseSha2);
7784
+ const requiredChecks2 = await discoverRequiredCheckContexts(deps, ctx, "main");
7785
+ const checks2 = await waitForRequiredTrainChecks(deps, ctx, releaseSha2, requiredChecks2);
7786
+ await deps.run("git", ["push", "origin", "main"]);
7787
+ const releaseUrl2 = clean(await deps.run("gh", ["release", "create", tag2, "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
7788
+ const announceNote2 = deps.announce ? (await deps.announce({ repo: ctx.repo, tag: tag2, summaryFile: options.announceSummaryFile })).note : void 0;
7789
+ const autoRunSince2 = (deps.now ?? Date.now)();
7790
+ const d2 = await dispatchDeploy(deps, ctx, "main", "main", deployModel2, watch, autoRunSince2, releaseSha2);
7791
+ const retirement2 = await retireRcRuntime(deps, ctx, deployModel2, d2.deployStatus, rcShaAtRelease);
7792
+ await deps.run("git", ["checkout", "development"]);
7793
+ await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
7794
+ await deps.run("git", ["merge", "main", "--no-edit"]);
7795
+ await deps.run("git", ["push", "origin", "development"]);
7796
+ let rcAlignment2;
7797
+ try {
7798
+ await deps.run("git", ["push", "origin", "main:rc"]);
7799
+ rcAlignment2 = "origin/rc aligned to the released main";
7800
+ } catch (e) {
7801
+ rcAlignment2 = `rc alignment push failed \u2014 align manually with \`git push origin main:rc\`: ${e instanceof Error ? e.message : String(e)}`;
7802
+ }
7803
+ const environments2 = await buildEnvironments(deps, ctx, deployModel2, d2.deployStatus, retirement2);
7804
+ return {
7805
+ ...ctx,
7806
+ command,
7807
+ stage: "main",
7808
+ ref: "main",
7809
+ tag: tag2,
7810
+ deployModel: deployModel2,
7811
+ promoted: true,
7812
+ checks: checks2,
7813
+ versionFold: versionFold2,
7814
+ dispatch: d2.note,
7815
+ runId: d2.runId,
7816
+ runUrl: d2.runUrl,
7817
+ workflowRuns: d2.workflowRuns,
7818
+ deployStatus: d2.deployStatus,
7819
+ rcRetirement: retirement2.status,
7820
+ rcRetirementNote: retirement2.note,
7821
+ rcRetirementCategory: retirement2.category,
7822
+ rcAlignment: rcAlignment2,
7823
+ announceNote: announceNote2,
7824
+ devNote: "released development -> main (--dev), skipping the rc candidate",
7825
+ release: { tag: tag2, url: releaseUrl2, targetSha: releaseSha2 },
7826
+ environments: environments2
7827
+ };
7828
+ }
7648
7829
  await requireBranch(deps, "rc");
7649
7830
  ensurePositiveCount(
7650
7831
  await deps.run("git", ["rev-list", "--count", "origin/main..origin/rc"]),
@@ -12550,7 +12731,39 @@ program2.command("port-range <repo>").description("assign (idempotently) + print
12550
12731
  printLine(`${repo}: stage.portRange [${start}, ${end}]${write.ok ? "" : ` (META not persisted: ${write.error ?? `HTTP ${write.status}`})`}`);
12551
12732
  }
12552
12733
  });
12553
- var stage = program2.command("stage").description("plan or run the repo local stage environment").option("--json", "machine-readable output").option("--apply", "run the full local stage: stop previous, build, start, health-check").option("--timeout-ms <ms>", "bounded build/health timeout", "60000").action(async (o) => {
12734
+ async function stageLiveTarget() {
12735
+ const repo = await resolveRepo();
12736
+ if (!repo) throw new Error("stage --live: cannot resolve the current repo (run inside a GitHub-remoted checkout)");
12737
+ const ref = await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "") || void 0;
12738
+ return { slug: slugOf(repo), repo, ref };
12739
+ }
12740
+ async function runStageLiveCommand(o) {
12741
+ let target;
12742
+ try {
12743
+ target = await stageLiveTarget();
12744
+ } catch (e) {
12745
+ return failGraceful(e.message);
12746
+ }
12747
+ const mode = o.down ? "down" : "up";
12748
+ if (!o.apply) {
12749
+ const steps = o.down ? stageLiveDownSteps(target) : stageLiveUpSteps(target);
12750
+ if (o.json) return console.log(JSON.stringify({ command: "stage --live", mode, slug: target.slug, repo: target.repo, ref: o.down ? void 0 : target.ref, steps }, null, 2));
12751
+ return console.log(renderSteps(`mmi-cli stage --live${o.down ? " --down" : ""}: dry-run plan`, steps));
12752
+ }
12753
+ const deps = {
12754
+ detectIp: () => detectPublicIp(),
12755
+ run: async (file, args) => (await execFileP2(file, args, { timeout: GH_MUTATION_TIMEOUT_MS })).stdout
12756
+ };
12757
+ try {
12758
+ const result = o.down ? await runStageLiveDown(deps, target) : await runStageLiveUp(deps, target);
12759
+ return printLine(o.json ? JSON.stringify(result) : `mmi-cli stage --live: ${result.message}`);
12760
+ } catch (e) {
12761
+ return failGraceful(`stage --live: ${e.message}`);
12762
+ }
12763
+ }
12764
+ var stage = program2.command("stage").description("plan or run the repo local stage environment; --live = personal IP-gated cloud dev stage").option("--json", "machine-readable output").option("--apply", "run the full local stage: stop previous, build, start, health-check").option("--live", "personal cloud dev stage: deploy the current branch to the dev runtime, served only to your public IP").option("--down", "with --live: stop the dev runtime and clear the IP allowlist").option("--timeout-ms <ms>", "bounded build/health timeout", "60000").action(async (o) => {
12765
+ if (o.down && !o.live) return fail("stage: --down applies to --live only (local teardown is `mmi-cli stage stop --apply`)");
12766
+ if (o.live) return runStageLiveCommand(o);
12554
12767
  const res = await resolveStage();
12555
12768
  if (o.apply) {
12556
12769
  if (res.source === "none") return failGraceful(`stage: ${res.gap}`);
@@ -12718,6 +12931,7 @@ function renderTrainApply(commandName, r) {
12718
12931
  let base = `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
12719
12932
  if (r.versionFold) base = `${base}; ${r.versionFold}`;
12720
12933
  if (r.resumeNote) base = `${base}; ${r.resumeNote}`;
12934
+ if (r.devNote) base = `${base}; ${r.devNote}`;
12721
12935
  if (r.rcRetirement) base = `${base}; rc retirement: ${r.rcRetirement.toUpperCase()} (${r.rcRetirementNote ?? ""})`;
12722
12936
  return r.announceNote ? `${base}; announce: ${r.announceNote}` : base;
12723
12937
  }
@@ -12733,7 +12947,7 @@ async function resolveRcandPlanTargets() {
12733
12947
  }
12734
12948
  }
12735
12949
  for (const commandName of ["rcand", "release"]) {
12736
- program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the deploy/publish workflow runs and report their outcomes").option("--apply", "execute the guarded master-only train after explicit approval").option("--announce-summary-file <path>", "release only: agent-curated summary lines for the Hub Slack announcement (#883)").option("--ack <shas>", "release only: 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)").action(async (o) => {
12950
+ program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the deploy/publish workflow runs and report their outcomes").option("--apply", "execute the guarded master-only train after explicit approval").option("--announce-summary-file <path>", "release only: agent-curated summary lines for the Hub Slack announcement (#883)").option("--ack <shas>", "release only: 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)").option("--dev", "release only: full-track repos release development -> main directly, skipping rc (refuses if rc carries content not in development; no-op on direct-track repos) (#1062)").action(async (o) => {
12737
12951
  try {
12738
12952
  await requireFreshTrainCli(commandName);
12739
12953
  } catch (e) {
@@ -12742,10 +12956,13 @@ for (const commandName of ["rcand", "release"]) {
12742
12956
  if (o.ack && commandName !== "release") {
12743
12957
  return fail("--ack applies only to release: it overrides the rc -> main hotfix-coverage guard, which rcand does not run");
12744
12958
  }
12959
+ if (o.dev && commandName !== "release") {
12960
+ return fail("--dev applies only to release: it ships development -> main skipping rc, which rcand cannot do");
12961
+ }
12745
12962
  if (o.apply) {
12746
12963
  try {
12747
12964
  const ack = (o.ack ?? "").split(",").map((s) => s.trim()).filter(Boolean);
12748
- const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile, ack });
12965
+ const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile, ack, dev: o.dev });
12749
12966
  return printLine(o.json ? JSON.stringify(result, null, 2) : renderTrainApply(commandName, result));
12750
12967
  } catch (e) {
12751
12968
  return failGraceful(`${commandName}: ${e.message}`);
@@ -12753,7 +12970,7 @@ for (const commandName of ["rcand", "release"]) {
12753
12970
  }
12754
12971
  const repo = await resolveRepo();
12755
12972
  const targets = commandName === "rcand" ? await resolveRcandPlanTargets() : void 0;
12756
- const steps = trainPlan(commandName, { ...targets ?? {}, repo });
12973
+ const steps = trainPlan(commandName, { ...targets ?? {}, repo, dev: o.dev });
12757
12974
  console.log(
12758
12975
  o.json ? JSON.stringify({ command: commandName, ...targets ?? {}, repo, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps)
12759
12976
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.21.0",
3
+ "version": "2.23.0",
4
4
  "description": "MMI Future CLI — delivers the org rules (whole-file), plus saga and KB access. The cross-IDE engine the plugin's SessionStart hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",