@mutmutco/cli 2.13.0 → 2.14.1

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 +221 -15
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -5945,7 +5945,10 @@ async function startStage(config = {}, opts = {}) {
5945
5945
  const child = (0, import_node_child_process5.spawn)(up, {
5946
5946
  cwd,
5947
5947
  shell: true,
5948
- detached: true,
5948
+ // POSIX-only: the process group exists for the group-kill in stopStage. On win32 teardown is
5949
+ // `taskkill /T /F` (no group needed), and detached+shell defeats windowsHide — every spawn would
5950
+ // flash a Windows Terminal window (0x800700e8) on dev machines.
5951
+ detached: process.platform !== "win32",
5949
5952
  windowsHide: true,
5950
5953
  stdio: "ignore",
5951
5954
  env: { ...process.env, ...stagePort != null ? { STAGE_PORT: String(stagePort) } : {}, ...extraEnv }
@@ -6040,6 +6043,41 @@ async function verifyHubDistributionVersion(deps, model, releaseTag) {
6040
6043
  if (model !== "hub-serverless") return;
6041
6044
  await deps.run("node", ["scripts/release-distribution.mjs", "verify", releaseTag, "--skip-npm-view"]);
6042
6045
  }
6046
+ var ORG_SPINE_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", ".claude/settings.json"];
6047
+ function isSpinePath(path2) {
6048
+ return ORG_SPINE_FILES.includes(path2);
6049
+ }
6050
+ async function predictMergeConflicts(deps, ours, theirs) {
6051
+ try {
6052
+ await deps.run("git", ["merge-tree", "--write-tree", "--name-only", "--no-messages", ours, theirs]);
6053
+ return [];
6054
+ } catch (e) {
6055
+ const out = String(e.stdout ?? "");
6056
+ const files = out.split("\n").map((s) => s.trim()).filter(Boolean).slice(1);
6057
+ if (files.length === 0) {
6058
+ throw new Error(`could not preflight the ${theirs} -> ${ours} merge (git merge-tree failed: ${e.message ?? e})`);
6059
+ }
6060
+ return files;
6061
+ }
6062
+ }
6063
+ async function mergeRcWithSpineResolution(deps) {
6064
+ try {
6065
+ await deps.run("git", ["merge", "rc", "--no-edit"]);
6066
+ return;
6067
+ } catch {
6068
+ }
6069
+ const unmerged = (await deps.run("git", ["diff", "--name-only", "--diff-filter=U"])).split("\n").map((s) => s.trim()).filter(Boolean);
6070
+ const nonSpine = unmerged.filter((f) => !isSpinePath(f));
6071
+ if (unmerged.length === 0 || nonSpine.length > 0) {
6072
+ await deps.run("git", ["merge", "--abort"]);
6073
+ throw new Error(
6074
+ unmerged.length === 0 ? "rc -> main merge failed without conflicted paths \u2014 merge aborted; inspect the repo state and rerun" : `rc -> main merge conflicts on non-spine path(s): ${nonSpine.join(", ")} \u2014 merge aborted (the train is misaligned; reconcile main and rc via an approved alignment PR, then rerun release)`
6075
+ );
6076
+ }
6077
+ await deps.run("git", ["checkout", "--theirs", "--", ...unmerged]);
6078
+ await deps.run("git", ["add", "--", ...unmerged]);
6079
+ await deps.run("git", ["commit", "--no-edit"]);
6080
+ }
6043
6081
  function ensurePositiveCount(out, emptyMessage) {
6044
6082
  const count = Number.parseInt(out.trim(), 10);
6045
6083
  if (!Number.isFinite(count)) throw new Error(`could not parse ahead count: ${out.trim() || "(empty)"}`);
@@ -6077,6 +6115,11 @@ var HUB_REPO2 = "mutmutco/MMI-Hub";
6077
6115
  var CORRELATE_ATTEMPTS = 5;
6078
6116
  var CORRELATE_DELAY_MS = 1500;
6079
6117
  var CORRELATE_SKEW_SLACK_MS = 1e4;
6118
+ var TRAIN_REQUIRED_CHECKS = ["cli", "infra", "docs"];
6119
+ var TRAIN_REQUIRED_CHECK_SET = new Set(TRAIN_REQUIRED_CHECKS);
6120
+ var TRAIN_CHECKS_JQ = '[.check_runs[]|select(.name|test("^(cli|infra|docs)$"))|{(.name):.conclusion}]';
6121
+ var TRAIN_CHECK_ATTEMPTS = 40;
6122
+ var TRAIN_CHECK_DELAY_MS = 15e3;
6080
6123
  async function correlateTenantRun(deps, since) {
6081
6124
  const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
6082
6125
  const threshold = since - CORRELATE_SKEW_SLACK_MS;
@@ -6114,6 +6157,51 @@ async function watchTenantRun(deps, runId) {
6114
6157
  return "failure";
6115
6158
  }
6116
6159
  }
6160
+ function parseTrainCheckConclusions(out) {
6161
+ const parsed = JSON.parse(out);
6162
+ if (!Array.isArray(parsed)) throw new Error("check-runs response was not an array");
6163
+ const conclusions = /* @__PURE__ */ new Map();
6164
+ for (const row of parsed) {
6165
+ if (row == null || typeof row !== "object") continue;
6166
+ for (const [name, raw] of Object.entries(row)) {
6167
+ if (!TRAIN_REQUIRED_CHECK_SET.has(name)) continue;
6168
+ conclusions.set(name, raw == null ? null : String(raw));
6169
+ }
6170
+ }
6171
+ return conclusions;
6172
+ }
6173
+ function describeTrainChecks(conclusions) {
6174
+ return TRAIN_REQUIRED_CHECKS.map((name) => `${name}=${conclusions.get(name) ?? "missing"}`).join(", ");
6175
+ }
6176
+ async function waitForRequiredTrainChecks(deps, ctx, sha) {
6177
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
6178
+ let lastStatus = "not checked";
6179
+ let lastError;
6180
+ for (let attempt = 0; attempt < TRAIN_CHECK_ATTEMPTS; attempt++) {
6181
+ if (attempt > 0) await sleep(TRAIN_CHECK_DELAY_MS);
6182
+ let conclusions;
6183
+ try {
6184
+ const out = await deps.run("gh", ["api", `repos/${ctx.repo}/commits/${sha}/check-runs`, "--jq", TRAIN_CHECKS_JQ]);
6185
+ conclusions = parseTrainCheckConclusions(out);
6186
+ lastError = void 0;
6187
+ } catch (e) {
6188
+ lastError = e.message || String(e);
6189
+ continue;
6190
+ }
6191
+ lastStatus = describeTrainChecks(conclusions);
6192
+ const failed = TRAIN_REQUIRED_CHECKS.filter((name) => {
6193
+ const conclusion = conclusions.get(name);
6194
+ return conclusion != null && conclusion !== "success";
6195
+ });
6196
+ if (failed.length > 0) {
6197
+ throw new Error(`required train check failed: ${failed.join(", ")} (${lastStatus})`);
6198
+ }
6199
+ if (TRAIN_REQUIRED_CHECKS.every((name) => conclusions.get(name) === "success")) return;
6200
+ }
6201
+ throw new Error(
6202
+ `timed out waiting for required train checks on ${sha}: ${lastError ? `last error: ${lastError}` : lastStatus}`
6203
+ );
6204
+ }
6117
6205
  async function dispatchDeploy(deps, ctx, stage2, ref, model, watch) {
6118
6206
  if (model === "tenant-container") {
6119
6207
  const since = (deps.now ?? Date.now)();
@@ -6180,8 +6268,10 @@ async function runTrainApply(command, deps, options = {}) {
6180
6268
  await deps.run("git", ["pull", "--ff-only", "origin", "rc"]);
6181
6269
  await deps.run("git", ["merge", "development", "--no-edit"]);
6182
6270
  await deps.run("git", ["tag", tag2]);
6183
- await deps.run("git", ["push", "origin", "rc"]);
6184
6271
  await deps.run("git", ["push", "origin", tag2]);
6272
+ const rcSha = requireValue(clean(await deps.run("git", ["rev-parse", "rc"])), "rc sha");
6273
+ await waitForRequiredTrainChecks(deps, ctx, rcSha);
6274
+ await deps.run("git", ["push", "origin", "rc"]);
6185
6275
  const d2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch);
6186
6276
  return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, deployStatus: d2.deployStatus };
6187
6277
  }
@@ -6191,21 +6281,86 @@ async function runTrainApply(command, deps, options = {}) {
6191
6281
  "nothing to release: origin/rc is not ahead of origin/main"
6192
6282
  );
6193
6283
  const deployModel = await preflight(deps, ctx, "main");
6284
+ const predicted = await predictMergeConflicts(deps, "origin/main", "origin/rc");
6285
+ const predictedNonSpine = predicted.filter((f) => !isSpinePath(f));
6286
+ if (predictedNonSpine.length > 0) {
6287
+ throw new Error(
6288
+ `rc -> main merge would conflict on non-spine path(s): ${predictedNonSpine.join(", ")} \u2014 no merge was started. The train is misaligned: reconcile main and rc via an approved alignment PR (do not hand-resolve on main), then rerun release.`
6289
+ );
6290
+ }
6291
+ const releasedRcSha = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
6194
6292
  await deps.run("git", ["checkout", "main"]);
6195
6293
  await deps.run("git", ["pull", "--ff-only", "origin", "main"]);
6196
- await deps.run("git", ["merge", "rc", "--no-edit"]);
6294
+ if (predicted.length === 0) {
6295
+ await deps.run("git", ["merge", "rc", "--no-edit"]);
6296
+ } else {
6297
+ await mergeRcWithSpineResolution(deps);
6298
+ }
6197
6299
  const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
6198
6300
  await verifyHubDistributionVersion(deps, deployModel, tag);
6199
6301
  await deps.run("git", ["tag", tag]);
6200
- await deps.run("git", ["push", "origin", "main"]);
6201
6302
  await deps.run("git", ["push", "origin", tag]);
6303
+ const releaseSha = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
6304
+ await waitForRequiredTrainChecks(deps, ctx, releaseSha);
6305
+ await deps.run("git", ["push", "origin", "main"]);
6202
6306
  await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
6203
6307
  const d = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch);
6308
+ const retirement = await retireRcRuntime(deps, ctx, deployModel, d.deployStatus, releasedRcSha);
6204
6309
  await deps.run("git", ["checkout", "development"]);
6205
6310
  await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
6206
6311
  await deps.run("git", ["merge", "main", "--no-edit"]);
6207
6312
  await deps.run("git", ["push", "origin", "development"]);
6208
- return { ...ctx, command, stage: "main", ref: "main", tag, deployModel, promoted: true, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, deployStatus: d.deployStatus };
6313
+ return {
6314
+ ...ctx,
6315
+ command,
6316
+ stage: "main",
6317
+ ref: "main",
6318
+ tag,
6319
+ deployModel,
6320
+ promoted: true,
6321
+ dispatch: d.note,
6322
+ runId: d.runId,
6323
+ runUrl: d.runUrl,
6324
+ deployStatus: d.deployStatus,
6325
+ rcRetirement: retirement.status,
6326
+ rcRetirementNote: retirement.note
6327
+ };
6328
+ }
6329
+ async function retireRcRuntime(deps, ctx, model, deployStatus, releasedRcSha) {
6330
+ if (model !== "tenant-container") {
6331
+ return { status: "not-applicable", note: `${model} has no co-resident rc runtime to retire` };
6332
+ }
6333
+ if (deployStatus === "failure") {
6334
+ return { status: "skipped", note: "prod deploy failed \u2014 rc runtime left untouched" };
6335
+ }
6336
+ if (deployStatus !== "success") {
6337
+ return {
6338
+ status: "skipped",
6339
+ note: `prod deploy outcome unconfirmed (run without --watch) \u2014 rc runtime left untouched; after verifying prod, retire with: mmi-cli tenant control ${ctx.repo} rc retire`
6340
+ };
6341
+ }
6342
+ try {
6343
+ await deps.run("git", ["fetch", "origin", "rc"]);
6344
+ const rcNow = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
6345
+ if (rcNow !== releasedRcSha) {
6346
+ return {
6347
+ status: "skipped",
6348
+ note: `origin/rc moved past the released candidate (${releasedRcSha.slice(0, 7)} -> ${rcNow.slice(0, 7)}) \u2014 a new candidate is in flight; rc runtime left untouched`
6349
+ };
6350
+ }
6351
+ const out = await deps.runSelf(["tenant", "control", ctx.repo, "rc", "retire"]);
6352
+ let commandId = "";
6353
+ try {
6354
+ commandId = String(JSON.parse(out).commandId ?? "");
6355
+ } catch {
6356
+ }
6357
+ return {
6358
+ status: "retired",
6359
+ note: `rc runtime retired (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept; /rcand or tenant redeploy recreates rc next cycle`
6360
+ };
6361
+ } catch (e) {
6362
+ return { status: "failed", note: `rc retirement failed (the release itself succeeded): ${e.message}` };
6363
+ }
6209
6364
  }
6210
6365
  async function runTenantRedeploy(deps, options) {
6211
6366
  const { stage: stage2 } = options;
@@ -8117,14 +8272,16 @@ async function secretsRequest(deps, key, opts) {
8117
8272
  deps.log(body.notified === false ? "admin notification not sent" : "admin notification sent");
8118
8273
  return true;
8119
8274
  }
8120
- async function secretsSet(deps, key, opts) {
8121
- if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
8122
- const repo = await targetRepo(deps, opts);
8123
- const value = await deps.readSecretValue(`value for ${key} (input hidden; will not be echoed): `);
8275
+ async function putSecret(deps, key, value, opts) {
8276
+ if (!isValidSecretKey(key)) {
8277
+ deps.err(`invalid secret key ${JSON.stringify(key)}`);
8278
+ return false;
8279
+ }
8124
8280
  if (!value) {
8125
- deps.err("secrets set: empty value \u2014 aborted (nothing written)");
8126
- return;
8281
+ deps.err(`secrets set: empty value for ${key} \u2014 aborted (nothing written)`);
8282
+ return false;
8127
8283
  }
8284
+ const repo = await targetRepo(deps, opts);
8128
8285
  const res = await deps.fetch(`${deps.apiUrl}/secrets/set`, {
8129
8286
  method: "POST",
8130
8287
  headers: await deps.headers({ "content-type": "application/json" }),
@@ -8135,9 +8292,19 @@ async function secretsSet(deps, key, opts) {
8135
8292
  deps.err(
8136
8293
  res.status === 403 ? `secrets set: not authorized to write ${key} (HTTP 403)${await readErr(res)}` : `secrets set failed: HTTP ${res.status}${await readErr(res)}`
8137
8294
  );
8138
- return;
8295
+ return false;
8139
8296
  }
8140
8297
  deps.log(`set ${key} (${classifyTier(await vaultSlug(deps, opts), key)} tier)`);
8298
+ return true;
8299
+ }
8300
+ async function secretsSet(deps, key, opts) {
8301
+ if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
8302
+ const value = await deps.readSecretValue(`value for ${key} (input hidden; will not be echoed): `);
8303
+ if (!value) {
8304
+ deps.err("secrets set: empty value \u2014 aborted (nothing written)");
8305
+ return;
8306
+ }
8307
+ await putSecret(deps, key, value, opts);
8141
8308
  }
8142
8309
  async function secretsEdit(deps, key, opts) {
8143
8310
  return secretsSet(deps, key, opts);
@@ -8241,6 +8408,22 @@ function expectedRedirectUris(cfg) {
8241
8408
  function oauthSsmKeys() {
8242
8409
  return SSM_ENVS.flatMap((env) => SSM_NAMES.map((name) => `${env}/${name}`));
8243
8410
  }
8411
+ function parseOauthClientJson(input) {
8412
+ let parsed;
8413
+ try {
8414
+ parsed = JSON.parse(input);
8415
+ } catch {
8416
+ throw new Error('not valid JSON \u2014 pipe the Google client JSON (the Console "Download JSON" file)');
8417
+ }
8418
+ const root = parsed ?? {};
8419
+ const obj = root.web ?? root.installed ?? parsed;
8420
+ const clientId = typeof obj?.client_id === "string" ? obj.client_id.trim() : "";
8421
+ const clientSecret = typeof obj?.client_secret === "string" ? obj.client_secret.trim() : "";
8422
+ if (!clientId || !clientSecret) {
8423
+ throw new Error("missing client_id or client_secret in the JSON");
8424
+ }
8425
+ return { clientId, clientSecret };
8426
+ }
8244
8427
  function parseOauthConfig(mmiConfig, slug) {
8245
8428
  const rawUnknown = mmiConfig?.oauth;
8246
8429
  if (rawUnknown === void 0) throw new Error(`oauth is not configured for ${slug}`);
@@ -9112,7 +9295,7 @@ function reportWrite(label, res) {
9112
9295
  fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
9113
9296
  }
9114
9297
  var tenant = program2.command("tenant").description("tenant runtime control through Hub authority");
9115
- tenant.command("control <owner/repo> <stage> <action>").description("run bounded service control (status/start/stop/restart) for a tenant; project-admin dev/rc, master main").option("--json", "machine-readable output").action(async (repo, stage2, action) => {
9298
+ tenant.command("control <owner/repo> <stage> <action>").description("run bounded service control (status/start/stop/restart, plus rc-only retire) for a tenant; project-admin dev/rc, master main").option("--json", "machine-readable output").action(async (repo, stage2, action) => {
9116
9299
  const cfg = await loadConfig();
9117
9300
  const res = await tenantControl({ repo, stage: stage2, action }, registryClientDeps(cfg));
9118
9301
  reportWrite("tenant control", res);
@@ -9344,7 +9527,29 @@ oauth.command("plan", { isDefault: true }).description("print the canonical JS o
9344
9527
  console.log(`
9345
9528
  SSM cred params (under /mmi-future/${slug}/):`);
9346
9529
  ssm.forEach((k) => console.log(` ${k}`));
9347
- console.log("\nProvision/repair the Console client per docs/Guides/oauth-provision.md; creds via `mmi-cli secrets set`.");
9530
+ console.log("\nProvision/repair the Console client per docs/Guides/oauth-provision.md; store creds with `mmi-cli oauth set-creds`.");
9531
+ });
9532
+ oauth.command("set-creds").description('store the OAuth client into the canonical {dev,rc,main}/GOOGLE_CLIENT_* SSM keys (pipe the Console "Download JSON" on stdin)').option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (o) => {
9533
+ const raw = await readStdin();
9534
+ if (!raw.trim()) {
9535
+ return fail("oauth set-creds: pipe the Google client JSON on stdin \u2014 e.g.\n mmi-cli oauth set-creds --repo <owner/repo> < client.json");
9536
+ }
9537
+ let creds;
9538
+ try {
9539
+ creds = parseOauthClientJson(raw);
9540
+ } catch (e) {
9541
+ return fail(`oauth set-creds: ${e.message}`);
9542
+ }
9543
+ await withSecrets(async (d) => {
9544
+ for (const key of oauthSsmKeys()) {
9545
+ const value = key.endsWith("GOOGLE_CLIENT_ID") ? creds.clientId : creds.clientSecret;
9546
+ if (!await putSecret(d, key, value, { repo: o.repo })) {
9547
+ process.exitCode = 1;
9548
+ return;
9549
+ }
9550
+ }
9551
+ console.log(`OAuth client stored in all ${oauthSsmKeys().length} canonical keys. Run \`mmi-cli oauth verify\` to confirm the client is port-agnostic.`);
9552
+ });
9348
9553
  });
9349
9554
  oauth.command("verify").description("probe Google authorize with an arbitrary port (:9123) to confirm the client is port-agnostic").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--client-id <id>", "OAuth client_id (else read dev/GOOGLE_CLIENT_ID from SSM)").option("--json", "machine-readable output").action(async (o) => {
9350
9555
  const cfg = await loadConfig();
@@ -9895,7 +10100,8 @@ function renderDeployLine(d) {
9895
10100
  return parts.join("; ");
9896
10101
  }
9897
10102
  function renderTrainApply(commandName, r) {
9898
- return `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
10103
+ const base = `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
10104
+ return r.rcRetirement ? `${base}; rc retirement: ${r.rcRetirement.toUpperCase()} (${r.rcRetirementNote ?? ""})` : base;
9899
10105
  }
9900
10106
  function renderTenantRedeploy(r) {
9901
10107
  return `mmi-cli tenant redeploy: ${r.repo} ${r.stage} (ref=${r.ref}) [${r.deployModel}]; ${renderDeployLine(r)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.13.0",
3
+ "version": "2.14.1",
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",