@mutmutco/cli 2.1.0 → 2.5.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/index.cjs +229 -51
  2. package/package.json +1 -1
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
@@ -4412,6 +4419,13 @@ ${block}
4412
4419
  // src/doctor.ts
4413
4420
  var GH_PROJECT_LOGIN_FIX = 'run: gh auth login --hostname github.com --git-protocol https --web --scopes "project"';
4414
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
+ }
4415
4429
  function buildGithubAuthCheck(input) {
4416
4430
  const ok = Boolean(input.login?.trim());
4417
4431
  return {
@@ -4467,6 +4481,45 @@ function buildPluginInstallRecordCheck(input) {
4467
4481
  recordToInsert
4468
4482
  };
4469
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
+ }
4470
4523
  var GITIGNORE_BLOCK_LABEL = "org .gitignore managed block (.playwright-mcp/, .claude/worktrees/, scratch *.png)";
4471
4524
  var GITIGNORE_BLOCK_FIX = "run `mmi-cli doctor` to auto-insert the `# >>> mmi-managed >>>` block (or copy it from MMI-Hub's .gitignore)";
4472
4525
  function buildGitignoreManagedBlockCheck(input) {
@@ -4635,6 +4688,13 @@ async function runStage(config = {}, opts = {}) {
4635
4688
  }
4636
4689
 
4637
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
+ }
4638
4698
  function clean(out) {
4639
4699
  return out.trim();
4640
4700
  }
@@ -4642,6 +4702,13 @@ function requireValue(value, label) {
4642
4702
  if (!value) throw new Error(`${label} could not be resolved`);
4643
4703
  return value;
4644
4704
  }
4705
+ function releaseTagFromRcTag(tag) {
4706
+ return tag.replace(/-rc\.\d+$/, "");
4707
+ }
4708
+ async function verifyHubDistributionVersion(deps, model, releaseTag) {
4709
+ if (model !== "hub-serverless") return;
4710
+ await deps.run("node", ["scripts/release-distribution.mjs", "verify", releaseTag, "--skip-npm-view"]);
4711
+ }
4645
4712
  function ensurePositiveCount(out, emptyMessage) {
4646
4713
  const count = Number.parseInt(out.trim(), 10);
4647
4714
  if (!Number.isFinite(count)) throw new Error(`could not parse ahead count: ${out.trim() || "(empty)"}`);
@@ -4674,26 +4741,43 @@ async function requireBranch(deps, branch) {
4674
4741
  const current = clean(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
4675
4742
  if (current !== branch) throw new Error(`must run from ${branch}, currently on ${current || "(unknown)"}`);
4676
4743
  }
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
- ]);
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`;
4693
4767
  }
4694
4768
  async function preflight(deps, ctx, stage2) {
4695
- await deps.runSelf(["project", "get", ctx.repo]);
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
+ }
4696
4779
  await deps.runSelf(["secrets", "preflight", "--stage", stage2, "--repo", ctx.repo]);
4780
+ return model;
4697
4781
  }
4698
4782
  async function runTrainApply(command, deps) {
4699
4783
  const ctx = await buildTrainApplyContext(deps);
@@ -4706,37 +4790,39 @@ async function runTrainApply(command, deps) {
4706
4790
  await deps.run("git", ["rev-list", "--count", "origin/rc..origin/development"]),
4707
4791
  "nothing to promote: origin/development is not ahead of origin/rc"
4708
4792
  );
4709
- await preflight(deps, ctx, "rc");
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 verifyHubDistributionVersion(deps, deployModel2, releaseTagFromRcTag(tag2));
4710
4796
  await deps.run("git", ["checkout", "rc"]);
4711
4797
  await deps.run("git", ["pull", "--ff-only", "origin", "rc"]);
4712
4798
  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
4799
  await deps.run("git", ["tag", tag2]);
4715
4800
  await deps.run("git", ["push", "origin", "rc"]);
4716
4801
  await deps.run("git", ["push", "origin", tag2]);
4717
- await dispatchDeploy(deps, ctx, "rc", "rc");
4718
- return { ...ctx, command, stage: "rc", ref: "rc", tag: 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 };
4719
4804
  }
4720
4805
  await requireBranch(deps, "rc");
4721
4806
  ensurePositiveCount(
4722
4807
  await deps.run("git", ["rev-list", "--count", "origin/main..origin/rc"]),
4723
4808
  "nothing to release: origin/rc is not ahead of origin/main"
4724
4809
  );
4725
- await preflight(deps, ctx, "main");
4810
+ const deployModel = await preflight(deps, ctx, "main");
4726
4811
  await deps.run("git", ["checkout", "main"]);
4727
4812
  await deps.run("git", ["pull", "--ff-only", "origin", "main"]);
4728
4813
  await deps.run("git", ["merge", "rc", "--no-edit"]);
4729
4814
  const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
4815
+ await verifyHubDistributionVersion(deps, deployModel, tag);
4730
4816
  await deps.run("git", ["tag", tag]);
4731
4817
  await deps.run("git", ["push", "origin", "main"]);
4732
4818
  await deps.run("git", ["push", "origin", tag]);
4733
4819
  await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
4734
- await dispatchDeploy(deps, ctx, "main", "main");
4820
+ const dispatch = await dispatchDeploy(deps, ctx, "main", "main", deployModel);
4735
4821
  await deps.run("git", ["checkout", "development"]);
4736
4822
  await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
4737
4823
  await deps.run("git", ["merge", "main", "--no-edit"]);
4738
4824
  await deps.run("git", ["push", "origin", "development"]);
4739
- return { ...ctx, command, stage: "main", ref: "main", tag };
4825
+ return { ...ctx, command, stage: "main", ref: "main", tag, deployModel, dispatch };
4740
4826
  }
4741
4827
 
4742
4828
  // src/port-registry.ts
@@ -5030,11 +5116,24 @@ var requiredProjectWorkflows = [
5030
5116
  ];
5031
5117
  var requiredOrgRulesetTypes = ["pull_request", "non_fast_forward", "deletion"];
5032
5118
  var requiredHubStatusChecks = ["cli", "infra", "docs"];
5033
- var requiredActionsVariables = ["MMI_APP_ID"];
5034
- var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
5035
5119
  function expectedBranches(repoClass) {
5036
5120
  return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
5037
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
+ }
5038
5137
  function safeJson2(text, fallback) {
5039
5138
  try {
5040
5139
  return JSON.parse(text);
@@ -5161,16 +5260,6 @@ async function verifyBootstrap(repo, repoClass, deps) {
5161
5260
  checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: presentDetail(strays) });
5162
5261
  const actions = await ghJson2(deps, ["api", `repos/${repo}/actions/permissions`], {});
5163
5262
  checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
5164
- const variables = await ghJson2(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
5165
- const variableNames = new Set(variables.map((v) => v.name));
5166
- for (const variable of requiredActionsVariables) {
5167
- checks.push({ ok: variableNames.has(variable), label: `Actions variable exists: ${variable}` });
5168
- }
5169
- const secrets2 = await ghJson2(deps, ["secret", "list", "--repo", repo, "--json", "name"], []);
5170
- const secretNames = new Set(secrets2.map((s) => s.name));
5171
- for (const secret of requiredActionsSecrets) {
5172
- checks.push({ ok: secretNames.has(secret), label: `Actions secret exists: ${secret}` });
5173
- }
5174
5263
  const config = safeJson2(await contentText(deps, repo, baseBranch, ".mmi/config.json") || "", null);
5175
5264
  checks.push({
5176
5265
  ok: Boolean(config?.projectOwner && config?.projectNumber),
@@ -5281,6 +5370,26 @@ async function verifyBootstrap(repo, repoClass, deps) {
5281
5370
  detail: optionDetail(missing)
5282
5371
  });
5283
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
+ }
5284
5393
  const waived = applyWaivers(checks, config?.verifyWaivers ?? []);
5285
5394
  return { ok: waived.every((c) => c.ok || c.waived), repo, class: repoClass, baseBranch, checks: waived };
5286
5395
  }
@@ -6423,6 +6532,15 @@ async function probeBackend(url) {
6423
6532
  return false;
6424
6533
  }
6425
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
+ }
6426
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) => {
6427
6545
  const cfg = await loadConfig();
6428
6546
  const session = resolveSessionId();
@@ -6430,7 +6548,8 @@ saga.command("health").option("--json", "machine-readable output").option("--ban
6430
6548
  const source = session.source;
6431
6549
  const identity = await githubLogin();
6432
6550
  const reachable = cfg.sagaApiUrl ? await probeBackend(cfg.sagaApiUrl) : false;
6433
- 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 });
6434
6553
  if (o.json) return console.log(JSON.stringify(report));
6435
6554
  if (o.banner) {
6436
6555
  const banner = healthBanner(report);
@@ -6715,7 +6834,7 @@ project.command("list").description("list all projects (identity + board, never
6715
6834
  return;
6716
6835
  }
6717
6836
  for (const p of projects) {
6718
- 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}}` : ""}`);
6719
6838
  }
6720
6839
  });
6721
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) => {
@@ -6733,7 +6852,7 @@ project.command("resolve <owner/repo>").description("deploy coords for a stage \
6733
6852
  }
6734
6853
  fail(msg);
6735
6854
  });
6736
- 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) => {
6737
6856
  const cfg = await loadConfig();
6738
6857
  const slug = slugOf(repoOrSlug);
6739
6858
  const patch = {};
@@ -6741,6 +6860,11 @@ project.command("set <owner/repo>").description("MASTER-ONLY: upsert a project M
6741
6860
  if (o.class !== "deployable" && o.class !== "content") return fail("project set: --class must be deployable or content");
6742
6861
  patch.class = o.class;
6743
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
+ }
6744
6868
  for (let i = 0; i < process.argv.length - 1; i++) {
6745
6869
  if (process.argv[i] === "--var") {
6746
6870
  const eq = process.argv[i + 1].indexOf("=");
@@ -6827,8 +6951,10 @@ oauth.command("verify").description("probe Google authorize with an arbitrary po
6827
6951
  var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
6828
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) => {
6829
6953
  let args;
6954
+ let priority;
6830
6955
  try {
6831
- 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 });
6832
6958
  } catch (e) {
6833
6959
  return fail(`issue create: ${e.message}`);
6834
6960
  }
@@ -6841,9 +6967,9 @@ issue.command("create").description("create an issue (type \u2192 label) and pri
6841
6967
  }
6842
6968
  }
6843
6969
  const created = await ghCreate(args);
6844
- const projectItemId = await attachToProject(created.number, o.repo, o.priority);
6970
+ const projectItemId = await attachToProject(created.number, o.repo, priority);
6845
6971
  if (o.related !== false) scheduleRelatedDiscovery({ repo: o.repo, number: created.number, title: o.title, body: o.body });
6846
- console.log(JSON.stringify({ ...created, label: o.type, priority: o.priority, projectItemId }));
6972
+ console.log(JSON.stringify({ ...created, label: o.type, priority, projectItemId }));
6847
6973
  });
6848
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) => {
6849
6975
  const number = Number(o.number);
@@ -7098,7 +7224,7 @@ for (const commandName of ["rcand", "release", "hotfix"]) {
7098
7224
  run: async (file, args) => (await execFileP3(file, args, { timeout: file === "gh" ? 3e4 : GIT_TIMEOUT_MS })).stdout,
7099
7225
  runSelf: async (args) => (await execFileP3(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout
7100
7226
  });
7101
- const message = `mmi-cli ${commandName}: applied ${result.repo} ${result.stage} train at ${result.tag}; dispatched ${result.ref} deploy`;
7227
+ const message = `mmi-cli ${commandName}: applied ${result.repo} ${result.stage} train at ${result.tag} [${result.deployModel}]; ${result.dispatch}`;
7102
7228
  return printLine(o.json ? JSON.stringify(result, null, 2) : message);
7103
7229
  } catch (e) {
7104
7230
  return fail(`${commandName}: ${e.message}`);
@@ -7120,9 +7246,31 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
7120
7246
  if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
7121
7247
  const cfg = await loadConfig();
7122
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 });
7123
7251
  const report = await verifyBootstrap(repo, o.class, {
7124
7252
  gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
7125
- 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
+ }
7126
7274
  });
7127
7275
  console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
7128
7276
  if (!report.ok) process.exitCode = 1;
@@ -7279,6 +7427,21 @@ function writeProjectInstallRecord(record) {
7279
7427
  return false;
7280
7428
  }
7281
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
+ }
7282
7445
  var gitignorePath = () => (0, import_node_path4.join)(process.cwd(), ".gitignore");
7283
7446
  function readGitignore() {
7284
7447
  try {
@@ -7295,7 +7458,12 @@ function writeGitignore(content) {
7295
7458
  return false;
7296
7459
  }
7297
7460
  }
7298
- 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
+ }
7299
7467
  const checks = [];
7300
7468
  const login = await githubLogin();
7301
7469
  let ghInstalled = true;
@@ -7368,17 +7536,27 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
7368
7536
  }
7369
7537
  }
7370
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);
7371
7547
  const gaps = checks.filter((c) => !c.ok);
7548
+ const resources = doctorResourcesForGaps(gaps);
7372
7549
  if (opts.json) {
7373
- 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));
7374
7551
  return;
7375
7552
  }
7376
7553
  if (opts.banner) {
7377
- 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}`);
7378
7555
  return;
7379
7556
  }
7380
7557
  for (const c of checks) console.log(c.ok ? `\u2713 ${c.label}` : `\u2717 ${c.label}
7381
7558
  \u2192 ${c.fix}`);
7559
+ for (const r of resources) console.log(`Resource: ${r.label} \u2014 ${r.url}`);
7382
7560
  console.log(gaps.length ? `
7383
7561
  ${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
7384
7562
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.1.0",
3
+ "version": "2.5.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",