@mutmutco/cli 2.0.0 → 2.3.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 +1 -1
  2. package/dist/index.cjs +395 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -43,7 +43,7 @@ mmi-cli doctor --json
43
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.
44
44
  - `mmi-cli board read|claim|show|move|done|backfill-priority` reads and moves GitHub Project work.
45
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 rc`, `release`, and `hotfix` render guarded train plans; the train triggers the Hub's central tenant deployer, so product repos carry no deploy file.
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.
47
47
  - `mmi-cli bootstrap`, `bootstrap verify`, and `bootstrap apply` plan, audit, and seed repo onboarding.
48
48
  - `mmi-cli access audit` checks collaborator roles and train-branch allowlists.
49
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
@@ -3221,6 +3221,13 @@ async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
3221
3221
  // src/gh-create.ts
3222
3222
  var ISSUE_TYPES = ["bug", "feature", "task"];
3223
3223
  var PRIORITIES = ["urgent", "high", "medium", "low"];
3224
+ function normalizePriority(priority) {
3225
+ const p = priority.trim().toLowerCase();
3226
+ if (!PRIORITIES.includes(p)) {
3227
+ throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${PRIORITIES.join(", ")}`);
3228
+ }
3229
+ return p;
3230
+ }
3224
3231
  function parseCreatedUrl(stdout) {
3225
3232
  const re = /https:\/\/github\.com\/[^\s]+\/(?:issues|pull)\/(\d+)/g;
3226
3233
  let match;
@@ -3234,9 +3241,7 @@ ${stdout.trim() || "(empty)"}`);
3234
3241
  }
3235
3242
  function buildIssueArgs({ type, title, body, priority, repo, labels }) {
3236
3243
  if (!ISSUE_TYPES.includes(type)) throw new Error(`unknown issue type "${type}" \u2014 expected one of: ${ISSUE_TYPES.join(", ")}`);
3237
- if (!PRIORITIES.includes(priority)) {
3238
- throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${PRIORITIES.join(", ")}`);
3239
- }
3244
+ normalizePriority(priority);
3240
3245
  const args = ["issue", "create"];
3241
3246
  if (repo) args.push("--repo", repo);
3242
3247
  args.push("--title", title, "--body", body, "--label", type);
@@ -3304,6 +3309,7 @@ function buildHealth(i) {
3304
3309
  if (!i.sagaApiUrl) problems.push("sagaApiUrl not configured in .mmi/config.json");
3305
3310
  if (!i.identity) problems.push("no GitHub identity (gh auth token / GH_TOKEN)");
3306
3311
  if (!i.reachable) problems.push("saga backend unreachable");
3312
+ if (i.reachable && i.authorized === false) problems.push("saga backend rejected authenticated state access");
3307
3313
  if (!i.key.sessionId || i.key.sessionId === "-") problems.push("unsafe session id");
3308
3314
  const safeToWrite = problems.length === 0;
3309
3315
  return {
@@ -3311,6 +3317,7 @@ function buildHealth(i) {
3311
3317
  safeToWrite,
3312
3318
  identity: i.identity,
3313
3319
  reachable: i.reachable,
3320
+ authorized: i.authorized,
3314
3321
  sagaApiUrl: i.sagaApiUrl,
3315
3322
  key: i.key,
3316
3323
  source: i.source,
@@ -3324,7 +3331,7 @@ function healthBanner(report) {
3324
3331
  return `saga health: CHECK - ${summary}${suffix}`;
3325
3332
  }
3326
3333
  function resumeCue() {
3327
- return '> STATUS/RESUME CUE \u2014 For any status, resume, or "where do I stand" report: read THIS saga HEAD first (`mmi-cli saga show`), then reconcile its NEXT / LAST 5 / DECISIONS against the live board + git/gh before reporting. Do not rebuild the picture from board/issues/memory while skipping the HEAD.';
3334
+ return '> STATUS/RESUME CUE \u2014 For any status, resume, or "where do I stand" report: read THIS saga HEAD first (`mmi-cli saga show`), then reconcile its NEXT / LAST 5 / DECISIONS against the live board + git/gh before reporting. Do not rebuild the picture from board/issues/memory while skipping the HEAD. PRECEDENCE: the HEAD is prior-session belief and MAY BE SUPERSEDED \u2014 the current live user/master instruction WINS over any conflicting HEAD anchor, NEXT, or checklist; follow the live instruction and treat the stale HEAD item as superseded.';
3328
3335
  }
3329
3336
 
3330
3337
  // src/saga-note.ts
@@ -4288,23 +4295,29 @@ function stagePlan(stage2 = {}) {
4288
4295
  function stageLivePlan() {
4289
4296
  return [
4290
4297
  { 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 }
4298
+ { label: "remote rc/live environments move through the gated promotion train", command: "mmi-cli rcand && mmi-cli release && mmi-cli hotfix", gated: true }
4292
4299
  ];
4293
4300
  }
4294
4301
  function trainPlan(command) {
4295
- if (command === "rc") {
4302
+ if (command === "rcand") {
4296
4303
  return [
4297
- { label: "verify current branch is development" },
4304
+ { label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
4305
+ { label: "verify current branch is development", gated: true },
4306
+ { label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
4307
+ { label: "preflight required rc secret names", command: "mmi-cli secrets preflight --stage rc --repo <owner/repo>", gated: true },
4298
4308
  { label: "merge development to rc", gated: true },
4299
- { label: "deploy rc", gated: true }
4309
+ { 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
4310
  ];
4301
4311
  }
4302
4312
  if (command === "release") {
4303
4313
  return [
4304
- { label: "verify current branch is rc" },
4314
+ { label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
4315
+ { label: "verify current branch is rc", gated: true },
4316
+ { label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
4317
+ { label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
4305
4318
  { label: "merge rc to main", gated: true },
4306
4319
  { label: "tag release and publish GitHub Release", gated: true },
4307
- { label: "deploy prod", gated: true },
4320
+ { 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
4321
  { label: "roll development forward", gated: true }
4309
4322
  ];
4310
4323
  }
@@ -4406,6 +4419,13 @@ ${block}
4406
4419
  // src/doctor.ts
4407
4420
  var GH_PROJECT_LOGIN_FIX = 'run: gh auth login --hostname github.com --git-protocol https --web --scopes "project"';
4408
4421
  var AWS_CROSS_ACCOUNT_FIX = "use a non-root IAM user/session profile for master-agent AWS checks; set AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (plus AWS_SESSION_TOKEN for temporary credentials), then verify `aws sts get-caller-identity` does not end in :root";
4422
+ var MMI_AGENTIC_ONBOARDING_GUIDE = {
4423
+ label: "MMI Agentic Onboarding",
4424
+ url: "https://github.com/mutmutco/MMI-Hub/wiki/Agentic-Dev-Environment-Guide"
4425
+ };
4426
+ function doctorResourcesForGaps(gaps) {
4427
+ return gaps.length ? [MMI_AGENTIC_ONBOARDING_GUIDE] : [];
4428
+ }
4409
4429
  function buildGithubAuthCheck(input) {
4410
4430
  const ok = Boolean(input.login?.trim());
4411
4431
  return {
@@ -4461,6 +4481,45 @@ function buildPluginInstallRecordCheck(input) {
4461
4481
  recordToInsert
4462
4482
  };
4463
4483
  }
4484
+ var PLUGIN_DRIFT_LABEL = "plugin config drift (mmi@mmi duplicate rows / stale gitCommitSha)";
4485
+ function recordFreshness(r) {
4486
+ return r.lastUpdated ?? r.installedAt ?? "";
4487
+ }
4488
+ function freshestRecord(records) {
4489
+ return records.reduce((best, r) => recordFreshness(r) > recordFreshness(best) ? r : best);
4490
+ }
4491
+ function pluginConfigDriftFix(pluginId) {
4492
+ return `\`${pluginId}\` registered as N duplicate project rows / a stale gitCommitSha in ~/.claude/plugins/installed_plugins.json \u2014 run \`mmi-cli doctor\` to collapse them to one \`scope: user\` entry at the highest version (a .bak backup is written first), or in \`/plugin\` uninstall the extra rows and reinstall once at user scope`;
4493
+ }
4494
+ function buildPluginConfigDriftCheck(input) {
4495
+ const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
4496
+ const base = {
4497
+ ok: true,
4498
+ label: PLUGIN_DRIFT_LABEL,
4499
+ fix: pluginConfigDriftFix(pluginId),
4500
+ pluginId
4501
+ };
4502
+ if (!input.isOrgRepo) return base;
4503
+ const records = input.installed?.plugins?.[pluginId];
4504
+ if (!Array.isArray(records) || records.length === 0) return base;
4505
+ const projectRows = records.filter((r) => r.scope === "project");
4506
+ const freshest = freshestRecord(records);
4507
+ const freshSha = freshest.gitCommitSha;
4508
+ const staleShaRows = freshSha ? records.filter((r) => r.gitCommitSha !== void 0 && r.gitCommitSha !== freshSha).length : 0;
4509
+ const duplicateProjectRows = projectRows.length > 1 ? projectRows.length : 0;
4510
+ if (duplicateProjectRows === 0 && staleShaRows === 0) {
4511
+ return { ...base, duplicateProjectRows: 0, staleShaRows: 0 };
4512
+ }
4513
+ const { projectPath: _drop, ...rest } = freshest;
4514
+ const collapsed = { ...rest, scope: "user" };
4515
+ return {
4516
+ ...base,
4517
+ ok: false,
4518
+ recordsToWrite: [collapsed],
4519
+ duplicateProjectRows,
4520
+ staleShaRows
4521
+ };
4522
+ }
4464
4523
  var GITIGNORE_BLOCK_LABEL = "org .gitignore managed block (.playwright-mcp/, .claude/worktrees/, scratch *.png)";
4465
4524
  var GITIGNORE_BLOCK_FIX = "run `mmi-cli doctor` to auto-insert the `# >>> mmi-managed >>>` block (or copy it from MMI-Hub's .gitignore)";
4466
4525
  function buildGitignoreManagedBlockCheck(input) {
@@ -4628,6 +4687,144 @@ async function runStage(config = {}, opts = {}) {
4628
4687
  return { ...started, action: "run", message: `built and ${started.message}` };
4629
4688
  }
4630
4689
 
4690
+ // src/train-apply.ts
4691
+ function resolveDeployModel(meta, repo) {
4692
+ const m = meta?.deployModel;
4693
+ if (m === "hub-serverless" || m === "serverless" || m === "tenant-container" || m === "content") return m;
4694
+ if (meta?.class === "content") return "content";
4695
+ if (repo.toLowerCase().endsWith("/mmi-hub")) return "hub-serverless";
4696
+ return "tenant-container";
4697
+ }
4698
+ function clean(out) {
4699
+ return out.trim();
4700
+ }
4701
+ function requireValue(value, label) {
4702
+ if (!value) throw new Error(`${label} could not be resolved`);
4703
+ return value;
4704
+ }
4705
+ function releaseTagFromRcTag(tag) {
4706
+ return tag.replace(/-rc\.\d+$/, "");
4707
+ }
4708
+ async function verifyHubDistributionIfChanged(deps, model, releaseTag) {
4709
+ if (model !== "hub-serverless") return;
4710
+ await deps.run("node", ["scripts/release-distribution.mjs", "verify-if-changed", releaseTag, "--skip-npm-view"]);
4711
+ }
4712
+ function ensurePositiveCount(out, emptyMessage) {
4713
+ const count = Number.parseInt(out.trim(), 10);
4714
+ if (!Number.isFinite(count)) throw new Error(`could not parse ahead count: ${out.trim() || "(empty)"}`);
4715
+ if (count <= 0) throw new Error(emptyMessage);
4716
+ }
4717
+ async function buildTrainApplyContext(deps) {
4718
+ const repo = requireValue(clean(await deps.run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])), "repo");
4719
+ const [owner, name] = repo.split("/");
4720
+ if (!owner || !name) throw new Error(`repo must be owner/name, got ${repo}`);
4721
+ const login = requireValue(clean(await deps.run("gh", ["api", "user", "--jq", ".login"])), "GitHub login");
4722
+ const role = clean(await deps.run("gh", ["api", `orgs/${owner}/memberships/${login}`, "--jq", ".role"]));
4723
+ if (role !== "admin") {
4724
+ throw new Error(`${commandAuthorityLabel(owner)} is master-admin only; @${login} is ${role || "not an org admin"}`);
4725
+ }
4726
+ return {
4727
+ repo,
4728
+ owner,
4729
+ slug: name.toLowerCase(),
4730
+ login
4731
+ };
4732
+ }
4733
+ function commandAuthorityLabel(owner) {
4734
+ return `${owner} release train`;
4735
+ }
4736
+ async function requireCleanTree(deps) {
4737
+ const status = await deps.run("git", ["status", "--porcelain"]);
4738
+ if (status.trim()) throw new Error("working tree must be clean before train apply");
4739
+ }
4740
+ async function requireBranch(deps, branch) {
4741
+ const current = clean(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
4742
+ if (current !== branch) throw new Error(`must run from ${branch}, currently on ${current || "(unknown)"}`);
4743
+ }
4744
+ async function dispatchDeploy(deps, ctx, stage2, ref, model) {
4745
+ if (model === "tenant-container") {
4746
+ await deps.run("gh", [
4747
+ "workflow",
4748
+ "run",
4749
+ "tenant-deploy.yml",
4750
+ "--repo",
4751
+ "mutmutco/MMI-Hub",
4752
+ "-f",
4753
+ `slug=${ctx.slug}`,
4754
+ "-f",
4755
+ `repo=${ctx.repo}`,
4756
+ "-f",
4757
+ `ref=${ref}`,
4758
+ "-f",
4759
+ `stage=${stage2}`
4760
+ ]);
4761
+ return `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`;
4762
+ }
4763
+ if (model === "hub-serverless") {
4764
+ return ref === "rc" ? "no manual dispatch: deploy.yml auto-fires on the rc push (rc stage)" : "no manual dispatch: deploy.yml + publish.yml auto-fire on the published Release (prod)";
4765
+ }
4766
+ return `no manual dispatch: ${model} repo deploys via its own push-triggered workflow`;
4767
+ }
4768
+ async function preflight(deps, ctx, stage2) {
4769
+ let meta = null;
4770
+ try {
4771
+ meta = JSON.parse(await deps.runSelf(["project", "get", ctx.repo]));
4772
+ } catch {
4773
+ meta = null;
4774
+ }
4775
+ const model = resolveDeployModel(meta, ctx.repo);
4776
+ if (model === "content") {
4777
+ throw new Error(`${ctx.repo} is a content repo (deployModel=content) \u2014 the release train does not apply (trunk-based; PR to main)`);
4778
+ }
4779
+ await deps.runSelf(["secrets", "preflight", "--stage", stage2, "--repo", ctx.repo]);
4780
+ return model;
4781
+ }
4782
+ async function runTrainApply(command, deps) {
4783
+ const ctx = await buildTrainApplyContext(deps);
4784
+ await requireCleanTree(deps);
4785
+ await deps.run("git", ["fetch", "origin"]);
4786
+ if (command === "rcand") {
4787
+ await requireBranch(deps, "development");
4788
+ await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
4789
+ ensurePositiveCount(
4790
+ await deps.run("git", ["rev-list", "--count", "origin/rc..origin/development"]),
4791
+ "nothing to promote: origin/development is not ahead of origin/rc"
4792
+ );
4793
+ const deployModel2 = await preflight(deps, ctx, "rc");
4794
+ const tag2 = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "rc"])), "rc tag");
4795
+ await verifyHubDistributionIfChanged(deps, deployModel2, releaseTagFromRcTag(tag2));
4796
+ await deps.run("git", ["checkout", "rc"]);
4797
+ await deps.run("git", ["pull", "--ff-only", "origin", "rc"]);
4798
+ await deps.run("git", ["merge", "development", "--no-edit"]);
4799
+ await deps.run("git", ["tag", tag2]);
4800
+ await deps.run("git", ["push", "origin", "rc"]);
4801
+ await deps.run("git", ["push", "origin", tag2]);
4802
+ const dispatch2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2);
4803
+ return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, dispatch: dispatch2 };
4804
+ }
4805
+ await requireBranch(deps, "rc");
4806
+ ensurePositiveCount(
4807
+ await deps.run("git", ["rev-list", "--count", "origin/main..origin/rc"]),
4808
+ "nothing to release: origin/rc is not ahead of origin/main"
4809
+ );
4810
+ const deployModel = await preflight(deps, ctx, "main");
4811
+ await deps.run("git", ["checkout", "main"]);
4812
+ await deps.run("git", ["pull", "--ff-only", "origin", "main"]);
4813
+ await deps.run("git", ["merge", "rc", "--no-edit"]);
4814
+ const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
4815
+ await verifyHubDistributionIfChanged(deps, deployModel, tag);
4816
+ await deps.run("git", ["tag", tag]);
4817
+ await deps.run("git", ["push", "origin", "main"]);
4818
+ await deps.run("git", ["push", "origin", tag]);
4819
+ await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
4820
+ const dispatch = await dispatchDeploy(deps, ctx, "main", "main", deployModel);
4821
+ await deps.run("git", ["checkout", "development"]);
4822
+ await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
4823
+ await deps.run("git", ["merge", "main", "--no-edit"]);
4824
+ await deps.run("git", ["push", "origin", "development"]);
4825
+ return { ...ctx, command, stage: "main", ref: "main", tag, deployModel, dispatch };
4826
+ }
4827
+
4631
4828
  // src/port-registry.ts
4632
4829
  var import_node_fs3 = require("node:fs");
4633
4830
  var BLOCK = 100;
@@ -4919,11 +5116,24 @@ var requiredProjectWorkflows = [
4919
5116
  ];
4920
5117
  var requiredOrgRulesetTypes = ["pull_request", "non_fast_forward", "deletion"];
4921
5118
  var requiredHubStatusChecks = ["cli", "infra", "docs"];
4922
- var requiredActionsVariables = ["MMI_APP_ID"];
4923
- var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
4924
5119
  function expectedBranches(repoClass) {
4925
5120
  return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
4926
5121
  }
5122
+ function gcpProjectForSlug(slug) {
5123
+ return `mutmutco-${slug}`;
5124
+ }
5125
+ function findMissingGcpApis(declared, enabled) {
5126
+ const enabledSet = new Set(enabled.map((a) => a.trim().toLowerCase()).filter(Boolean));
5127
+ const seen = /* @__PURE__ */ new Set();
5128
+ const missing = [];
5129
+ for (const raw of declared) {
5130
+ const api = raw.trim().toLowerCase();
5131
+ if (!api || seen.has(api)) continue;
5132
+ seen.add(api);
5133
+ if (!enabledSet.has(api)) missing.push(api);
5134
+ }
5135
+ return missing;
5136
+ }
4927
5137
  function safeJson2(text, fallback) {
4928
5138
  try {
4929
5139
  return JSON.parse(text);
@@ -5050,16 +5260,6 @@ async function verifyBootstrap(repo, repoClass, deps) {
5050
5260
  checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: presentDetail(strays) });
5051
5261
  const actions = await ghJson2(deps, ["api", `repos/${repo}/actions/permissions`], {});
5052
5262
  checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
5053
- const variables = await ghJson2(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
5054
- const variableNames = new Set(variables.map((v) => v.name));
5055
- for (const variable of requiredActionsVariables) {
5056
- checks.push({ ok: variableNames.has(variable), label: `Actions variable exists: ${variable}` });
5057
- }
5058
- const secrets2 = await ghJson2(deps, ["secret", "list", "--repo", repo, "--json", "name"], []);
5059
- const secretNames = new Set(secrets2.map((s) => s.name));
5060
- for (const secret of requiredActionsSecrets) {
5061
- checks.push({ ok: secretNames.has(secret), label: `Actions secret exists: ${secret}` });
5062
- }
5063
5263
  const config = safeJson2(await contentText(deps, repo, baseBranch, ".mmi/config.json") || "", null);
5064
5264
  checks.push({
5065
5265
  ok: Boolean(config?.projectOwner && config?.projectNumber),
@@ -5170,6 +5370,26 @@ async function verifyBootstrap(repo, repoClass, deps) {
5170
5370
  detail: optionDetail(missing)
5171
5371
  });
5172
5372
  }
5373
+ const declaredApis = (deps.requiredGcpApis ?? []).filter((a) => a && a.trim());
5374
+ if (declaredApis.length > 0) {
5375
+ const slug = (repo.includes("/") ? repo.split("/")[1] : repo).toLowerCase();
5376
+ const gcpProject = gcpProjectForSlug(slug);
5377
+ const enabled = deps.listEnabledGcpApis ? await deps.listEnabledGcpApis(gcpProject) : null;
5378
+ if (enabled == null) {
5379
+ checks.push({
5380
+ ok: false,
5381
+ label: `required GCP APIs enabled in ${gcpProject}`,
5382
+ detail: `could not list enabled APIs (gcloud unavailable, not authed, or no project ${gcpProject})`
5383
+ });
5384
+ } else {
5385
+ const missing = findMissingGcpApis(declaredApis, enabled);
5386
+ checks.push({
5387
+ ok: missing.length === 0,
5388
+ label: `required GCP APIs enabled in ${gcpProject}`,
5389
+ detail: missing.length ? `disabled: ${missing.join(", ")}` : void 0
5390
+ });
5391
+ }
5392
+ }
5173
5393
  const waived = applyWaivers(checks, config?.verifyWaivers ?? []);
5174
5394
  return { ok: waived.every((c) => c.ok || c.waived), repo, class: repoClass, baseBranch, checks: waived };
5175
5395
  }
@@ -5373,8 +5593,8 @@ function resolveKbSource(rawBase) {
5373
5593
  return { owner: m[1], repo: m[2], ref: m[3] };
5374
5594
  }
5375
5595
  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"];
5596
+ const clean2 = path.replace(/^\/+/, "");
5597
+ return ["api", `repos/${src.owner}/${src.repo}/contents/${clean2}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
5378
5598
  }
5379
5599
  function buildKbTreeArgs(src) {
5380
5600
  return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
@@ -5686,6 +5906,46 @@ async function secretsList(deps, opts) {
5686
5906
  const { secrets: secrets2 } = await res.json();
5687
5907
  deps.log(formatSecretList(secrets2 ?? []));
5688
5908
  }
5909
+ var DEFAULT_RUNTIME_SECRET_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
5910
+ function stringList(v) {
5911
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string" && isValidSecretKey(x)) : [];
5912
+ }
5913
+ function requiredRuntimeSecretNames(stage2, contract) {
5914
+ const extra = Array.isArray(contract) ? stringList(contract) : stringList(contract?.[stage2]);
5915
+ return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES, ...extra])];
5916
+ }
5917
+ function stageKey(stage2, key) {
5918
+ return key.includes("/") ? key : `${stage2}/${key}`;
5919
+ }
5920
+ async function secretsPreflight(deps, opts) {
5921
+ const repo = await targetRepo(deps, opts);
5922
+ const qs = new URLSearchParams({ repo }).toString();
5923
+ let res;
5924
+ try {
5925
+ res = await deps.fetch(`${deps.apiUrl}/secrets/list?${qs}`, {
5926
+ method: "GET",
5927
+ headers: await deps.headers(),
5928
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5929
+ });
5930
+ } catch (e) {
5931
+ deps.err(`secrets preflight: ${e.message}`);
5932
+ return false;
5933
+ }
5934
+ if (!res.ok) {
5935
+ deps.err(`secrets preflight failed: HTTP ${res.status}${await readErr(res)}`);
5936
+ return false;
5937
+ }
5938
+ const { secrets: secrets2 } = await res.json();
5939
+ const present = new Set((secrets2 ?? []).map((s) => s.key));
5940
+ const required = opts.required.map((key) => stageKey(opts.stage, key));
5941
+ const missing = required.filter((key) => !present.has(key));
5942
+ if (missing.length) {
5943
+ deps.log(`missing ${missing.join(", ")}`);
5944
+ return false;
5945
+ }
5946
+ deps.log(`all required ${opts.stage} secret names are present`);
5947
+ return true;
5948
+ }
5689
5949
  async function secretsGet(deps, key, opts) {
5690
5950
  if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
5691
5951
  const repo = await targetRepo(deps, opts);
@@ -6272,6 +6532,15 @@ async function probeBackend(url) {
6272
6532
  return false;
6273
6533
  }
6274
6534
  }
6535
+ async function probeSagaAccess(url, key) {
6536
+ try {
6537
+ const qs = new URLSearchParams(key).toString();
6538
+ const res = await fetch(`${url}/saga/state?${qs}`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
6539
+ return res.ok;
6540
+ } catch {
6541
+ return false;
6542
+ }
6543
+ }
6275
6544
  saga.command("health").option("--json", "machine-readable output").option("--banner", "one-line SessionStart banner; silent when healthy").option("--quiet", "suppress detail output").description("zero-write health check: auth, backend reachability, resolved key").action(async (o) => {
6276
6545
  const cfg = await loadConfig();
6277
6546
  const session = resolveSessionId();
@@ -6279,7 +6548,8 @@ saga.command("health").option("--json", "machine-readable output").option("--ban
6279
6548
  const source = session.source;
6280
6549
  const identity = await githubLogin();
6281
6550
  const reachable = cfg.sagaApiUrl ? await probeBackend(cfg.sagaApiUrl) : false;
6282
- const report = buildHealth({ key, source, identity, reachable, sagaApiUrl: cfg.sagaApiUrl });
6551
+ const authorized = cfg.sagaApiUrl && reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
6552
+ const report = buildHealth({ key, source, identity, reachable, authorized, sagaApiUrl: cfg.sagaApiUrl });
6283
6553
  if (o.json) return console.log(JSON.stringify(report));
6284
6554
  if (o.banner) {
6285
6555
  const banner = healthBanner(report);
@@ -6516,6 +6786,22 @@ async function withSecrets(run) {
6516
6786
  var secrets = program2.command("secrets").description("two-tier project secrets \u2014 self-serve your repo dev/* tier; org tier is master-gated");
6517
6787
  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
6788
  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)));
6789
+ 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) => {
6790
+ if (!["dev", "rc", "main"].includes(o.stage)) {
6791
+ return fail("secrets preflight: --stage must be dev, rc, or main");
6792
+ }
6793
+ const cfg = await loadConfig();
6794
+ if (!cfg.sagaApiUrl) {
6795
+ fail("secrets: sagaApiUrl not configured in .mmi/config.json (this repo is not bootstrapped)");
6796
+ return;
6797
+ }
6798
+ const d = makeSecretsDeps(cfg);
6799
+ const repo = o.repo ?? `mutmutco/${await d.slug()}`;
6800
+ const meta = await fetchProjectBySlug(slugOf(repo), registryClientDeps(cfg));
6801
+ const required = o.required?.length ? o.required : requiredRuntimeSecretNames(o.stage, meta?.requiredRuntimeSecrets);
6802
+ const ok = await secretsPreflight(d, { repo: o.repo, stage: o.stage, required });
6803
+ if (!ok) process.exitCode = 1;
6804
+ });
6519
6805
  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
6806
  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
6807
  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)));
@@ -6548,7 +6834,7 @@ project.command("list").description("list all projects (identity + board, never
6548
6834
  return;
6549
6835
  }
6550
6836
  for (const p of projects) {
6551
- console.log(`${p.slug ?? "?"} - ${p.name ?? ""}${p.division ? ` [${p.division}]` : ""}${p.class ? ` (${p.class})` : ""}`);
6837
+ console.log(`${p.slug ?? "?"} - ${p.name ?? ""}${p.division ? ` [${p.division}]` : ""}${p.class ? ` (${p.class})` : ""}${p.deployModel ? ` {${p.deployModel}}` : ""}`);
6552
6838
  }
6553
6839
  });
6554
6840
  project.command("get <owner/repo>").description("a project's META (board ids + pointers) by repo or slug \u2014 identity, NOT deploy coords").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
@@ -6566,7 +6852,7 @@ project.command("resolve <owner/repo>").description("deploy coords for a stage \
6566
6852
  }
6567
6853
  fail(msg);
6568
6854
  });
6569
- project.command("set <owner/repo>").description("MASTER-ONLY: upsert a project META (idempotent merge; no clobber of unspecified fields)").option("--class <class>", "deployable | content").option("--var <KEY=VALUE...>", "META field to set (repeatable): name, division, projectId, branch, wikiRepo, vaultPath, kbPointer").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
6855
+ project.command("set <owner/repo>").description("MASTER-ONLY: upsert a project META (idempotent merge; no clobber of unspecified fields)").option("--class <class>", "deployable | content").option("--deploy-model <model>", "hub-serverless | serverless | tenant-container | content (release-train deploy path, #514)").option("--var <KEY=VALUE...>", "META field to set (repeatable): name, division, projectId, branch, wikiRepo, vaultPath, kbPointer").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
6570
6856
  const cfg = await loadConfig();
6571
6857
  const slug = slugOf(repoOrSlug);
6572
6858
  const patch = {};
@@ -6574,6 +6860,11 @@ project.command("set <owner/repo>").description("MASTER-ONLY: upsert a project M
6574
6860
  if (o.class !== "deployable" && o.class !== "content") return fail("project set: --class must be deployable or content");
6575
6861
  patch.class = o.class;
6576
6862
  }
6863
+ if (o.deployModel) {
6864
+ const models = ["hub-serverless", "serverless", "tenant-container", "content"];
6865
+ if (!models.includes(o.deployModel)) return fail(`project set: --deploy-model must be one of: ${models.join(", ")}`);
6866
+ patch.deployModel = o.deployModel;
6867
+ }
6577
6868
  for (let i = 0; i < process.argv.length - 1; i++) {
6578
6869
  if (process.argv[i] === "--var") {
6579
6870
  const eq = process.argv[i + 1].indexOf("=");
@@ -6660,8 +6951,10 @@ oauth.command("verify").description("probe Google authorize with an arbitrary po
6660
6951
  var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
6661
6952
  issue.command("create").description("create an issue (type \u2192 label) and print {number,url,label} JSON").requiredOption("--type <type>", "bug | feature | task (sets the matching label)").requiredOption("--title <title>", "issue title").requiredOption("--body <body>", "issue body (markdown)").requiredOption("--priority <priority>", "urgent | high | medium | low (label + board Priority field when configured)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--label <label...>", "extra label(s) to attach (repeatable; auto-created if missing)").option("--no-related", "skip the auto related-issues comment").action(async (o) => {
6662
6953
  let args;
6954
+ let priority;
6663
6955
  try {
6664
- args = buildIssueArgs({ type: o.type, title: o.title, body: o.body, priority: o.priority, repo: o.repo, labels: o.label });
6956
+ priority = normalizePriority(o.priority);
6957
+ args = buildIssueArgs({ type: o.type, title: o.title, body: o.body, priority, repo: o.repo, labels: o.label });
6665
6958
  } catch (e) {
6666
6959
  return fail(`issue create: ${e.message}`);
6667
6960
  }
@@ -6674,9 +6967,9 @@ issue.command("create").description("create an issue (type \u2192 label) and pri
6674
6967
  }
6675
6968
  }
6676
6969
  const created = await ghCreate(args);
6677
- const projectItemId = await attachToProject(created.number, o.repo, o.priority);
6970
+ const projectItemId = await attachToProject(created.number, o.repo, priority);
6678
6971
  if (o.related !== false) scheduleRelatedDiscovery({ repo: o.repo, number: created.number, title: o.title, body: o.body });
6679
- console.log(JSON.stringify({ ...created, label: o.type, priority: o.priority, projectItemId }));
6972
+ console.log(JSON.stringify({ ...created, label: o.type, priority, projectItemId }));
6680
6973
  });
6681
6974
  issue.command("discover-related").description("find related issues for an existing issue and post only high-confidence links").requiredOption("--number <number>", "created issue number").requiredOption("--title <title>", "created issue title").requiredOption("--body <body>", "created issue body").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "print candidates instead of posting").action(async (o) => {
6682
6975
  const number = Number(o.number);
@@ -6918,13 +7211,25 @@ stage.command("run").description("force-stop previous stage, build, start, and h
6918
7211
  }
6919
7212
  });
6920
7213
  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");
7214
+ 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
7215
  const steps = stageLivePlan();
6923
7216
  console.log(o.json ? JSON.stringify({ command: "stage-live", steps }, null, 2) : renderSteps("mmi-cli stage-live: not an org command", steps));
6924
7217
  });
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`);
7218
+ for (const commandName of ["rcand", "release", "hotfix"]) {
7219
+ 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) => {
7220
+ if (o.apply) {
7221
+ if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
7222
+ try {
7223
+ const result = await runTrainApply(commandName, {
7224
+ run: async (file, args) => (await execFileP3(file, args, { timeout: file === "gh" ? 3e4 : GIT_TIMEOUT_MS })).stdout,
7225
+ runSelf: async (args) => (await execFileP3(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout
7226
+ });
7227
+ const message = `mmi-cli ${commandName}: applied ${result.repo} ${result.stage} train at ${result.tag} [${result.deployModel}]; ${result.dispatch}`;
7228
+ return printLine(o.json ? JSON.stringify(result, null, 2) : message);
7229
+ } catch (e) {
7230
+ return fail(`${commandName}: ${e.message}`);
7231
+ }
7232
+ }
6928
7233
  const steps = trainPlan(commandName);
6929
7234
  console.log(o.json ? JSON.stringify({ command: commandName, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps));
6930
7235
  });
@@ -6941,9 +7246,31 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
6941
7246
  if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
6942
7247
  const cfg = await loadConfig();
6943
7248
  const apiProjects = await fetchProjectsJson({ baseUrl: cfg.sagaApiUrl, token: githubToken });
7249
+ const slug = (repo.includes("/") ? repo.split("/")[1] : repo).toLowerCase();
7250
+ const meta = await fetchProjectBySlug(slug, { baseUrl: cfg.sagaApiUrl, token: githubToken });
6944
7251
  const report = await verifyBootstrap(repo, o.class, {
6945
7252
  gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
6946
- readLocalFile: (path) => path === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : null
7253
+ readLocalFile: (path) => path === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : null,
7254
+ // requiredGcpApis is stored as an array by a JSON write, but `project set --var KEY=VALUE` stores a raw
7255
+ // comma-string — accept either so the seeded value verifies regardless of how it was written.
7256
+ requiredGcpApis: (() => {
7257
+ const v = meta?.requiredGcpApis;
7258
+ if (Array.isArray(v)) return v;
7259
+ if (typeof v === "string") return v.split(",").map((s) => s.trim()).filter(Boolean);
7260
+ return void 0;
7261
+ })(),
7262
+ listEnabledGcpApis: async (gcpProject) => {
7263
+ try {
7264
+ const { stdout } = await execFileP3(
7265
+ "gcloud",
7266
+ ["services", "list", "--enabled", "--project", gcpProject, "--format", "value(config.name)"],
7267
+ { timeout: 3e4 }
7268
+ );
7269
+ return stdout.split("\n").map((l) => l.trim()).filter(Boolean);
7270
+ } catch {
7271
+ return null;
7272
+ }
7273
+ }
6947
7274
  });
6948
7275
  console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
6949
7276
  if (!report.ok) process.exitCode = 1;
@@ -7100,6 +7427,21 @@ function writeProjectInstallRecord(record) {
7100
7427
  return false;
7101
7428
  }
7102
7429
  }
7430
+ function backupAndWriteInstalledPlugins(records, pluginId) {
7431
+ try {
7432
+ const file = readInstalledPlugins();
7433
+ if (!file) return false;
7434
+ if (!file.plugins) file.plugins = {};
7435
+ const path = installedPluginsPath();
7436
+ (0, import_node_fs4.copyFileSync)(path, `${path}.bak`);
7437
+ file.plugins[pluginId] = records;
7438
+ (0, import_node_fs4.writeFileSync)(path, `${JSON.stringify(file, null, 2)}
7439
+ `, "utf8");
7440
+ return true;
7441
+ } catch {
7442
+ return false;
7443
+ }
7444
+ }
7103
7445
  var gitignorePath = () => (0, import_node_path4.join)(process.cwd(), ".gitignore");
7104
7446
  function readGitignore() {
7105
7447
  try {
@@ -7116,7 +7458,12 @@ function writeGitignore(content) {
7116
7458
  return false;
7117
7459
  }
7118
7460
  }
7119
- program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone, plugin install record, .gitignore managed block) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--json", "machine-readable output").action(async (opts) => {
7461
+ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone, plugin install record, .gitignore managed block, plugin config drift) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output").action(async (opts) => {
7462
+ if (opts.guide) {
7463
+ if (opts.json) console.log(JSON.stringify({ resources: [MMI_AGENTIC_ONBOARDING_GUIDE] }, null, 2));
7464
+ else console.log(MMI_AGENTIC_ONBOARDING_GUIDE.url);
7465
+ return;
7466
+ }
7120
7467
  const checks = [];
7121
7468
  const login = await githubLogin();
7122
7469
  let ghInstalled = true;
@@ -7189,17 +7536,27 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
7189
7536
  }
7190
7537
  }
7191
7538
  checks.push(gitignoreCheck);
7539
+ let driftCheck = buildPluginConfigDriftCheck({ isOrgRepo: Boolean(cfg.sagaApiUrl), installed });
7540
+ if (!driftCheck.ok && driftCheck.recordsToWrite && !opts.json) {
7541
+ if (backupAndWriteInstalledPlugins(driftCheck.recordsToWrite, driftCheck.pluginId)) {
7542
+ driftCheck = { ...driftCheck, ok: true };
7543
+ if (!opts.banner) console.error(" \u21BB repaired: collapsed mmi@mmi to one user-scope entry (backup at installed_plugins.json.bak) \u2014 run /reload-plugins");
7544
+ }
7545
+ }
7546
+ checks.push(driftCheck);
7192
7547
  const gaps = checks.filter((c) => !c.ok);
7548
+ const resources = doctorResourcesForGaps(gaps);
7193
7549
  if (opts.json) {
7194
- console.log(JSON.stringify({ ok: gaps.length === 0, checks }, null, 2));
7550
+ console.log(JSON.stringify({ ok: gaps.length === 0, checks, ...resources.length ? { resources } : {} }, null, 2));
7195
7551
  return;
7196
7552
  }
7197
7553
  if (opts.banner) {
7198
- if (gaps.length) console.log(`\u26A0 MMI setup needed \u2014 ${gaps.map((g) => g.fix).join(" \xB7 ")}`);
7554
+ if (gaps.length) console.log(`\u26A0 MMI setup needed \u2014 ${gaps.map((g) => g.fix).join(" \xB7 ")} \xB7 guide: ${MMI_AGENTIC_ONBOARDING_GUIDE.url}`);
7199
7555
  return;
7200
7556
  }
7201
7557
  for (const c of checks) console.log(c.ok ? `\u2713 ${c.label}` : `\u2717 ${c.label}
7202
7558
  \u2192 ${c.fix}`);
7559
+ for (const r of resources) console.log(`Resource: ${r.label} \u2014 ${r.url}`);
7203
7560
  console.log(gaps.length ? `
7204
7561
  ${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
7205
7562
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.0.0",
3
+ "version": "2.3.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",