@mutmutco/cli 0.12.0 → 2.1.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 (3) hide show
  1. package/README.md +12 -7
  2. package/dist/index.cjs +192 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  The command-line engine for MMI Future org tooling. It delivers the org spine, reads and claims GitHub Project work, records saga continuity notes, and exposes the model-agnostic commands used by the MMI plugin and non-Claude agents.
4
4
 
5
- This package is published from [mutmutco/MMI-Hub](https://github.com/mutmutco/MMI-Hub) and its version matches the MMI Hub Claude Code and Codex plugin versions.
5
+ This package is published from [mutmutco/MMI-Hub](https://github.com/mutmutco/MMI-Hub) and its version matches the MMI Hub Claude Code and Codex plugin versions (the release train bumps all of them in lockstep).
6
+
7
+ The CLI carries the org **Hub endpoint** intrinsically (override with the `MMI_HUB_URL` env var), so a product repo needs **no committed `.mmi/config.json`** to reach the Hub — board coords, deploy coordinates, OAuth, and the secrets layout are all discovered from the Hub registry at runtime.
6
8
 
7
9
  ## Install
8
10
 
@@ -28,17 +30,20 @@ mmi-cli doctor --json
28
30
  - `mmi-cli rules sync` delivers the org-owned `AGENTS.md`, `CLAUDE.md`, and Claude settings files.
29
31
  - `mmi-cli docs sync` refreshes repo-owned `README.md` and `architecture.md` without clobbering dirty files.
30
32
  - `mmi-cli saga note`, `saga show`, `saga health`, `saga session`, `saga capture`, and `saga head-update` write and inspect session continuity.
31
- - `mmi-cli kb get`, `kb tree`, and `kb list` read the MM KB source.
33
+ - `mmi-cli kb get` and `kb list` read the MM KB source (`kb list [prefix]` lists document paths, optionally under a prefix).
32
34
  - `mmi-cli northstar push|pull|list|delete|graduate` manages North Star, the per-user plan/SSOT store.
33
35
  `northstar graduate <slug> --merged-pr <url-or-number> --org-visible` marks a built-and-merged plan for
34
36
  KB curation without echoing the plan body.
35
37
  `mmi-cli plan` remains a compatibility alias.
36
- - `mmi-cli secrets list|get|set|edit|rm|use|grant|revoke` manages two-tier project/org secrets without logging values.
37
- - `mmi-cli issue create` creates typed, prioritized GitHub issues and queues related-issue discovery.
38
- - `mmi-cli pr create` and `pr merge` create PRs and land them with branch/worktree cleanup.
38
+ - `mmi-cli secrets where|list|get|set|edit|rm|use|grant|revoke` manages two-tier project/org secrets without logging values; `where` prints the vault layout + well-known keys, and values move over TLS in the request body — never an argument.
39
+ - `mmi-cli project list|get|resolve|set` reads the DDB org registry — a project's identity + board coords + deploy coordinates (`resolve` reads deploy coords, which are OIDC-gated; `set` is master-only).
40
+ - `mmi-cli registry org` reads org-level constants from the registry (`ORG#config`).
41
+ - `mmi-cli oauth plan|verify` prints a repo's canonical Google OAuth URI set (read from the registry) and verifies the client is port-agnostic.
42
+ - `mmi-cli issue create` creates typed, prioritized GitHub issues (priority sets the board field, not a label) and queues related-issue discovery.
43
+ - `mmi-cli pr create` and `pr merge` create PRs and land them with branch/worktree cleanup; `mmi-cli gc` dry-runs cleanup of merged/closed PR branches + stale tracking refs.
39
44
  - `mmi-cli board read|claim|show|move|done|backfill-priority` reads and moves GitHub Project work.
40
- - `mmi-cli stage`, `stage start`, `stage stop`, and `stage run` manage the local gitignored stage.
41
- - `mmi-cli rc`, `release`, and `hotfix` render guarded train plans.
45
+ - `mmi-cli stage`, `stage start`, `stage stop`, `stage run`, and `port-range <repo>` manage the local gitignored stage and its port block; `stage-live` explains that remote rc/live move only via `/rcand` · `/release` · `/hotfix`.
46
+ - `mmi-cli rcand`, `release`, and `hotfix` render guarded train plans; the train triggers the Hub's central tenant deployer, so product repos carry no deploy file.
42
47
  - `mmi-cli bootstrap`, `bootstrap verify`, and `bootstrap apply` plan, audit, and seed repo onboarding.
43
48
  - `mmi-cli access audit` checks collaborator roles and train-branch allowlists.
44
49
  - `mmi-cli doctor` checks GitHub auth, repo config, CLI availability, the per-project plugin install record, and stale plugin/cache state, auto-repairing the safe gaps.
package/dist/index.cjs CHANGED
@@ -4288,23 +4288,29 @@ function stagePlan(stage2 = {}) {
4288
4288
  function stageLivePlan() {
4289
4289
  return [
4290
4290
  { label: "stage-live is not an org command; /stage is local only", command: "mmi-cli stage run --apply" },
4291
- { label: "remote rc/live environments move through the gated promotion train", command: "mmi-cli rc && mmi-cli release && mmi-cli hotfix", gated: true }
4291
+ { label: "remote rc/live environments move through the gated promotion train", command: "mmi-cli rcand && mmi-cli release && mmi-cli hotfix", gated: true }
4292
4292
  ];
4293
4293
  }
4294
4294
  function trainPlan(command) {
4295
- if (command === "rc") {
4295
+ if (command === "rcand") {
4296
4296
  return [
4297
- { label: "verify current branch is development" },
4297
+ { label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
4298
+ { label: "verify current branch is development", gated: true },
4299
+ { label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
4300
+ { label: "preflight required rc secret names", command: "mmi-cli secrets preflight --stage rc --repo <owner/repo>", gated: true },
4298
4301
  { label: "merge development to rc", gated: true },
4299
- { label: "deploy rc", gated: true }
4302
+ { label: "dispatch central tenant deploy for rc", command: "gh workflow run tenant-deploy.yml --repo mutmutco/MMI-Hub -f slug=<slug> -f repo=<owner/repo> -f ref=rc -f stage=rc", gated: true }
4300
4303
  ];
4301
4304
  }
4302
4305
  if (command === "release") {
4303
4306
  return [
4304
- { label: "verify current branch is rc" },
4307
+ { label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
4308
+ { label: "verify current branch is rc", gated: true },
4309
+ { label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
4310
+ { label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
4305
4311
  { label: "merge rc to main", gated: true },
4306
4312
  { label: "tag release and publish GitHub Release", gated: true },
4307
- { label: "deploy prod", gated: true },
4313
+ { label: "dispatch central tenant deploy for main", command: "gh workflow run tenant-deploy.yml --repo mutmutco/MMI-Hub -f slug=<slug> -f repo=<owner/repo> -f ref=main -f stage=main", gated: true },
4308
4314
  { label: "roll development forward", gated: true }
4309
4315
  ];
4310
4316
  }
@@ -4628,6 +4634,111 @@ async function runStage(config = {}, opts = {}) {
4628
4634
  return { ...started, action: "run", message: `built and ${started.message}` };
4629
4635
  }
4630
4636
 
4637
+ // src/train-apply.ts
4638
+ function clean(out) {
4639
+ return out.trim();
4640
+ }
4641
+ function requireValue(value, label) {
4642
+ if (!value) throw new Error(`${label} could not be resolved`);
4643
+ return value;
4644
+ }
4645
+ function ensurePositiveCount(out, emptyMessage) {
4646
+ const count = Number.parseInt(out.trim(), 10);
4647
+ if (!Number.isFinite(count)) throw new Error(`could not parse ahead count: ${out.trim() || "(empty)"}`);
4648
+ if (count <= 0) throw new Error(emptyMessage);
4649
+ }
4650
+ async function buildTrainApplyContext(deps) {
4651
+ const repo = requireValue(clean(await deps.run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])), "repo");
4652
+ const [owner, name] = repo.split("/");
4653
+ if (!owner || !name) throw new Error(`repo must be owner/name, got ${repo}`);
4654
+ const login = requireValue(clean(await deps.run("gh", ["api", "user", "--jq", ".login"])), "GitHub login");
4655
+ const role = clean(await deps.run("gh", ["api", `orgs/${owner}/memberships/${login}`, "--jq", ".role"]));
4656
+ if (role !== "admin") {
4657
+ throw new Error(`${commandAuthorityLabel(owner)} is master-admin only; @${login} is ${role || "not an org admin"}`);
4658
+ }
4659
+ return {
4660
+ repo,
4661
+ owner,
4662
+ slug: name.toLowerCase(),
4663
+ login
4664
+ };
4665
+ }
4666
+ function commandAuthorityLabel(owner) {
4667
+ return `${owner} release train`;
4668
+ }
4669
+ async function requireCleanTree(deps) {
4670
+ const status = await deps.run("git", ["status", "--porcelain"]);
4671
+ if (status.trim()) throw new Error("working tree must be clean before train apply");
4672
+ }
4673
+ async function requireBranch(deps, branch) {
4674
+ const current = clean(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
4675
+ if (current !== branch) throw new Error(`must run from ${branch}, currently on ${current || "(unknown)"}`);
4676
+ }
4677
+ async function dispatchDeploy(deps, ctx, stage2, ref) {
4678
+ await deps.run("gh", [
4679
+ "workflow",
4680
+ "run",
4681
+ "tenant-deploy.yml",
4682
+ "--repo",
4683
+ "mutmutco/MMI-Hub",
4684
+ "-f",
4685
+ `slug=${ctx.slug}`,
4686
+ "-f",
4687
+ `repo=${ctx.repo}`,
4688
+ "-f",
4689
+ `ref=${ref}`,
4690
+ "-f",
4691
+ `stage=${stage2}`
4692
+ ]);
4693
+ }
4694
+ async function preflight(deps, ctx, stage2) {
4695
+ await deps.runSelf(["project", "get", ctx.repo]);
4696
+ await deps.runSelf(["secrets", "preflight", "--stage", stage2, "--repo", ctx.repo]);
4697
+ }
4698
+ async function runTrainApply(command, deps) {
4699
+ const ctx = await buildTrainApplyContext(deps);
4700
+ await requireCleanTree(deps);
4701
+ await deps.run("git", ["fetch", "origin"]);
4702
+ if (command === "rcand") {
4703
+ await requireBranch(deps, "development");
4704
+ await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
4705
+ ensurePositiveCount(
4706
+ await deps.run("git", ["rev-list", "--count", "origin/rc..origin/development"]),
4707
+ "nothing to promote: origin/development is not ahead of origin/rc"
4708
+ );
4709
+ await preflight(deps, ctx, "rc");
4710
+ await deps.run("git", ["checkout", "rc"]);
4711
+ await deps.run("git", ["pull", "--ff-only", "origin", "rc"]);
4712
+ await deps.run("git", ["merge", "development", "--no-edit"]);
4713
+ const tag2 = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "rc"])), "rc tag");
4714
+ await deps.run("git", ["tag", tag2]);
4715
+ await deps.run("git", ["push", "origin", "rc"]);
4716
+ await deps.run("git", ["push", "origin", tag2]);
4717
+ await dispatchDeploy(deps, ctx, "rc", "rc");
4718
+ return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2 };
4719
+ }
4720
+ await requireBranch(deps, "rc");
4721
+ ensurePositiveCount(
4722
+ await deps.run("git", ["rev-list", "--count", "origin/main..origin/rc"]),
4723
+ "nothing to release: origin/rc is not ahead of origin/main"
4724
+ );
4725
+ await preflight(deps, ctx, "main");
4726
+ await deps.run("git", ["checkout", "main"]);
4727
+ await deps.run("git", ["pull", "--ff-only", "origin", "main"]);
4728
+ await deps.run("git", ["merge", "rc", "--no-edit"]);
4729
+ const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
4730
+ await deps.run("git", ["tag", tag]);
4731
+ await deps.run("git", ["push", "origin", "main"]);
4732
+ await deps.run("git", ["push", "origin", tag]);
4733
+ await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
4734
+ await dispatchDeploy(deps, ctx, "main", "main");
4735
+ await deps.run("git", ["checkout", "development"]);
4736
+ await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
4737
+ await deps.run("git", ["merge", "main", "--no-edit"]);
4738
+ await deps.run("git", ["push", "origin", "development"]);
4739
+ return { ...ctx, command, stage: "main", ref: "main", tag };
4740
+ }
4741
+
4631
4742
  // src/port-registry.ts
4632
4743
  var import_node_fs3 = require("node:fs");
4633
4744
  var BLOCK = 100;
@@ -4906,7 +5017,7 @@ var requiredIssueTemplates = [
4906
5017
  ".github/ISSUE_TEMPLATE/task.yml",
4907
5018
  ".github/ISSUE_TEMPLATE/config.yml"
4908
5019
  ];
4909
- var requiredWorkflows = [".github/workflows/pr-to-board.yml"];
5020
+ var requiredWorkflows = [];
4910
5021
  var requiredLabels = ["bug", "feature", "task", "priority:urgent", "priority:high", "priority:medium", "priority:low"];
4911
5022
  var requiredPriorityOptions = ["Urgent", "High", "Medium", "Low"];
4912
5023
  var strayDefaultLabels = ["documentation", "duplicate", "enhancement", "good first issue", "help wanted", "invalid", "question", "wontfix"];
@@ -5373,8 +5484,8 @@ function resolveKbSource(rawBase) {
5373
5484
  return { owner: m[1], repo: m[2], ref: m[3] };
5374
5485
  }
5375
5486
  function buildKbGetArgs(src, path) {
5376
- const clean = path.replace(/^\/+/, "");
5377
- return ["api", `repos/${src.owner}/${src.repo}/contents/${clean}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
5487
+ const clean2 = path.replace(/^\/+/, "");
5488
+ return ["api", `repos/${src.owner}/${src.repo}/contents/${clean2}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
5378
5489
  }
5379
5490
  function buildKbTreeArgs(src) {
5380
5491
  return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
@@ -5686,6 +5797,46 @@ async function secretsList(deps, opts) {
5686
5797
  const { secrets: secrets2 } = await res.json();
5687
5798
  deps.log(formatSecretList(secrets2 ?? []));
5688
5799
  }
5800
+ var DEFAULT_RUNTIME_SECRET_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
5801
+ function stringList(v) {
5802
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string" && isValidSecretKey(x)) : [];
5803
+ }
5804
+ function requiredRuntimeSecretNames(stage2, contract) {
5805
+ const extra = Array.isArray(contract) ? stringList(contract) : stringList(contract?.[stage2]);
5806
+ return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES, ...extra])];
5807
+ }
5808
+ function stageKey(stage2, key) {
5809
+ return key.includes("/") ? key : `${stage2}/${key}`;
5810
+ }
5811
+ async function secretsPreflight(deps, opts) {
5812
+ const repo = await targetRepo(deps, opts);
5813
+ const qs = new URLSearchParams({ repo }).toString();
5814
+ let res;
5815
+ try {
5816
+ res = await deps.fetch(`${deps.apiUrl}/secrets/list?${qs}`, {
5817
+ method: "GET",
5818
+ headers: await deps.headers(),
5819
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5820
+ });
5821
+ } catch (e) {
5822
+ deps.err(`secrets preflight: ${e.message}`);
5823
+ return false;
5824
+ }
5825
+ if (!res.ok) {
5826
+ deps.err(`secrets preflight failed: HTTP ${res.status}${await readErr(res)}`);
5827
+ return false;
5828
+ }
5829
+ const { secrets: secrets2 } = await res.json();
5830
+ const present = new Set((secrets2 ?? []).map((s) => s.key));
5831
+ const required = opts.required.map((key) => stageKey(opts.stage, key));
5832
+ const missing = required.filter((key) => !present.has(key));
5833
+ if (missing.length) {
5834
+ deps.log(`missing ${missing.join(", ")}`);
5835
+ return false;
5836
+ }
5837
+ deps.log(`all required ${opts.stage} secret names are present`);
5838
+ return true;
5839
+ }
5689
5840
  async function secretsGet(deps, key, opts) {
5690
5841
  if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
5691
5842
  const repo = await targetRepo(deps, opts);
@@ -6516,6 +6667,22 @@ async function withSecrets(run) {
6516
6667
  var secrets = program2.command("secrets").description("two-tier project secrets \u2014 self-serve your repo dev/* tier; org tier is master-gated");
6517
6668
  secrets.command("where").description("print where this repo\u2019s secrets live \u2014 the two-tier vault layout + well-known keys (no values)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((o) => withSecrets((d) => secretsWhere(d, o)));
6518
6669
  secrets.command("list").description("list secret NAMES + tier for this repo (never values)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((o) => withSecrets((d) => secretsList(d, o)));
6670
+ secrets.command("preflight").description("check required stage secret names for a deploy/train without reading values").requiredOption("--stage <dev|rc|main>", "stage to check").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--required <KEY...>", "required keys; bare keys are scoped under --stage").action(async (o) => {
6671
+ if (!["dev", "rc", "main"].includes(o.stage)) {
6672
+ return fail("secrets preflight: --stage must be dev, rc, or main");
6673
+ }
6674
+ const cfg = await loadConfig();
6675
+ if (!cfg.sagaApiUrl) {
6676
+ fail("secrets: sagaApiUrl not configured in .mmi/config.json (this repo is not bootstrapped)");
6677
+ return;
6678
+ }
6679
+ const d = makeSecretsDeps(cfg);
6680
+ const repo = o.repo ?? `mutmutco/${await d.slug()}`;
6681
+ const meta = await fetchProjectBySlug(slugOf(repo), registryClientDeps(cfg));
6682
+ const required = o.required?.length ? o.required : requiredRuntimeSecretNames(o.stage, meta?.requiredRuntimeSecrets);
6683
+ const ok = await secretsPreflight(d, { repo: o.repo, stage: o.stage, required });
6684
+ if (!ok) process.exitCode = 1;
6685
+ });
6519
6686
  secrets.command("get <key>").description("print one secret value over TLS (prints once, raw \u2014 do not log/paste it)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsGet(d, key, o)));
6520
6687
  secrets.command("set <key>").description("write/rotate a secret; value is read from stdin (never an argument)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsSet(d, key, o)));
6521
6688
  secrets.command("edit <key>").description("alias for set \u2014 replace a secret value (read from stdin)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsEdit(d, key, o)));
@@ -6918,13 +7085,25 @@ stage.command("run").description("force-stop previous stage, build, start, and h
6918
7085
  }
6919
7086
  });
6920
7087
  program2.command("stage-live").description("explain that remote rc/live environments use /rcand, /release, and /hotfix; /stage is local only").option("--json", "machine-readable output").option("--apply", "always refused; there is no stage-live mutation path").action((o) => {
6921
- if (o.apply) return fail("stage-live: not an org command; use mmi-cli stage for local tests, or the gated rc/release/hotfix train for remote environments");
7088
+ if (o.apply) return fail("stage-live: not an org command; use mmi-cli stage for local tests, or the gated rcand/release/hotfix train for remote environments");
6922
7089
  const steps = stageLivePlan();
6923
7090
  console.log(o.json ? JSON.stringify({ command: "stage-live", steps }, null, 2) : renderSteps("mmi-cli stage-live: not an org command", steps));
6924
7091
  });
6925
- for (const commandName of ["rc", "release", "hotfix"]) {
6926
- program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit approval`).option("--json", "machine-readable output").option("--apply", "reserved for future train execution after explicit admin approval").action((o) => {
6927
- if (o.apply) return fail(`${commandName}: execution is not implemented yet; use the dry-run plan and the existing /${commandName === "rc" ? "rcand" : commandName} skill`);
7092
+ for (const commandName of ["rcand", "release", "hotfix"]) {
7093
+ program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--apply", commandName === "hotfix" ? "reserved; hotfix uses the /hotfix skill PR path" : "execute the guarded master-only train after explicit approval").action(async (o) => {
7094
+ if (o.apply) {
7095
+ if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
7096
+ try {
7097
+ const result = await runTrainApply(commandName, {
7098
+ run: async (file, args) => (await execFileP3(file, args, { timeout: file === "gh" ? 3e4 : GIT_TIMEOUT_MS })).stdout,
7099
+ runSelf: async (args) => (await execFileP3(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout
7100
+ });
7101
+ const message = `mmi-cli ${commandName}: applied ${result.repo} ${result.stage} train at ${result.tag}; dispatched ${result.ref} deploy`;
7102
+ return printLine(o.json ? JSON.stringify(result, null, 2) : message);
7103
+ } catch (e) {
7104
+ return fail(`${commandName}: ${e.message}`);
7105
+ }
7106
+ }
6928
7107
  const steps = trainPlan(commandName);
6929
7108
  console.log(o.json ? JSON.stringify({ command: commandName, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps));
6930
7109
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "0.12.0",
3
+ "version": "2.1.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",