@mutmutco/cli 3.26.0 → 3.27.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/main.cjs +181 -44
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -3616,13 +3616,29 @@ var CLI_EXIT_WATCHDOG_MS = 1e4;
3616
3616
  async function finishCliRun(watchdogMs = CLI_EXIT_WATCHDOG_MS) {
3617
3617
  const timer = setTimeout(() => {
3618
3618
  console.error(
3619
- `mmi-cli: command finished but the process did not exit within ${watchdogMs}ms \u2014 a handle leaked; forcing exit (#2904). Please report this.`
3619
+ `mmi-cli: command finished but the process did not exit within ${watchdogMs}ms \u2014 a handle leaked (${describeActiveHandles()}); forcing exit (#2904). Please report this.`
3620
3620
  );
3621
3621
  void flushStdio().finally(() => process.exit(typeof process.exitCode === "number" ? process.exitCode : 0));
3622
3622
  }, watchdogMs);
3623
3623
  timer.unref?.();
3624
3624
  await closeHttpPool();
3625
3625
  }
3626
+ function describeActiveHandles() {
3627
+ let summary;
3628
+ try {
3629
+ const counts = /* @__PURE__ */ new Map();
3630
+ for (const type of process.getActiveResourcesInfo()) counts.set(type, (counts.get(type) ?? 0) + 1);
3631
+ summary = [...counts.entries()].sort().map(([type, n]) => n > 1 ? `${type}\xD7${n}` : type).join(", ") || "none reported";
3632
+ } catch {
3633
+ summary = "unavailable";
3634
+ }
3635
+ try {
3636
+ const children = (process._getActiveHandles?.() ?? []).filter((h) => h?.constructor?.name === "ChildProcess").map((h) => `${h.spawnfile ?? "?"}(pid=${h.pid ?? "?"})`);
3637
+ if (children.length) summary += `; live children: ${children.join(", ")}`;
3638
+ } catch {
3639
+ }
3640
+ return summary;
3641
+ }
3626
3642
  async function failGraceful(msg) {
3627
3643
  console.error(`mmi-cli ${msg}`);
3628
3644
  return cleanExit(1);
@@ -5453,6 +5469,7 @@ function buildPrMergeArgs(input) {
5453
5469
  function basePolicyBlocksImmediateMerge(message) {
5454
5470
  return /base branch policy prohibits|protected branch|required status check|merge queue/i.test(message);
5455
5471
  }
5472
+ var PR_MERGE_ENQUEUED_EXIT_CODE = 2;
5456
5473
  function ghPrMergeLocalBranchDeleteWarning(message) {
5457
5474
  return /used by worktree|cannot delete branch/i.test(message);
5458
5475
  }
@@ -9200,7 +9217,7 @@ function resolveMergeCiPolicy(input) {
9200
9217
  (p) => /^\.github\/workflows\/[^/]+\.(ya?ml)$/i.test(p.replace(/\\/g, "/"))
9201
9218
  );
9202
9219
  if (!ciWorkflows.length) {
9203
- return { policy: "no-ci", reason: "no .github/workflows CI" };
9220
+ return { policy: "no-ci", reason: "no pull_request-triggered .github/workflows CI" };
9204
9221
  }
9205
9222
  return { policy: "wait-for-checks", reason: ciWorkflows.join(", ") };
9206
9223
  }
@@ -9211,13 +9228,18 @@ function parseGhPrChecksOutput(stdout) {
9211
9228
  const lines = text.split(/\r?\n/).filter((l) => l.trim());
9212
9229
  if (!lines.length) return "no-checks-reported";
9213
9230
  let anyPending = false;
9231
+ let anyFailure = false;
9214
9232
  for (const line of lines) {
9215
9233
  const parts = line.includes(" ") ? line.split(" ") : line.split(/\s+/);
9216
9234
  const state = (parts[1] ?? "").toLowerCase();
9217
- if (state === "fail" || state === "failure" || state === "error") return "failure";
9235
+ if (state === "fail" || state === "failure" || state === "error") {
9236
+ anyFailure = true;
9237
+ continue;
9238
+ }
9218
9239
  if (state === "pass" || state === "success" || state === "skipping" || state === "skipped") continue;
9219
9240
  anyPending = true;
9220
9241
  }
9242
+ if (anyFailure) return anyPending ? "failing" : "failure";
9221
9243
  return anyPending ? "pending" : "success";
9222
9244
  }
9223
9245
  function parseGhPrChecksResult(stdout, stderr) {
@@ -9230,14 +9252,16 @@ function parseGhPrChecksResult(stdout, stderr) {
9230
9252
  function pollStateFromParsed(parsed) {
9231
9253
  return parsed === "error" ? "failure" : parsed;
9232
9254
  }
9255
+ var PR_CHECKS_TIMEOUT_EXIT_CODE = 2;
9233
9256
  var PR_CHECKS_POLL_MS = 3e4;
9234
- var PR_CHECKS_TIMEOUT_MS = 10 * 6e4;
9257
+ var PR_CHECKS_TIMEOUT_MS = 30 * 6e4;
9235
9258
  var PR_CHECKS_SUCCESS_CONFIRMATIONS = 2;
9236
9259
  var PR_CHECKS_CONFIRM_MS = 5e3;
9260
+ var PR_CHECKS_FAILURE_CONFIRMATIONS = 2;
9237
9261
  var NO_CI_LIVE_CHECKS_CONTRADICTION_REASON = "ci:none contradicted by live check runs on PR head";
9238
9262
  function decidePrMergeNoCiGuard(checks, policyReason = "registry META ci:none") {
9239
9263
  if (checks === "no-checks-reported") return { action: "proceed" };
9240
- const staleNote = `registry says no-ci (${policyReason}), but live PR checks exist`;
9264
+ const staleNote = `merge CI policy says no-ci (${policyReason}), but live PR checks exist`;
9241
9265
  if (checks === "success") {
9242
9266
  return { action: "proceed", note: `${staleNote}; proceeding because they are green` };
9243
9267
  }
@@ -9264,12 +9288,39 @@ async function waitForPrChecks(deps) {
9264
9288
  queuedStates.push(firstState);
9265
9289
  }
9266
9290
  const now = deps.now ?? (() => Date.now());
9267
- const deadline = now() + PR_CHECKS_TIMEOUT_MS;
9291
+ const timeoutMs = deps.timeoutMs ?? PR_CHECKS_TIMEOUT_MS;
9292
+ const started = now();
9293
+ const deadline = started + timeoutMs;
9294
+ const report = (state) => {
9295
+ try {
9296
+ const elapsedMs = now() - started;
9297
+ deps.progress?.({ state, elapsedMs, remainingMs: Math.max(0, deadline - now()) });
9298
+ } catch {
9299
+ }
9300
+ };
9268
9301
  let lastDetail = "pending";
9269
9302
  let successStreak = 0;
9303
+ let failureStreak = 0;
9304
+ report("starting");
9270
9305
  while (now() < deadline) {
9271
9306
  const state = queuedStates.shift() ?? await deps.pollChecks();
9272
- if (state === "failure") return { policy, status: "failure", reason, detail: lastDetail };
9307
+ report(state);
9308
+ if (state !== "success") successStreak = 0;
9309
+ if (state !== "failure") failureStreak = 0;
9310
+ if (state === "failure") {
9311
+ failureStreak += 1;
9312
+ if (failureStreak >= PR_CHECKS_FAILURE_CONFIRMATIONS) {
9313
+ return { policy, status: "failure", reason, detail: "checks-failure" };
9314
+ }
9315
+ lastDetail = "confirming-failure";
9316
+ await deps.sleep(PR_CHECKS_POLL_MS);
9317
+ continue;
9318
+ }
9319
+ if (state === "failing") {
9320
+ lastDetail = "failing (a check failed; others still running)";
9321
+ await deps.sleep(PR_CHECKS_POLL_MS);
9322
+ continue;
9323
+ }
9273
9324
  if (state === "success") {
9274
9325
  successStreak += 1;
9275
9326
  if (successStreak >= PR_CHECKS_SUCCESS_CONFIRMATIONS) {
@@ -9279,7 +9330,6 @@ async function waitForPrChecks(deps) {
9279
9330
  await deps.sleep(PR_CHECKS_CONFIRM_MS);
9280
9331
  continue;
9281
9332
  }
9282
- successStreak = 0;
9283
9333
  if (state === "no-checks-reported") {
9284
9334
  lastDetail = "no-checks-reported (waiting for workflow to queue)";
9285
9335
  await deps.sleep(PR_CHECKS_POLL_MS);
@@ -9288,7 +9338,7 @@ async function waitForPrChecks(deps) {
9288
9338
  lastDetail = state;
9289
9339
  await deps.sleep(PR_CHECKS_POLL_MS);
9290
9340
  }
9291
- return { policy, status: "timeout", reason, detail: lastDetail };
9341
+ return { policy, status: "timeout", reason, detail: lastDetail, waitedMs: now() - started };
9292
9342
  }
9293
9343
 
9294
9344
  // src/bootstrap-ruleset.ts
@@ -9376,6 +9426,11 @@ function workflowTriggersPullRequest(yaml) {
9376
9426
  if (!onBlock) return false;
9377
9427
  return /\bpull_request\b/.test(onBlock);
9378
9428
  }
9429
+ function workflowReportsPrChecks(yaml) {
9430
+ const onBlock = extractOnBlock(yaml);
9431
+ if (!onBlock) return false;
9432
+ return /\bpull_request(_target)?\b/.test(onBlock);
9433
+ }
9379
9434
  function extractOnBlock(yaml) {
9380
9435
  const lines = yaml.split(/\r?\n/);
9381
9436
  let inOn = false;
@@ -9926,12 +9981,12 @@ async function listWorkflowPaths(deps, repo, branch) {
9926
9981
  `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`
9927
9982
  );
9928
9983
  return (entries ?? []).filter((e) => e.type === "file" && /\.ya?ml$/i.test(e.name ?? "")).map((e) => `.github/workflows/${e.name}`);
9929
- } catch {
9930
- return [];
9984
+ } catch (e) {
9985
+ return e?.status === 404 ? [] : void 0;
9931
9986
  }
9932
9987
  }
9933
9988
  async function resolveEmittedPrContexts(deps, repo, branch) {
9934
- const paths = await listWorkflowPaths(deps, repo, branch);
9989
+ const paths = await listWorkflowPaths(deps, repo, branch) ?? [];
9935
9990
  const workflows = [];
9936
9991
  for (const path2 of paths) {
9937
9992
  const body = await fetchFileContent(deps, repo, branch, path2);
@@ -9984,12 +10039,25 @@ async function resolveRepoMergeCiPolicy(repo, deps) {
9984
10039
  }
9985
10040
  const baseBranch = "development";
9986
10041
  const hasGate = repoClass === "hub" ? true : await contentExists(deps, repo, baseBranch, PRODUCT_GATE_PATH);
10042
+ const workflowPaths = repoClass === "hub" ? [".github/workflows/gate.yml"] : hasGate ? [PRODUCT_GATE_PATH] : await listWorkflowPaths(deps, repo, baseBranch);
10043
+ if (workflowPaths === void 0) {
10044
+ return { policy: "wait-for-checks", reason: "workflows listing unreadable (API error) \u2014 failing safe to wait-for-checks" };
10045
+ }
10046
+ const prTriggered = await filterPullRequestTriggered(deps, repo, baseBranch, workflowPaths);
9987
10047
  return resolveMergeCiPolicy({
9988
- workflowPaths: hasGate ? repoClass === "hub" ? [".github/workflows/gate.yml"] : [PRODUCT_GATE_PATH] : [],
10048
+ workflowPaths: prTriggered,
9989
10049
  registryCi: meta?.ci,
9990
10050
  registryRequiredChecks: meta?.requiredChecks
9991
10051
  });
9992
10052
  }
10053
+ async function filterPullRequestTriggered(deps, repo, branch, paths) {
10054
+ const kept = [];
10055
+ for (const path2 of paths) {
10056
+ const body = await fetchFileContent(deps, repo, branch, path2);
10057
+ if (body == null || workflowReportsPrChecks(body)) kept.push(path2);
10058
+ }
10059
+ return kept;
10060
+ }
9993
10061
  async function auditRepoCi(repo, deps) {
9994
10062
  const meta = await deps.getProjectMeta(slugFromRepo(repo));
9995
10063
  const repoClass = classifyRepo(repo, meta);
@@ -16453,6 +16521,15 @@ function settableVarHelp() {
16453
16521
  });
16454
16522
  return `META field to set (repeatable): ${keys.join(", ")}`;
16455
16523
  }
16524
+ function duplicateVarKeyAcrossFlags(vars, sets) {
16525
+ const keyOf = (v) => v.includes("=") ? v.slice(0, v.indexOf("=")) : v;
16526
+ const varKeys = new Set(vars.map(keyOf));
16527
+ for (const s of sets) {
16528
+ const key = keyOf(s);
16529
+ if (varKeys.has(key)) return key;
16530
+ }
16531
+ return null;
16532
+ }
16456
16533
  function buildProjectSetPatch(input) {
16457
16534
  const patch = {};
16458
16535
  if (input.class) {
@@ -16481,7 +16558,11 @@ function buildProjectSetPatch(input) {
16481
16558
  }
16482
16559
  for (const value of input.vars) {
16483
16560
  const eq = value.indexOf("=");
16484
- if (eq <= 0) throw new Error(`project set: --var must be KEY=VALUE (got "${value}")`);
16561
+ if (eq <= 0) {
16562
+ const looksPositional = value.includes("/") || !value.includes("=");
16563
+ const hint = looksPositional ? " \u2014 if that is the [owner/repo] argument, put it BEFORE --var; a variadic flag swallows the tokens after it" : "";
16564
+ throw new Error(`project set: --var must be KEY=VALUE (got "${value}")${hint}`);
16565
+ }
16485
16566
  const key = value.slice(0, eq);
16486
16567
  const raw = value.slice(eq + 1);
16487
16568
  if (!SETTABLE_VAR_KEY_SET.has(key)) {
@@ -18206,6 +18287,28 @@ function validateBatchSpecs(specs) {
18206
18287
  }
18207
18288
  return { ok: errors.length === 0, errors, validated };
18208
18289
  }
18290
+ var NORTH_STAR_LABEL_COLOR = "5319e7";
18291
+ function northStarLabelsForBatch(specs) {
18292
+ const labels = /* @__PURE__ */ new Set();
18293
+ for (const spec of specs) {
18294
+ if (spec.northStar) labels.add(northStarLabel(normalizeNorthStarSlug(spec.northStar)));
18295
+ for (const l of spec.labels ?? []) if (l.startsWith("northstar:")) labels.add(l);
18296
+ }
18297
+ return [...labels].sort((a, b) => a.localeCompare(b));
18298
+ }
18299
+ async function ensureNorthStarLabels(client, repo, specs) {
18300
+ const minted = [];
18301
+ for (const name of northStarLabelsForBatch(specs)) {
18302
+ try {
18303
+ await client.rest("POST", `repos/${repo}/labels`, {
18304
+ body: { name, color: NORTH_STAR_LABEL_COLOR, description: `North Star: ${name.slice("northstar:".length)}` }
18305
+ });
18306
+ minted.push(name);
18307
+ } catch {
18308
+ }
18309
+ }
18310
+ return minted;
18311
+ }
18209
18312
  async function createIssuesBatch(specs, options, deps = {}) {
18210
18313
  const client = deps.client ?? defaultGitHubClient();
18211
18314
  const validation = validateBatchSpecs(specs);
@@ -18216,6 +18319,7 @@ ${lines}`);
18216
18319
  }
18217
18320
  const repo = await resolveRepo(options.repo);
18218
18321
  if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
18322
+ await ensureNorthStarLabels(client, repo, validation.validated.map((v) => v.spec));
18219
18323
  const created = [];
18220
18324
  const failures = [];
18221
18325
  for (const entry of validation.validated) {
@@ -19594,7 +19698,7 @@ function registerPrLifecycleCommands(program3) {
19594
19698
  const { state, rows } = await snap();
19595
19699
  const output = { state, checks: rows };
19596
19700
  console.log(JSON.stringify(output));
19597
- if (state === "failure") process.exitCode = 1;
19701
+ if (state === "failure" || state === "failing") process.exitCode = 1;
19598
19702
  return;
19599
19703
  }
19600
19704
  const deadline = Date.now() + CHECKS_WATCH_TIMEOUT_MS;
@@ -20515,11 +20619,15 @@ function acquireWorktreeSetupLock(worktreeRoot) {
20515
20619
  }
20516
20620
  }
20517
20621
  var worktree = program2.command("worktree").description("self-provisioning worktrees \u2014 install deps + copy local-only config");
20518
- worktree.command("create <branch>").description("create a worktree from a base ref and provision it (install deps + copy local-only config)").option("--from <ref>", "base ref to branch from", "origin/development").option("--path <path>", "worktree path (default: ../mmi-worktrees/<branch>)").option("--remote <name>", "remote to fetch when --from is a <remote>/<branch> ref", "origin").option("--json", "machine-readable output").action(async (branch, o) => {
20622
+ worktree.command("create <branch>").description("create a worktree from a base ref and provision it (install deps + copy local-only config)").option("--from <ref>", "base ref to branch from", "origin/development").option("--base <ref>", "alias of --from (git's own word for the base ref)").option("--path <path>", "worktree path (default: ../mmi-worktrees/<branch>)").option("--remote <name>", "remote to fetch when --from is a <remote>/<branch> ref", "origin").option("--json", "machine-readable output").action(async (branch, o, cmd) => {
20519
20623
  try {
20624
+ if (o.base !== void 0 && cmd.getOptionValueSource("from") === "cli" && o.base !== o.from) {
20625
+ return fail(`worktree create: --from and --base are the same option (--base is an alias); got --from ${o.from} and --base ${o.base} \u2014 pass one`);
20626
+ }
20627
+ const fromRef = o.base ?? o.from;
20520
20628
  const repoRoot2 = (await execFileP2("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim() || process.cwd();
20521
20629
  const wtPath = o.path ?? defaultWorktreePath(repoRoot2, branch);
20522
- const { base, fetchBranch } = resolveWorktreeBase(o.from, o.remote);
20630
+ const { base, fetchBranch } = resolveWorktreeBase(fromRef, o.remote);
20523
20631
  if (fetchBranch) {
20524
20632
  const fetchErr = await execFileP2("git", ["fetch", o.remote, fetchBranch], { timeout: GH_MUTATION_TIMEOUT_MS }).then(() => void 0).catch((e) => (e instanceof Error ? e.message : String(e)).split("\n")[0]);
20525
20633
  if (fetchErr) console.error(` warning: could not fetch ${o.remote}/${fetchBranch} (${fetchErr}); base ${base} may be stale`);
@@ -20975,7 +21083,7 @@ project.command("attest [owner/repo]").description("attest this repo's app-owned
20975
21083
  const res = await attestAppGaps(slugOf(target), repo, registryClientDeps(cfg));
20976
21084
  return reportWrite("project attest", res);
20977
21085
  });
20978
- project.command("set [owner/repo]").description("upsert project META (idempotent merge; master-gated)").option("--class <class>", "deployable | content").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).option("--var <KEY=VALUE...>", settableVarHelp()).option("--secrets-file <path>", "read the #2244 secrets catalog map (JSON) from a file and merge it per entry into the existing catalog (clear all with --unset secrets)").option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, requiredBuildSecrets, secrets, edgeDomains, requiredGcpApis, publishRequired").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
21086
+ project.command("set [owner/repo]").description("upsert project META (idempotent merge; master-gated)").option("--class <class>", "deployable | content").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).option("--var <KEY=VALUE...>", settableVarHelp()).option("--set <KEY=VALUE...>", "alias of --var (one KEY=VALUE per flag; repeat the flag to set several)").option("--secrets-file <path>", "read the #2244 secrets catalog map (JSON) from a file and merge it per entry into the existing catalog (clear all with --unset secrets)").option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, requiredBuildSecrets, secrets, edgeDomains, requiredGcpApis, publishRequired").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
20979
21087
  const cfg = await loadConfig();
20980
21088
  let target;
20981
21089
  try {
@@ -20985,7 +21093,10 @@ project.command("set [owner/repo]").description("upsert project META (idempotent
20985
21093
  }
20986
21094
  const slug = slugOf(target);
20987
21095
  const repo = target.includes("/") ? target : `mutmutco/${slug}`;
20988
- const vars = rawValues("--var");
21096
+ const setAlias = o.set ?? [];
21097
+ const vars = [...o.var ?? [], ...setAlias];
21098
+ const dupe = duplicateVarKeyAcrossFlags(o.var ?? [], setAlias);
21099
+ if (dupe) return fail(`project set: KEY "${dupe}" was passed to both --var and --set; --set is an alias of --var, so pass each KEY once`);
20989
21100
  if (o.secretsFile) {
20990
21101
  try {
20991
21102
  vars.push(`secrets=${(0, import_node_fs24.readFileSync)(o.secretsFile, "utf8")}`);
@@ -21001,7 +21112,7 @@ project.command("set [owner/repo]").description("upsert project META (idempotent
21001
21112
  deployModel: o.deployModel,
21002
21113
  releaseTrack: o.releaseTrack,
21003
21114
  vars,
21004
- unsets: rawValues("--unset"),
21115
+ unsets: o.unset ?? [],
21005
21116
  clearWebProfile: Boolean(o.clearWebProfile)
21006
21117
  });
21007
21118
  } catch (e) {
@@ -21620,7 +21731,13 @@ pr.command("view <number>").description("read a PR as structured JSON (merged st
21620
21731
  async function listCiWorkflowPaths(cwd = process.cwd()) {
21621
21732
  const wfDir = (0, import_node_path23.join)(cwd, ".github", "workflows");
21622
21733
  if (!(0, import_node_fs24.existsSync)(wfDir)) return [];
21623
- return (0, import_node_fs24.readdirSync)(wfDir).filter((name) => /\.(ya?ml)$/i.test(name)).map((name) => `.github/workflows/${name}`);
21734
+ return (0, import_node_fs24.readdirSync)(wfDir).filter((name) => /\.(ya?ml)$/i.test(name)).filter((name) => {
21735
+ try {
21736
+ return workflowReportsPrChecks((0, import_node_fs24.readFileSync)((0, import_node_path23.join)(wfDir, name), "utf8"));
21737
+ } catch {
21738
+ return true;
21739
+ }
21740
+ }).map((name) => `.github/workflows/${name}`);
21624
21741
  }
21625
21742
  async function resolveMergeCiPolicyForCheckout(repoOpt) {
21626
21743
  const repo = repoOpt ?? await resolveRepo();
@@ -21677,17 +21794,30 @@ async function pollGhPrChecks(prNumber, repoArgs) {
21677
21794
  }
21678
21795
  return state;
21679
21796
  }
21680
- pr.command("checks-wait <number>").description("bounded wait for PR checks; skips immediately on no-ci repos (#1432)").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (number, o) => {
21797
+ pr.command("checks-wait <number>").description(`bounded wait for PR checks; skips immediately on no-ci repos (#1432). Default budget ${PR_CHECKS_TIMEOUT_MS / 6e4}m; --timeout raises it. Exit 1 = a check FAILED, exit ${PR_CHECKS_TIMEOUT_EXIT_CODE} = the wait window expired (re-arm)`).option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--timeout <minutes>", `wait budget in minutes (default ${PR_CHECKS_TIMEOUT_MS / 6e4}) \u2014 raise it for serial self-hosted e2e queues`).action(async (number, o) => {
21681
21798
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
21799
+ let timeoutMs;
21800
+ if (o.timeout !== void 0) {
21801
+ const minutes = Number(o.timeout);
21802
+ if (!Number.isFinite(minutes) || minutes <= 0) return fail("pr checks-wait: --timeout must be a positive number of minutes");
21803
+ timeoutMs = Math.round(minutes * 6e4);
21804
+ }
21682
21805
  const result = await waitForPrChecks({
21683
21806
  resolvePolicy: () => resolveMergeCiPolicyForCheckout(o.repo),
21684
21807
  pollChecks: () => pollGhPrChecks(number, repoArgs),
21685
21808
  sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
21686
- log: (message) => console.warn(message)
21809
+ log: (message) => console.warn(message),
21810
+ timeoutMs,
21811
+ // Liveness on stderr, one line per poll. A silent bounded wait is indistinguishable from a hang, and
21812
+ // an agent harness kills it on its own (shorter) deadline before the verdict ever prints (#2940).
21813
+ progress: ({ state, elapsedMs, remainingMs }) => console.warn(`pr checks-wait: ${state} \u2014 ${Math.round(elapsedMs / 1e3)}s elapsed, ${Math.round(remainingMs / 6e4)}m left`)
21687
21814
  });
21688
21815
  if (o.json) printLine(JSON.stringify(result));
21689
- else printLine(`pr checks-wait: ${result.status}${result.reason ? ` \u2014 ${result.reason}` : ""}${result.detail ? ` (${result.detail})` : ""}`);
21690
- if (result.status === "failure" || result.status === "timeout") process.exitCode = 1;
21816
+ else if (result.status === "timeout") {
21817
+ printLine(`pr checks-wait: timeout \u2014 waited ${Math.round((result.waitedMs ?? 0) / 6e4)}m, last state: ${result.detail ?? "pending"}. No check failed; re-run to keep waiting, or pass --timeout <minutes>.`);
21818
+ } else printLine(`pr checks-wait: ${result.status}${result.reason ? ` \u2014 ${result.reason}` : ""}${result.detail ? ` (${result.detail})` : ""}`);
21819
+ if (result.status === "failure") process.exitCode = 1;
21820
+ if (result.status === "timeout") process.exitCode = PR_CHECKS_TIMEOUT_EXIT_CODE;
21691
21821
  });
21692
21822
  async function ghMergeAutoEnqueue(prNumber, repo, method) {
21693
21823
  const args = repo ? ["--repo", repo] : [];
@@ -21770,7 +21900,10 @@ pr.command("land <number>").description("agent merge path (#1440): train probe \
21770
21900
  resolvePolicy: () => resolveRepoMergeCiPolicy(repo, ciAuditDeps()),
21771
21901
  pollChecks: () => pollGhPrChecks(prNumber, repo ? ["--repo", repo] : []),
21772
21902
  sleep: (ms) => new Promise((resolve5) => setTimeout(resolve5, ms)),
21773
- log: (message) => console.warn(message)
21903
+ log: (message) => console.warn(message),
21904
+ // `pr land` inherits the same (raised) checks budget, so it needs the same liveness — otherwise the
21905
+ // 30m wait is SILENT and reads exactly like the hang #2940 was filed about, only three times longer.
21906
+ progress: ({ state, elapsedMs, remainingMs }) => console.warn(`pr land: waiting on checks \u2014 ${state}, ${Math.round(elapsedMs / 1e3)}s elapsed, ${Math.round(remainingMs / 6e4)}m left`)
21774
21907
  }),
21775
21908
  // #2425: re-probe used ONLY to decide whether a failed `gh pr merge --auto` is worth retrying — a
21776
21909
  // fresh read of state/mergeable/checks, independent of the merge call's own (possibly stale) error.
@@ -21952,6 +22085,7 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
21952
22085
  }
21953
22086
  const remoteBefore = await remoteBranchExists2(headRef);
21954
22087
  let remoteDeleteAttempted = false;
22088
+ let upgradedToAuto = false;
21955
22089
  let remoteNotAttemptedReason = headIsProtected ? "protected-branch" : void 0;
21956
22090
  await execFileP2("gh", buildPrMergeArgs({ number, repoArgs, method, auto: o.auto, deleteBranch: !headIsProtected }), { timeout: GH_MUTATION_TIMEOUT_MS }).catch(async (e) => {
21957
22091
  const message = String(e.message || "");
@@ -21975,14 +22109,30 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
21975
22109
  return;
21976
22110
  }
21977
22111
  if (!o.auto && basePolicyBlocksImmediateMerge(message)) {
21978
- throw new Error(`gh pr merge ${number}: the base-branch policy blocks an immediate merge \u2014 re-run with --auto to merge once required checks pass.`);
22112
+ console.warn(`pr merge: the base-branch policy blocks an immediate merge \u2014 upgrading to --auto (merges once required checks pass).`);
22113
+ upgradedToAuto = true;
22114
+ await execFileP2("gh", buildPrMergeArgs({ number, repoArgs, method, auto: true, deleteBranch: !headIsProtected }), { timeout: GH_MUTATION_TIMEOUT_MS }).catch((e2) => {
22115
+ const m2 = String(e2.message || "");
22116
+ if (/already been merged/i.test(m2)) {
22117
+ remoteNotAttemptedReason = "pr-already-merged";
22118
+ return;
22119
+ }
22120
+ const note2 = timeoutKillNote(e2, GH_MUTATION_TIMEOUT_MS);
22121
+ if (note2) throw new Error(`gh pr merge ${number}: ${note2}`);
22122
+ if (!ghPrMergeLocalBranchDeleteWarning(m2)) throw e2;
22123
+ });
22124
+ return;
21979
22125
  }
21980
22126
  if (!ghPrMergeLocalBranchDeleteWarning(message)) throw e;
21981
22127
  });
21982
- if (o.auto) {
22128
+ if (o.auto || upgradedToAuto) {
21983
22129
  const state = (await execFileP2("gh", ["pr", "view", number, ...repoArgs, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS5 }).catch(() => ({ stdout: "" }))).stdout.trim();
21984
22130
  if (state !== "MERGED") {
21985
- console.log(JSON.stringify({ mergeStatus: "auto-merge-enqueued", pr: number, branch: headRef, state: state || "unknown" }));
22131
+ console.log(JSON.stringify({ mergeStatus: "auto-merge-enqueued", pr: number, branch: headRef, state: state || "unknown", upgradedToAuto: upgradedToAuto || void 0 }));
22132
+ if (upgradedToAuto && !o.auto) {
22133
+ console.warn(`pr merge: PR #${number} is ENQUEUED, not merged \u2014 auto-merge will land it once required checks pass. Exiting ${PR_MERGE_ENQUEUED_EXIT_CODE} so a chained command does not treat this as a completed merge.`);
22134
+ process.exitCode = PR_MERGE_ENQUEUED_EXIT_CODE;
22135
+ }
21986
22136
  return;
21987
22137
  }
21988
22138
  }
@@ -22316,19 +22466,6 @@ function rawValue(flag, fallback) {
22316
22466
  const inline = process.argv.find((arg) => arg.startsWith(`${flag}=`));
22317
22467
  return inline ? inline.slice(flag.length + 1) : fallback;
22318
22468
  }
22319
- function rawValues(flag) {
22320
- const out = [];
22321
- for (let i = 0; i < process.argv.length; i++) {
22322
- const arg = process.argv[i];
22323
- if (arg === flag && process.argv[i + 1]) {
22324
- out.push(process.argv[i + 1]);
22325
- i++;
22326
- continue;
22327
- }
22328
- if (arg.startsWith(`${flag}=`)) out.push(arg.slice(flag.length + 1));
22329
- }
22330
- return out;
22331
- }
22332
22469
  function stagePortFromArgv() {
22333
22470
  const raw = rawValue("--port", "");
22334
22471
  if (!raw) return void 0;
@@ -22896,7 +23033,7 @@ bootstrap.command("org-ruleset").description("drift-check the codified org no-ag
22896
23033
  else console.log(renderOrgRulesetDriftReport(plan));
22897
23034
  if (plan.action !== "noop") process.exitCode = 1;
22898
23035
  });
22899
- bootstrap.command("apply <repo>").description("run from the MMI-Hub repo root: idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").option("--class <class>", "deployable | content", "deployable").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path)`).option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo) => {
23036
+ bootstrap.command("apply <repo>").description("run from the MMI-Hub repo root: idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").option("--class <class>", "deployable | content", "deployable").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path)`).option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo, cmdOpts) => {
22900
23037
  const o = {
22901
23038
  class: rawValue("--class", "deployable"),
22902
23039
  projectType: rawValue("--project-type", ""),
@@ -22922,7 +23059,7 @@ bootstrap.command("apply <repo>").description("run from the MMI-Hub repo root: i
22922
23059
  const readFile4 = (p) => (0, import_node_fs24.existsSync)(p) ? (0, import_node_fs24.readFileSync)(p, "utf8") : null;
22923
23060
  const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
22924
23061
  const rawVars = {};
22925
- for (const value of rawValues("--var")) {
23062
+ for (const value of cmdOpts.var ?? []) {
22926
23063
  const eq = value.indexOf("=");
22927
23064
  if (eq > 0) rawVars[value.slice(0, eq)] = value.slice(eq + 1);
22928
23065
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.26.0",
3
+ "version": "3.27.1",
4
4
  "description": "MMI Future CLI — the org dev toolbox (board, registry, keyless secrets, release train, bootstrap, doctor) and the cross-IDE engine the plugin's session-start hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",