@mutmutco/cli 2.13.0 → 2.14.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 +165 -13
  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)"}`);
@@ -6191,9 +6229,21 @@ async function runTrainApply(command, deps, options = {}) {
6191
6229
  "nothing to release: origin/rc is not ahead of origin/main"
6192
6230
  );
6193
6231
  const deployModel = await preflight(deps, ctx, "main");
6232
+ const predicted = await predictMergeConflicts(deps, "origin/main", "origin/rc");
6233
+ const predictedNonSpine = predicted.filter((f) => !isSpinePath(f));
6234
+ if (predictedNonSpine.length > 0) {
6235
+ throw new Error(
6236
+ `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.`
6237
+ );
6238
+ }
6239
+ const releasedRcSha = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
6194
6240
  await deps.run("git", ["checkout", "main"]);
6195
6241
  await deps.run("git", ["pull", "--ff-only", "origin", "main"]);
6196
- await deps.run("git", ["merge", "rc", "--no-edit"]);
6242
+ if (predicted.length === 0) {
6243
+ await deps.run("git", ["merge", "rc", "--no-edit"]);
6244
+ } else {
6245
+ await mergeRcWithSpineResolution(deps);
6246
+ }
6197
6247
  const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
6198
6248
  await verifyHubDistributionVersion(deps, deployModel, tag);
6199
6249
  await deps.run("git", ["tag", tag]);
@@ -6201,11 +6251,62 @@ async function runTrainApply(command, deps, options = {}) {
6201
6251
  await deps.run("git", ["push", "origin", tag]);
6202
6252
  await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
6203
6253
  const d = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch);
6254
+ const retirement = await retireRcRuntime(deps, ctx, deployModel, d.deployStatus, releasedRcSha);
6204
6255
  await deps.run("git", ["checkout", "development"]);
6205
6256
  await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
6206
6257
  await deps.run("git", ["merge", "main", "--no-edit"]);
6207
6258
  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 };
6259
+ return {
6260
+ ...ctx,
6261
+ command,
6262
+ stage: "main",
6263
+ ref: "main",
6264
+ tag,
6265
+ deployModel,
6266
+ promoted: true,
6267
+ dispatch: d.note,
6268
+ runId: d.runId,
6269
+ runUrl: d.runUrl,
6270
+ deployStatus: d.deployStatus,
6271
+ rcRetirement: retirement.status,
6272
+ rcRetirementNote: retirement.note
6273
+ };
6274
+ }
6275
+ async function retireRcRuntime(deps, ctx, model, deployStatus, releasedRcSha) {
6276
+ if (model !== "tenant-container") {
6277
+ return { status: "not-applicable", note: `${model} has no co-resident rc runtime to retire` };
6278
+ }
6279
+ if (deployStatus === "failure") {
6280
+ return { status: "skipped", note: "prod deploy failed \u2014 rc runtime left untouched" };
6281
+ }
6282
+ if (deployStatus !== "success") {
6283
+ return {
6284
+ status: "skipped",
6285
+ 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`
6286
+ };
6287
+ }
6288
+ try {
6289
+ await deps.run("git", ["fetch", "origin", "rc"]);
6290
+ const rcNow = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
6291
+ if (rcNow !== releasedRcSha) {
6292
+ return {
6293
+ status: "skipped",
6294
+ 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`
6295
+ };
6296
+ }
6297
+ const out = await deps.runSelf(["tenant", "control", ctx.repo, "rc", "retire"]);
6298
+ let commandId = "";
6299
+ try {
6300
+ commandId = String(JSON.parse(out).commandId ?? "");
6301
+ } catch {
6302
+ }
6303
+ return {
6304
+ status: "retired",
6305
+ note: `rc runtime retired (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept; /rcand or tenant redeploy recreates rc next cycle`
6306
+ };
6307
+ } catch (e) {
6308
+ return { status: "failed", note: `rc retirement failed (the release itself succeeded): ${e.message}` };
6309
+ }
6209
6310
  }
6210
6311
  async function runTenantRedeploy(deps, options) {
6211
6312
  const { stage: stage2 } = options;
@@ -8117,14 +8218,16 @@ async function secretsRequest(deps, key, opts) {
8117
8218
  deps.log(body.notified === false ? "admin notification not sent" : "admin notification sent");
8118
8219
  return true;
8119
8220
  }
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): `);
8221
+ async function putSecret(deps, key, value, opts) {
8222
+ if (!isValidSecretKey(key)) {
8223
+ deps.err(`invalid secret key ${JSON.stringify(key)}`);
8224
+ return false;
8225
+ }
8124
8226
  if (!value) {
8125
- deps.err("secrets set: empty value \u2014 aborted (nothing written)");
8126
- return;
8227
+ deps.err(`secrets set: empty value for ${key} \u2014 aborted (nothing written)`);
8228
+ return false;
8127
8229
  }
8230
+ const repo = await targetRepo(deps, opts);
8128
8231
  const res = await deps.fetch(`${deps.apiUrl}/secrets/set`, {
8129
8232
  method: "POST",
8130
8233
  headers: await deps.headers({ "content-type": "application/json" }),
@@ -8135,9 +8238,19 @@ async function secretsSet(deps, key, opts) {
8135
8238
  deps.err(
8136
8239
  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
8240
  );
8138
- return;
8241
+ return false;
8139
8242
  }
8140
8243
  deps.log(`set ${key} (${classifyTier(await vaultSlug(deps, opts), key)} tier)`);
8244
+ return true;
8245
+ }
8246
+ async function secretsSet(deps, key, opts) {
8247
+ if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
8248
+ const value = await deps.readSecretValue(`value for ${key} (input hidden; will not be echoed): `);
8249
+ if (!value) {
8250
+ deps.err("secrets set: empty value \u2014 aborted (nothing written)");
8251
+ return;
8252
+ }
8253
+ await putSecret(deps, key, value, opts);
8141
8254
  }
8142
8255
  async function secretsEdit(deps, key, opts) {
8143
8256
  return secretsSet(deps, key, opts);
@@ -8241,6 +8354,22 @@ function expectedRedirectUris(cfg) {
8241
8354
  function oauthSsmKeys() {
8242
8355
  return SSM_ENVS.flatMap((env) => SSM_NAMES.map((name) => `${env}/${name}`));
8243
8356
  }
8357
+ function parseOauthClientJson(input) {
8358
+ let parsed;
8359
+ try {
8360
+ parsed = JSON.parse(input);
8361
+ } catch {
8362
+ throw new Error('not valid JSON \u2014 pipe the Google client JSON (the Console "Download JSON" file)');
8363
+ }
8364
+ const root = parsed ?? {};
8365
+ const obj = root.web ?? root.installed ?? parsed;
8366
+ const clientId = typeof obj?.client_id === "string" ? obj.client_id.trim() : "";
8367
+ const clientSecret = typeof obj?.client_secret === "string" ? obj.client_secret.trim() : "";
8368
+ if (!clientId || !clientSecret) {
8369
+ throw new Error("missing client_id or client_secret in the JSON");
8370
+ }
8371
+ return { clientId, clientSecret };
8372
+ }
8244
8373
  function parseOauthConfig(mmiConfig, slug) {
8245
8374
  const rawUnknown = mmiConfig?.oauth;
8246
8375
  if (rawUnknown === void 0) throw new Error(`oauth is not configured for ${slug}`);
@@ -9112,7 +9241,7 @@ function reportWrite(label, res) {
9112
9241
  fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
9113
9242
  }
9114
9243
  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) => {
9244
+ 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
9245
  const cfg = await loadConfig();
9117
9246
  const res = await tenantControl({ repo, stage: stage2, action }, registryClientDeps(cfg));
9118
9247
  reportWrite("tenant control", res);
@@ -9344,7 +9473,29 @@ oauth.command("plan", { isDefault: true }).description("print the canonical JS o
9344
9473
  console.log(`
9345
9474
  SSM cred params (under /mmi-future/${slug}/):`);
9346
9475
  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`.");
9476
+ console.log("\nProvision/repair the Console client per docs/Guides/oauth-provision.md; store creds with `mmi-cli oauth set-creds`.");
9477
+ });
9478
+ 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) => {
9479
+ const raw = await readStdin();
9480
+ if (!raw.trim()) {
9481
+ 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");
9482
+ }
9483
+ let creds;
9484
+ try {
9485
+ creds = parseOauthClientJson(raw);
9486
+ } catch (e) {
9487
+ return fail(`oauth set-creds: ${e.message}`);
9488
+ }
9489
+ await withSecrets(async (d) => {
9490
+ for (const key of oauthSsmKeys()) {
9491
+ const value = key.endsWith("GOOGLE_CLIENT_ID") ? creds.clientId : creds.clientSecret;
9492
+ if (!await putSecret(d, key, value, { repo: o.repo })) {
9493
+ process.exitCode = 1;
9494
+ return;
9495
+ }
9496
+ }
9497
+ console.log(`OAuth client stored in all ${oauthSsmKeys().length} canonical keys. Run \`mmi-cli oauth verify\` to confirm the client is port-agnostic.`);
9498
+ });
9348
9499
  });
9349
9500
  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
9501
  const cfg = await loadConfig();
@@ -9895,7 +10046,8 @@ function renderDeployLine(d) {
9895
10046
  return parts.join("; ");
9896
10047
  }
9897
10048
  function renderTrainApply(commandName, r) {
9898
- return `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
10049
+ const base = `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
10050
+ return r.rcRetirement ? `${base}; rc retirement: ${r.rcRetirement.toUpperCase()} (${r.rcRetirementNote ?? ""})` : base;
9899
10051
  }
9900
10052
  function renderTenantRedeploy(r) {
9901
10053
  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.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",