@mutmutco/cli 2.14.0 → 2.15.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.
- package/dist/index.cjs +763 -68
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -4301,6 +4301,7 @@ var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
|
|
|
4301
4301
|
var rawExecFileP2 = (0, import_node_util4.promisify)(import_node_child_process4.execFile);
|
|
4302
4302
|
var BOARD_GIT_TIMEOUT_MS = 1e4;
|
|
4303
4303
|
var WRITE_PROBE_CONCURRENCY = 8;
|
|
4304
|
+
var CLAIM_CONCURRENCY = 5;
|
|
4304
4305
|
var execFileP2 = (file, args, options = {}) => (
|
|
4305
4306
|
// encoding 'utf8' guarantees string output at runtime; the cast pins the type (promisify's
|
|
4306
4307
|
// overloads widen to string|Buffer when options is spread in).
|
|
@@ -4675,17 +4676,16 @@ async function showBoardItem(options, deps = {}) {
|
|
|
4675
4676
|
}
|
|
4676
4677
|
return item;
|
|
4677
4678
|
}
|
|
4678
|
-
async function
|
|
4679
|
+
async function prepareClaimContext(options, selectors, deps, collected) {
|
|
4679
4680
|
const cfg = resolveBoardConfig(options.config);
|
|
4680
4681
|
const client = deps.client ?? defaultGitHubClient();
|
|
4681
|
-
const
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
collected.items.push(fallback);
|
|
4682
|
+
for (const selector of selectors) {
|
|
4683
|
+
try {
|
|
4684
|
+
findBoardItem(collected.items, selector);
|
|
4685
|
+
} catch {
|
|
4686
|
+
const fallback = (await fetchIssueProjectItem(client, cfg, selector)).item;
|
|
4687
|
+
if (fallback) collected.items.push(fallback);
|
|
4688
|
+
}
|
|
4689
4689
|
}
|
|
4690
4690
|
const writable = await resolveWritableReposForClaimables(collected.items, client, options.allowPartial ?? false);
|
|
4691
4691
|
collected.warnings.push(...writable.warnings);
|
|
@@ -4698,8 +4698,12 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
4698
4698
|
warnings: collected.warnings,
|
|
4699
4699
|
partial: collected.partial
|
|
4700
4700
|
};
|
|
4701
|
-
|
|
4702
|
-
|
|
4701
|
+
return { cfg, client, items: collected.items, writable: writable.repos, report };
|
|
4702
|
+
}
|
|
4703
|
+
async function claimOneBoardItem(ctx, selector, options) {
|
|
4704
|
+
const { cfg, client, report } = ctx;
|
|
4705
|
+
const flatItem = findBoardItem(ctx.items, selector);
|
|
4706
|
+
if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !ctx.writable.has(flatItem.repository.toLowerCase())) {
|
|
4703
4707
|
throw new Error(`${flatItem.ref} is not claimable: viewer does not have write access to ${flatItem.repository}`);
|
|
4704
4708
|
}
|
|
4705
4709
|
let item = findClaimableItem(report, selector);
|
|
@@ -4736,6 +4740,49 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
4736
4740
|
partial: false
|
|
4737
4741
|
};
|
|
4738
4742
|
}
|
|
4743
|
+
async function claimBoardIssue(options, deps = {}) {
|
|
4744
|
+
const cfg = resolveBoardConfig(options.config);
|
|
4745
|
+
const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
|
|
4746
|
+
const selector = parseIssueSelector(options.selector, collected.repo);
|
|
4747
|
+
const ctx = await prepareClaimContext(options, [selector], deps, collected);
|
|
4748
|
+
return claimOneBoardItem(ctx, selector, options);
|
|
4749
|
+
}
|
|
4750
|
+
async function claimBoardIssues(options, deps = {}) {
|
|
4751
|
+
const cfg = resolveBoardConfig(options.config);
|
|
4752
|
+
const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
|
|
4753
|
+
const selectors = [];
|
|
4754
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4755
|
+
for (const raw of options.selectors) {
|
|
4756
|
+
const selector = parseIssueSelector(raw, collected.repo);
|
|
4757
|
+
const key = `${selector.repo.toLowerCase()}#${selector.number}`;
|
|
4758
|
+
if (seen.has(key)) continue;
|
|
4759
|
+
seen.add(key);
|
|
4760
|
+
selectors.push(selector);
|
|
4761
|
+
}
|
|
4762
|
+
const ctx = await prepareClaimContext(options, selectors, deps, collected);
|
|
4763
|
+
const results = new Array(selectors.length);
|
|
4764
|
+
let next = 0;
|
|
4765
|
+
const worker = async () => {
|
|
4766
|
+
while (next < selectors.length) {
|
|
4767
|
+
const index = next++;
|
|
4768
|
+
const selector = selectors[index];
|
|
4769
|
+
const ref = `${selector.repo}#${selector.number}`;
|
|
4770
|
+
try {
|
|
4771
|
+
const result = await claimOneBoardItem(ctx, selector, options);
|
|
4772
|
+
results[index] = { ref: result.item.ref, claimed: true, item: result.item, status: result.status, partial: result.partial, warning: result.warning };
|
|
4773
|
+
} catch (e) {
|
|
4774
|
+
results[index] = { ref, claimed: false, reason: e.message };
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
};
|
|
4778
|
+
await Promise.all(Array.from({ length: Math.min(CLAIM_CONCURRENCY, selectors.length) }, () => worker()));
|
|
4779
|
+
return {
|
|
4780
|
+
viewer: ctx.report.viewer,
|
|
4781
|
+
repo: ctx.report.repo,
|
|
4782
|
+
results,
|
|
4783
|
+
failed: results.filter((result) => !result.claimed).length
|
|
4784
|
+
};
|
|
4785
|
+
}
|
|
4739
4786
|
async function setBoardItemPriority(client, cfg, itemId, priority) {
|
|
4740
4787
|
if (!isPriorityFieldConfigured(cfg)) return void 0;
|
|
4741
4788
|
const optionId = resolvePriorityOptionId(cfg, priority);
|
|
@@ -5315,6 +5362,7 @@ function trainPlan(command) {
|
|
|
5315
5362
|
{ label: "verify current branch is rc", gated: true },
|
|
5316
5363
|
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
5317
5364
|
{ label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
|
|
5365
|
+
{ label: "verify every main-only hotfix commit is covered by the rc candidate", command: "node scripts/hotfix-coverage.mjs", gated: true },
|
|
5318
5366
|
{ label: "merge rc to main", gated: true },
|
|
5319
5367
|
{ label: "for Hub distribution changes, verify the promoted SHA carries the release bump", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
|
|
5320
5368
|
{ label: "tag release and publish GitHub Release", gated: true },
|
|
@@ -5323,10 +5371,10 @@ function trainPlan(command) {
|
|
|
5323
5371
|
];
|
|
5324
5372
|
}
|
|
5325
5373
|
return [
|
|
5326
|
-
{ label: "
|
|
5327
|
-
{ label: "
|
|
5328
|
-
{ label: "deploy prod", gated: true },
|
|
5329
|
-
{ label: "
|
|
5374
|
+
{ label: "verify the fix is merged on development (the only hotfix origin)", gated: true },
|
|
5375
|
+
{ label: "branch hotfix from main and cherry-pick the dev commits", command: "git cherry-pick -x <dev-sha>", gated: true },
|
|
5376
|
+
{ label: "land on main via PR, tag, deploy prod", gated: true },
|
|
5377
|
+
{ label: "forward-bump development distribution manifests (Hub only; no back-merge)", gated: true }
|
|
5330
5378
|
];
|
|
5331
5379
|
}
|
|
5332
5380
|
function bootstrapPlan(repo, repoClass) {
|
|
@@ -5405,9 +5453,9 @@ function stalePosixFields(config, shell2) {
|
|
|
5405
5453
|
}
|
|
5406
5454
|
function sanitizeLocalStage(local, stale) {
|
|
5407
5455
|
if (!stale.length) return local;
|
|
5408
|
-
const
|
|
5409
|
-
for (const field of stale) delete
|
|
5410
|
-
return
|
|
5456
|
+
const clean3 = { ...local };
|
|
5457
|
+
for (const field of stale) delete clean3[field];
|
|
5458
|
+
return clean3;
|
|
5411
5459
|
}
|
|
5412
5460
|
function staleNote(staleFields, outcome) {
|
|
5413
5461
|
const list = staleFields.join(", ");
|
|
@@ -5679,7 +5727,7 @@ function buildGitignoreManagedBlockCheck(input) {
|
|
|
5679
5727
|
return { ...base, ok: false, contentToWrite: content, added, removed, seeded };
|
|
5680
5728
|
}
|
|
5681
5729
|
var MMI_PLUGIN_CACHE_CLEANUP_LABEL = "stale MMI plugin cache dirs (Claude/Codex)";
|
|
5682
|
-
var MMI_PLUGIN_CACHE_CLEANUP_FIX = "run `mmi-cli doctor` to quarantine stale MMI-only plugin cache dirs, then reload the affected agent surface";
|
|
5730
|
+
var MMI_PLUGIN_CACHE_CLEANUP_FIX = "run `mmi-cli doctor` (or `mmi-cli doctor --apply --json` for machine callers) to quarantine stale MMI-only plugin cache dirs, then reload the affected agent surface";
|
|
5683
5731
|
function normalizeVersion(v) {
|
|
5684
5732
|
return v?.trim().replace(/^v/, "");
|
|
5685
5733
|
}
|
|
@@ -5714,7 +5762,7 @@ function buildMmiPluginCacheCleanupCheck(input) {
|
|
|
5714
5762
|
...base,
|
|
5715
5763
|
ok: false,
|
|
5716
5764
|
leftovers,
|
|
5717
|
-
|
|
5765
|
+
plannedCount: leftovers.length,
|
|
5718
5766
|
quarantinePlan: leftovers.map((entry) => ({
|
|
5719
5767
|
from: entry.path,
|
|
5720
5768
|
to: cachePathJoin(entry.root, ".mmi-quarantine", stamp, entry.name)
|
|
@@ -5768,6 +5816,16 @@ var INSTALLED_PLUGIN_VERSION_LABEL = "installed MMI plugin version (vs latest re
|
|
|
5768
5816
|
function isSemverVersion(v) {
|
|
5769
5817
|
return typeof v === "string" && /^v?\d+\.\d+\.\d+/.test(v.trim());
|
|
5770
5818
|
}
|
|
5819
|
+
function staleRecordCommand(surface) {
|
|
5820
|
+
return surface === "codex" ? "codex plugin marketplace upgrade mmi && codex plugin add mmi@mmi" : "claude plugin marketplace update mmi && claude plugin update mmi@mmi";
|
|
5821
|
+
}
|
|
5822
|
+
function staleSurfacesFix(stale, releasedVersion) {
|
|
5823
|
+
const parts = stale.map((s) => {
|
|
5824
|
+
const at = s.recordPath ? ` (${s.recordPath})` : "";
|
|
5825
|
+
return `${s.surface} record${at} is at ${s.installedVersion}${releasedVersion ? ` < ${releasedVersion}` : ""} \u2014 run: ${staleRecordCommand(s.surface)}`;
|
|
5826
|
+
});
|
|
5827
|
+
return `stale installed-plugin record on ${stale.map((s) => s.surface).join(" + ")}: ${parts.join("; ")}`;
|
|
5828
|
+
}
|
|
5771
5829
|
function buildInstalledPluginVersionCheck(input) {
|
|
5772
5830
|
const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
|
|
5773
5831
|
const base = {
|
|
@@ -5776,20 +5834,93 @@ function buildInstalledPluginVersionCheck(input) {
|
|
|
5776
5834
|
fix: pluginRecoveryFix(input.surface),
|
|
5777
5835
|
pluginId
|
|
5778
5836
|
};
|
|
5779
|
-
if (!input.isOrgRepo) return base;
|
|
5780
|
-
const
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5837
|
+
if (!input.isOrgRepo || !isSemverVersion(input.releasedVersion)) return base;
|
|
5838
|
+
const stale = [];
|
|
5839
|
+
let sawRecord = false;
|
|
5840
|
+
let currentVersion;
|
|
5841
|
+
for (const source of input.sources) {
|
|
5842
|
+
const records = source.installed?.plugins?.[pluginId];
|
|
5843
|
+
if (!Array.isArray(records) || records.length === 0) continue;
|
|
5844
|
+
sawRecord = true;
|
|
5845
|
+
const installedVersion = bestRecord(records).version;
|
|
5846
|
+
if (!isSemverVersion(installedVersion)) continue;
|
|
5847
|
+
if (compareVersions(installedVersion, input.releasedVersion) >= 0) {
|
|
5848
|
+
currentVersion = currentVersion ?? installedVersion;
|
|
5849
|
+
} else {
|
|
5850
|
+
stale.push({ surface: source.surface, installedVersion, ...source.recordPath ? { recordPath: source.recordPath } : {} });
|
|
5851
|
+
}
|
|
5852
|
+
}
|
|
5853
|
+
if (!sawRecord) return base;
|
|
5854
|
+
if (stale.length === 0) {
|
|
5855
|
+
return { ...base, ...currentVersion ? { installedVersion: currentVersion } : {}, releasedVersion: input.releasedVersion };
|
|
5786
5856
|
}
|
|
5787
5857
|
return {
|
|
5788
5858
|
...base,
|
|
5789
5859
|
ok: false,
|
|
5790
|
-
|
|
5791
|
-
|
|
5860
|
+
fix: staleSurfacesFix(stale, input.releasedVersion),
|
|
5861
|
+
installedVersion: stale[0].installedVersion,
|
|
5862
|
+
releasedVersion: input.releasedVersion,
|
|
5863
|
+
staleSurfaces: stale
|
|
5864
|
+
};
|
|
5865
|
+
}
|
|
5866
|
+
var CURSOR_PLUGIN_INSTALL_LABEL = "Cursor Team Marketplace plugin install";
|
|
5867
|
+
var CURSOR_MARKETPLACE_INSTALL_GUIDE = "https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/cursor-marketplace-install.md";
|
|
5868
|
+
var CURSOR_PLUGIN_JSON_REL = ".cursor-plugin/plugin.json";
|
|
5869
|
+
var CURSOR_HOOKS_JSON_REL = "hooks/hooks.json";
|
|
5870
|
+
function joinCachePath(root, ...parts) {
|
|
5871
|
+
const sep = root.includes("\\") ? "\\" : "/";
|
|
5872
|
+
return [root.replace(/[\\/]+$/, ""), ...parts].join(sep);
|
|
5873
|
+
}
|
|
5874
|
+
function cursorPluginInstallFix(input) {
|
|
5875
|
+
const logHint = "check %APPDATA%\\Cursor\\logs\\<session>\\window*\\exthost\\anysphere.cursor-agent-exec\\Cursor Plugins.*.log for `unable to get password from user`";
|
|
5876
|
+
const authSteps = "ensure GitHub SSH auth works for Cursor's background git (start ssh-agent, add your GitHub key, verify `ssh -T git@github.com`) \u2014 Cursor disables credential.helper during marketplace clone";
|
|
5877
|
+
const marketplaceRefresh = "in Cursor Dashboard \u2192 Settings \u2192 Plugins, click Update next to the MMI Team Marketplace, enable MMI, then restart Cursor";
|
|
5878
|
+
const guide = `full recovery: ${CURSOR_MARKETPLACE_INSTALL_GUIDE}`;
|
|
5879
|
+
if (input.reason === "missing-cache") {
|
|
5880
|
+
return `${marketplaceRefresh}; ${authSteps}; ${guide}`;
|
|
5881
|
+
}
|
|
5882
|
+
const pin = input.pinName ?? "<commit-pin>";
|
|
5883
|
+
const cacheDir = joinCachePath(input.cacheRoot, pin);
|
|
5884
|
+
const localSeed = input.hubCheckout ? `temporary fallback: copy ${joinCachePath(input.hubCheckout, "plugins", "mmi")} to ${cacheDir}, then restart Cursor` : `temporary fallback: copy plugins/mmi from a local MMI-Hub checkout to ${cacheDir}, then restart Cursor`;
|
|
5885
|
+
return `Cursor plugin cache at ${cacheDir} is empty or missing ${CURSOR_PLUGIN_JSON_REL} or ${CURSOR_HOOKS_JSON_REL} \u2014 ${marketplaceRefresh}; ${authSteps}; ${localSeed}; ${logHint}; ${guide}`;
|
|
5886
|
+
}
|
|
5887
|
+
function buildCursorPluginInstallCheck(input) {
|
|
5888
|
+
const base = {
|
|
5889
|
+
ok: true,
|
|
5890
|
+
label: CURSOR_PLUGIN_INSTALL_LABEL,
|
|
5891
|
+
fix: cursorPluginInstallFix({ reason: "missing-cache", cacheRoot: input.cacheRoot })
|
|
5792
5892
|
};
|
|
5893
|
+
if (!input.isOrgRepo) return base;
|
|
5894
|
+
const shouldCheck = input.surface === "cursor" || input.cacheRootExists;
|
|
5895
|
+
if (!shouldCheck) return base;
|
|
5896
|
+
if (input.surface === "cursor" && input.pins.length === 0) {
|
|
5897
|
+
return {
|
|
5898
|
+
...base,
|
|
5899
|
+
ok: false,
|
|
5900
|
+
cacheRoot: input.cacheRoot,
|
|
5901
|
+
pins: [],
|
|
5902
|
+
reason: "missing-cache",
|
|
5903
|
+
fix: cursorPluginInstallFix({ reason: "missing-cache", cacheRoot: input.cacheRoot, hubCheckout: input.hubCheckout })
|
|
5904
|
+
};
|
|
5905
|
+
}
|
|
5906
|
+
for (const pin of input.pins) {
|
|
5907
|
+
if (!pin.hasPluginJson || !pin.hasHooksJson || pin.isEmpty) {
|
|
5908
|
+
return {
|
|
5909
|
+
...base,
|
|
5910
|
+
ok: false,
|
|
5911
|
+
cacheRoot: input.cacheRoot,
|
|
5912
|
+
pins: input.pins,
|
|
5913
|
+
reason: "incomplete-cache",
|
|
5914
|
+
fix: cursorPluginInstallFix({
|
|
5915
|
+
reason: "incomplete-cache",
|
|
5916
|
+
cacheRoot: input.cacheRoot,
|
|
5917
|
+
pinName: pin.name,
|
|
5918
|
+
hubCheckout: input.hubCheckout
|
|
5919
|
+
})
|
|
5920
|
+
};
|
|
5921
|
+
}
|
|
5922
|
+
}
|
|
5923
|
+
return { ...base, cacheRoot: input.cacheRoot, pins: input.pins };
|
|
5793
5924
|
}
|
|
5794
5925
|
|
|
5795
5926
|
// src/stage-runner.ts
|
|
@@ -6060,9 +6191,9 @@ async function predictMergeConflicts(deps, ours, theirs) {
|
|
|
6060
6191
|
return files;
|
|
6061
6192
|
}
|
|
6062
6193
|
}
|
|
6063
|
-
async function
|
|
6194
|
+
async function mergeWithSpineResolution(deps, sourceRef, label, resolve) {
|
|
6064
6195
|
try {
|
|
6065
|
-
await deps.run("git", ["merge",
|
|
6196
|
+
await deps.run("git", ["merge", sourceRef, "--no-edit"]);
|
|
6066
6197
|
return;
|
|
6067
6198
|
} catch {
|
|
6068
6199
|
}
|
|
@@ -6071,10 +6202,10 @@ async function mergeRcWithSpineResolution(deps) {
|
|
|
6071
6202
|
if (unmerged.length === 0 || nonSpine.length > 0) {
|
|
6072
6203
|
await deps.run("git", ["merge", "--abort"]);
|
|
6073
6204
|
throw new Error(
|
|
6074
|
-
unmerged.length === 0 ?
|
|
6205
|
+
unmerged.length === 0 ? `${label} merge failed without conflicted paths \u2014 merge aborted; inspect the repo state and rerun` : `${label} merge conflicts on non-spine path(s): ${nonSpine.join(", ")} \u2014 merge aborted (the train is misaligned; reconcile the branches via an approved alignment PR, then rerun)`
|
|
6075
6206
|
);
|
|
6076
6207
|
}
|
|
6077
|
-
await deps.run("git", ["checkout",
|
|
6208
|
+
await deps.run("git", ["checkout", `--${resolve}`, "--", ...unmerged]);
|
|
6078
6209
|
await deps.run("git", ["add", "--", ...unmerged]);
|
|
6079
6210
|
await deps.run("git", ["commit", "--no-edit"]);
|
|
6080
6211
|
}
|
|
@@ -6115,6 +6246,12 @@ var HUB_REPO2 = "mutmutco/MMI-Hub";
|
|
|
6115
6246
|
var CORRELATE_ATTEMPTS = 5;
|
|
6116
6247
|
var CORRELATE_DELAY_MS = 1500;
|
|
6117
6248
|
var CORRELATE_SKEW_SLACK_MS = 1e4;
|
|
6249
|
+
var TRAIN_CHECK_RUNS_JQ = "[.check_runs[]|{name:.name,status:.status,conclusion:.conclusion}]";
|
|
6250
|
+
var TRAIN_COMMIT_STATUS_JQ = "[.statuses[]|{context:.context,state:.state}]";
|
|
6251
|
+
var TRAIN_PROTECTION_CONTEXTS_JQ = "[.contexts[]]";
|
|
6252
|
+
var TRAIN_RULES_CONTEXTS_JQ = '[.[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context]';
|
|
6253
|
+
var TRAIN_CHECK_ATTEMPTS = 40;
|
|
6254
|
+
var TRAIN_CHECK_DELAY_MS = 15e3;
|
|
6118
6255
|
async function correlateTenantRun(deps, since) {
|
|
6119
6256
|
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6120
6257
|
const threshold = since - CORRELATE_SKEW_SLACK_MS;
|
|
@@ -6152,6 +6289,112 @@ async function watchTenantRun(deps, runId) {
|
|
|
6152
6289
|
return "failure";
|
|
6153
6290
|
}
|
|
6154
6291
|
}
|
|
6292
|
+
function isNotFoundError(e) {
|
|
6293
|
+
const msg = `${e.message ?? e} ${String(e.stderr ?? "")}`;
|
|
6294
|
+
return /HTTP 404|Not Found|\(404\)/i.test(msg);
|
|
6295
|
+
}
|
|
6296
|
+
function parseStringArray(out, label) {
|
|
6297
|
+
const parsed = JSON.parse(out);
|
|
6298
|
+
if (!Array.isArray(parsed)) throw new Error(`${label} response was not an array`);
|
|
6299
|
+
return parsed.filter((v) => typeof v === "string");
|
|
6300
|
+
}
|
|
6301
|
+
async function discoverRequiredCheckContexts(deps, ctx, branch) {
|
|
6302
|
+
const contexts = /* @__PURE__ */ new Set();
|
|
6303
|
+
try {
|
|
6304
|
+
const out = await deps.run("gh", ["api", `repos/${ctx.repo}/branches/${branch}/protection/required_status_checks`, "--jq", TRAIN_PROTECTION_CONTEXTS_JQ]);
|
|
6305
|
+
for (const c of parseStringArray(out, "branch protection required_status_checks")) contexts.add(c);
|
|
6306
|
+
} catch (e) {
|
|
6307
|
+
if (!isNotFoundError(e)) throw new Error(`could not read branch protection for ${ctx.repo}@${branch}: ${e.message ?? e}`);
|
|
6308
|
+
}
|
|
6309
|
+
try {
|
|
6310
|
+
const out = await deps.run("gh", ["api", `repos/${ctx.repo}/rules/branches/${branch}`, "--jq", TRAIN_RULES_CONTEXTS_JQ]);
|
|
6311
|
+
for (const c of parseStringArray(out, "branch rules required_status_checks")) contexts.add(c);
|
|
6312
|
+
} catch (e) {
|
|
6313
|
+
if (!isNotFoundError(e)) throw new Error(`could not read branch rules for ${ctx.repo}@${branch}: ${e.message ?? e}`);
|
|
6314
|
+
}
|
|
6315
|
+
return [...contexts];
|
|
6316
|
+
}
|
|
6317
|
+
function resolveContextState(context, checkRuns, statuses) {
|
|
6318
|
+
let sawFailure = false;
|
|
6319
|
+
for (const r of checkRuns) {
|
|
6320
|
+
if (r.name !== context) continue;
|
|
6321
|
+
if (r.status === "completed") {
|
|
6322
|
+
if (r.conclusion === "success") return "success";
|
|
6323
|
+
sawFailure = true;
|
|
6324
|
+
}
|
|
6325
|
+
}
|
|
6326
|
+
for (const s of statuses) {
|
|
6327
|
+
if (s.context !== context) continue;
|
|
6328
|
+
if (s.state === "success") return "success";
|
|
6329
|
+
if (s.state === "failure" || s.state === "error") sawFailure = true;
|
|
6330
|
+
}
|
|
6331
|
+
return sawFailure ? "failed" : "pending";
|
|
6332
|
+
}
|
|
6333
|
+
async function waitForRequiredTrainChecks(deps, ctx, sha, required) {
|
|
6334
|
+
if (required.length === 0) {
|
|
6335
|
+
return "no required status checks configured on the target branch \u2014 check wait skipped (GitHub push gate is the backstop)";
|
|
6336
|
+
}
|
|
6337
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6338
|
+
let lastStatus = "not checked";
|
|
6339
|
+
let lastError;
|
|
6340
|
+
for (let attempt = 0; attempt < TRAIN_CHECK_ATTEMPTS; attempt++) {
|
|
6341
|
+
if (attempt > 0) await sleep(TRAIN_CHECK_DELAY_MS);
|
|
6342
|
+
let checkRuns;
|
|
6343
|
+
let statuses;
|
|
6344
|
+
try {
|
|
6345
|
+
const runsOut = await deps.run("gh", ["api", `repos/${ctx.repo}/commits/${sha}/check-runs`, "--jq", TRAIN_CHECK_RUNS_JQ]);
|
|
6346
|
+
const parsedRuns = JSON.parse(runsOut);
|
|
6347
|
+
if (!Array.isArray(parsedRuns)) throw new Error("check-runs response was not an array");
|
|
6348
|
+
checkRuns = parsedRuns;
|
|
6349
|
+
const statusOut = await deps.run("gh", ["api", `repos/${ctx.repo}/commits/${sha}/status`, "--jq", TRAIN_COMMIT_STATUS_JQ]);
|
|
6350
|
+
const parsedStatuses = JSON.parse(statusOut);
|
|
6351
|
+
if (!Array.isArray(parsedStatuses)) throw new Error("commit status response was not an array");
|
|
6352
|
+
statuses = parsedStatuses;
|
|
6353
|
+
lastError = void 0;
|
|
6354
|
+
} catch (e) {
|
|
6355
|
+
lastError = e.message || String(e);
|
|
6356
|
+
continue;
|
|
6357
|
+
}
|
|
6358
|
+
const states = required.map((c) => [c, resolveContextState(c, checkRuns, statuses)]);
|
|
6359
|
+
lastStatus = states.map(([c, s]) => `${c}=${s}`).join(", ");
|
|
6360
|
+
const failed = states.filter(([, s]) => s === "failed").map(([c]) => c);
|
|
6361
|
+
if (failed.length > 0) {
|
|
6362
|
+
throw new Error(`required train check failed: ${failed.join(", ")} (${lastStatus})`);
|
|
6363
|
+
}
|
|
6364
|
+
if (states.every(([, s]) => s === "success")) {
|
|
6365
|
+
return `required checks passed: ${required.join(", ")}`;
|
|
6366
|
+
}
|
|
6367
|
+
}
|
|
6368
|
+
throw new Error(
|
|
6369
|
+
`timed out waiting for required train checks on ${sha}: ${lastError ? `last error: ${lastError}` : lastStatus}`
|
|
6370
|
+
);
|
|
6371
|
+
}
|
|
6372
|
+
async function ensureTagPushed(deps, tag, sha) {
|
|
6373
|
+
const remoteOut = await deps.run("git", ["ls-remote", "origin", `refs/tags/${tag}`]);
|
|
6374
|
+
const remoteSha = clean(remoteOut).split(/\s+/)[0] || "";
|
|
6375
|
+
let localSha = "";
|
|
6376
|
+
try {
|
|
6377
|
+
localSha = clean(await deps.run("git", ["rev-parse", "--verify", `refs/tags/${tag}^{commit}`]));
|
|
6378
|
+
} catch {
|
|
6379
|
+
}
|
|
6380
|
+
if (remoteSha) {
|
|
6381
|
+
if (remoteSha !== sha) {
|
|
6382
|
+
throw new Error(
|
|
6383
|
+
`tag ${tag} already exists on origin at ${remoteSha}, but this run intends ${sha} \u2014 refusing to touch a pushed tag. Repair manually: either release the existing tagged commit (reset the local branch to ${remoteSha} and rerun), or mint the next candidate version so a fresh tag is used. Never force-move or delete a pushed tag.`
|
|
6384
|
+
);
|
|
6385
|
+
}
|
|
6386
|
+
if (localSha && localSha !== sha) {
|
|
6387
|
+
throw new Error(`local tag ${tag} points at ${localSha} but origin has it at ${sha} \u2014 delete the stale local tag (git tag -d ${tag}) and rerun`);
|
|
6388
|
+
}
|
|
6389
|
+
return `tag ${tag} already on origin at ${sha.slice(0, 7)} \u2014 resumed without re-pushing`;
|
|
6390
|
+
}
|
|
6391
|
+
if (localSha && localSha !== sha) {
|
|
6392
|
+
throw new Error(`local tag ${tag} already points at ${localSha}, not the intended ${sha} \u2014 delete the stale local tag (git tag -d ${tag}) and rerun`);
|
|
6393
|
+
}
|
|
6394
|
+
if (!localSha) await deps.run("git", ["tag", tag, sha]);
|
|
6395
|
+
await deps.run("git", ["push", "origin", tag]);
|
|
6396
|
+
return `tag ${tag} pushed at ${sha.slice(0, 7)}`;
|
|
6397
|
+
}
|
|
6155
6398
|
async function dispatchDeploy(deps, ctx, stage2, ref, model, watch) {
|
|
6156
6399
|
if (model === "tenant-container") {
|
|
6157
6400
|
const since = (deps.now ?? Date.now)();
|
|
@@ -6217,11 +6460,13 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6217
6460
|
await deps.run("git", ["checkout", "rc"]);
|
|
6218
6461
|
await deps.run("git", ["pull", "--ff-only", "origin", "rc"]);
|
|
6219
6462
|
await deps.run("git", ["merge", "development", "--no-edit"]);
|
|
6220
|
-
await deps.run("git", ["
|
|
6463
|
+
const rcSha = requireValue(clean(await deps.run("git", ["rev-parse", "rc"])), "rc sha");
|
|
6464
|
+
await ensureTagPushed(deps, tag2, rcSha);
|
|
6465
|
+
const requiredChecks2 = await discoverRequiredCheckContexts(deps, ctx, "rc");
|
|
6466
|
+
const checks2 = await waitForRequiredTrainChecks(deps, ctx, rcSha, requiredChecks2);
|
|
6221
6467
|
await deps.run("git", ["push", "origin", "rc"]);
|
|
6222
|
-
await deps.run("git", ["push", "origin", tag2]);
|
|
6223
6468
|
const d2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch);
|
|
6224
|
-
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 };
|
|
6469
|
+
return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, checks: checks2, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, deployStatus: d2.deployStatus };
|
|
6225
6470
|
}
|
|
6226
6471
|
await requireBranch(deps, "rc");
|
|
6227
6472
|
ensurePositiveCount(
|
|
@@ -6242,13 +6487,15 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6242
6487
|
if (predicted.length === 0) {
|
|
6243
6488
|
await deps.run("git", ["merge", "rc", "--no-edit"]);
|
|
6244
6489
|
} else {
|
|
6245
|
-
await
|
|
6490
|
+
await mergeWithSpineResolution(deps, "rc", "rc -> main", "theirs");
|
|
6246
6491
|
}
|
|
6247
6492
|
const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
|
|
6248
6493
|
await verifyHubDistributionVersion(deps, deployModel, tag);
|
|
6249
|
-
await deps.run("git", ["
|
|
6494
|
+
const releaseSha = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
|
|
6495
|
+
await ensureTagPushed(deps, tag, releaseSha);
|
|
6496
|
+
const requiredChecks = await discoverRequiredCheckContexts(deps, ctx, "main");
|
|
6497
|
+
const checks = await waitForRequiredTrainChecks(deps, ctx, releaseSha, requiredChecks);
|
|
6250
6498
|
await deps.run("git", ["push", "origin", "main"]);
|
|
6251
|
-
await deps.run("git", ["push", "origin", tag]);
|
|
6252
6499
|
await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
|
|
6253
6500
|
const d = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch);
|
|
6254
6501
|
const retirement = await retireRcRuntime(deps, ctx, deployModel, d.deployStatus, releasedRcSha);
|
|
@@ -6264,6 +6511,7 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6264
6511
|
tag,
|
|
6265
6512
|
deployModel,
|
|
6266
6513
|
promoted: true,
|
|
6514
|
+
checks,
|
|
6267
6515
|
dispatch: d.note,
|
|
6268
6516
|
runId: d.runId,
|
|
6269
6517
|
runUrl: d.runUrl,
|
|
@@ -6334,6 +6582,326 @@ async function runTenantRedeploy(deps, options) {
|
|
|
6334
6582
|
return { ...ctx, command: "tenant-redeploy", stage: stage2, ref, deployModel, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, deployStatus: d.deployStatus };
|
|
6335
6583
|
}
|
|
6336
6584
|
|
|
6585
|
+
// src/hotfix-apply.ts
|
|
6586
|
+
var HOTFIX_RELEASE_WORKFLOWS = ["deploy.yml", "publish.yml"];
|
|
6587
|
+
var HOTFIX_RUN_FIND_ATTEMPTS = 10;
|
|
6588
|
+
var HOTFIX_RUN_FIND_DELAY_MS = 15e3;
|
|
6589
|
+
function clean2(out) {
|
|
6590
|
+
return out.trim();
|
|
6591
|
+
}
|
|
6592
|
+
function sleeper(deps) {
|
|
6593
|
+
return deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6594
|
+
}
|
|
6595
|
+
function normalizeHotfixVersion(input) {
|
|
6596
|
+
const m = /^v?(\d+\.\d+\.\d+)$/.exec(input.trim());
|
|
6597
|
+
if (!m) throw new Error(`hotfix version must be vX.Y.Z (PATCH release), got "${input}"`);
|
|
6598
|
+
return { tag: `v${m[1]}`, version: m[1] };
|
|
6599
|
+
}
|
|
6600
|
+
async function deriveHotfixVersion(deps) {
|
|
6601
|
+
const out = await deps.run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--merged", "origin/main", "--sort=-v:refname"]);
|
|
6602
|
+
const baseTag = out.split("\n").map((s) => s.trim()).find((t) => /^v\d+\.\d+\.\d+$/.test(t));
|
|
6603
|
+
if (!baseTag) throw new Error("no vX.Y.Z release tag found on origin/main \u2014 cannot derive the hotfix PATCH version");
|
|
6604
|
+
const [major, minor, patch] = baseTag.slice(1).split(".").map(Number);
|
|
6605
|
+
const version = `${major}.${minor}.${patch + 1}`;
|
|
6606
|
+
return { tag: `v${version}`, version, baseTag };
|
|
6607
|
+
}
|
|
6608
|
+
function hotfixBranch(tag) {
|
|
6609
|
+
return `hotfix/${tag}`;
|
|
6610
|
+
}
|
|
6611
|
+
async function findHotfixPr(deps, ctx, tag) {
|
|
6612
|
+
const out = await deps.run("gh", [
|
|
6613
|
+
"pr",
|
|
6614
|
+
"list",
|
|
6615
|
+
"--repo",
|
|
6616
|
+
ctx.repo,
|
|
6617
|
+
"--head",
|
|
6618
|
+
hotfixBranch(tag),
|
|
6619
|
+
"--base",
|
|
6620
|
+
"main",
|
|
6621
|
+
"--state",
|
|
6622
|
+
"all",
|
|
6623
|
+
"--limit",
|
|
6624
|
+
"1",
|
|
6625
|
+
"--json",
|
|
6626
|
+
"number,state,url,mergeCommit"
|
|
6627
|
+
]);
|
|
6628
|
+
const rows = JSON.parse(out || "[]");
|
|
6629
|
+
return rows[0] ?? null;
|
|
6630
|
+
}
|
|
6631
|
+
async function resolveHotfixSource(deps, ctx, from) {
|
|
6632
|
+
const prMatch = /^#?(\d+)$/.exec(from.trim());
|
|
6633
|
+
if (prMatch) {
|
|
6634
|
+
const num = prMatch[1];
|
|
6635
|
+
const out = await deps.run("gh", ["pr", "view", num, "--repo", ctx.repo, "--json", "state,baseRefName,mergeCommit"]);
|
|
6636
|
+
const pr2 = JSON.parse(out);
|
|
6637
|
+
if (pr2.state !== "MERGED") throw new Error(`PR #${num} is ${pr2.state ?? "unknown"}, not MERGED \u2014 hotfix start cherry-picks an already-merged development fix`);
|
|
6638
|
+
if (pr2.baseRefName !== "development") throw new Error(`PR #${num} merged into ${pr2.baseRefName ?? "unknown"}, not development \u2014 name the commit SHA explicitly if this is intentional`);
|
|
6639
|
+
const sha2 = pr2.mergeCommit?.oid;
|
|
6640
|
+
if (!sha2) throw new Error(`PR #${num} has no merge commit recorded \u2014 name the commit SHA explicitly`);
|
|
6641
|
+
return { sha: sha2, label: `PR #${num} (${sha2.slice(0, 7)})` };
|
|
6642
|
+
}
|
|
6643
|
+
const sha = clean2(await deps.run("git", ["rev-parse", "--verify", `${from}^{commit}`]));
|
|
6644
|
+
if (!sha) throw new Error(`could not resolve commit ${from}`);
|
|
6645
|
+
return { sha, label: sha.slice(0, 7) };
|
|
6646
|
+
}
|
|
6647
|
+
async function runHotfixStart(deps, options) {
|
|
6648
|
+
const ctx = await buildTrainApplyContext(deps);
|
|
6649
|
+
const status = await deps.run("git", ["status", "--porcelain"]);
|
|
6650
|
+
if (status.trim()) throw new Error("working tree must be clean before hotfix start");
|
|
6651
|
+
await deps.run("git", ["fetch", "origin", "--tags"]);
|
|
6652
|
+
const { tag, version } = await deriveHotfixVersion(deps);
|
|
6653
|
+
const branch = hotfixBranch(tag);
|
|
6654
|
+
const notes = [];
|
|
6655
|
+
const existingPr = await findHotfixPr(deps, ctx, tag);
|
|
6656
|
+
if (existingPr) {
|
|
6657
|
+
return {
|
|
6658
|
+
...ctx,
|
|
6659
|
+
command: "hotfix-start",
|
|
6660
|
+
tag,
|
|
6661
|
+
version,
|
|
6662
|
+
branch,
|
|
6663
|
+
source: options.from,
|
|
6664
|
+
prUrl: existingPr.url,
|
|
6665
|
+
reused: true,
|
|
6666
|
+
notes: [`hotfix PR for ${tag} already exists (#${existingPr.number}, ${existingPr.state}) \u2014 reused; next: merge it, then mmi-cli hotfix release ${tag}`]
|
|
6667
|
+
};
|
|
6668
|
+
}
|
|
6669
|
+
const { sha, label } = await resolveHotfixSource(deps, ctx, options.from);
|
|
6670
|
+
const remoteBranch = clean2(await deps.run("git", ["ls-remote", "origin", `refs/heads/${branch}`]));
|
|
6671
|
+
if (remoteBranch) {
|
|
6672
|
+
await deps.run("git", ["checkout", branch]);
|
|
6673
|
+
await deps.run("git", ["pull", "--ff-only", "origin", branch]);
|
|
6674
|
+
notes.push(`branch ${branch} already on origin \u2014 reused (cherry-pick/bump assumed present; PR step resumes)`);
|
|
6675
|
+
} else {
|
|
6676
|
+
await deps.run("git", ["checkout", "-B", branch, "origin/main"]);
|
|
6677
|
+
try {
|
|
6678
|
+
await deps.run("git", ["cherry-pick", "-x", sha]);
|
|
6679
|
+
} catch (e) {
|
|
6680
|
+
await deps.run("git", ["cherry-pick", "--abort"]);
|
|
6681
|
+
throw new Error(`cherry-pick of ${label} onto ${branch} conflicted \u2014 aborted; resolve by hand on a manual hotfix branch, keeping the -x trailer (${e.message ?? e})`);
|
|
6682
|
+
}
|
|
6683
|
+
notes.push(`cherry-picked ${label} onto ${branch} (from origin/main, -x trailer recorded)`);
|
|
6684
|
+
await deps.run("node", ["scripts/release-distribution.mjs", "prepare", version]);
|
|
6685
|
+
const changedFiles = (await deps.run("node", ["scripts/release-distribution.mjs", "changed-files"])).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
6686
|
+
await deps.run("git", ["add", "--", ...changedFiles]);
|
|
6687
|
+
const staged = await deps.run("git", ["diff", "--cached", "--name-only"]);
|
|
6688
|
+
if (staged.trim()) {
|
|
6689
|
+
await deps.run("git", ["commit", "-m", `hotfix ${tag}: lock plugin set + @mutmutco/cli distribution to ${version}`]);
|
|
6690
|
+
notes.push(`distribution prepared + committed for ${version} (${changedFiles.length} locked paths)`);
|
|
6691
|
+
} else {
|
|
6692
|
+
notes.push("distribution prepare produced no changes \u2014 nothing extra committed");
|
|
6693
|
+
}
|
|
6694
|
+
await deps.run("git", ["push", "-u", "origin", branch]);
|
|
6695
|
+
}
|
|
6696
|
+
const prUrl = clean2(await deps.run("gh", [
|
|
6697
|
+
"pr",
|
|
6698
|
+
"create",
|
|
6699
|
+
"--repo",
|
|
6700
|
+
ctx.repo,
|
|
6701
|
+
"--base",
|
|
6702
|
+
"main",
|
|
6703
|
+
"--head",
|
|
6704
|
+
branch,
|
|
6705
|
+
"--title",
|
|
6706
|
+
`[hotfix] ${tag}`,
|
|
6707
|
+
"--body",
|
|
6708
|
+
`Hotfix ${tag}: cherry-pick of ${label} onto origin/main with the locked distribution bump.
|
|
6709
|
+
|
|
6710
|
+
Merge this PR (human-initiated), then run \`mmi-cli hotfix release ${tag}\`.`
|
|
6711
|
+
]));
|
|
6712
|
+
notes.push(`opened hotfix PR ${prUrl} \u2014 merge it (human-initiated), then: mmi-cli hotfix release ${tag}`);
|
|
6713
|
+
return { ...ctx, command: "hotfix-start", tag, version, branch, source: label, prUrl, reused: Boolean(remoteBranch), notes };
|
|
6714
|
+
}
|
|
6715
|
+
async function watchReleaseRun(deps, ctx, workflow, sha) {
|
|
6716
|
+
const sleep = sleeper(deps);
|
|
6717
|
+
for (let attempt = 0; attempt < HOTFIX_RUN_FIND_ATTEMPTS; attempt++) {
|
|
6718
|
+
if (attempt > 0) await sleep(HOTFIX_RUN_FIND_DELAY_MS);
|
|
6719
|
+
let rows;
|
|
6720
|
+
try {
|
|
6721
|
+
const out = await deps.run("gh", [
|
|
6722
|
+
"run",
|
|
6723
|
+
"list",
|
|
6724
|
+
"--repo",
|
|
6725
|
+
ctx.repo,
|
|
6726
|
+
"--workflow",
|
|
6727
|
+
workflow,
|
|
6728
|
+
"--event",
|
|
6729
|
+
"release",
|
|
6730
|
+
"--limit",
|
|
6731
|
+
"10",
|
|
6732
|
+
"--json",
|
|
6733
|
+
"databaseId,url,headSha,status,conclusion"
|
|
6734
|
+
]);
|
|
6735
|
+
rows = JSON.parse(out);
|
|
6736
|
+
} catch {
|
|
6737
|
+
continue;
|
|
6738
|
+
}
|
|
6739
|
+
const run = rows.find((r) => r.headSha === sha && typeof r.databaseId === "number");
|
|
6740
|
+
if (!run) continue;
|
|
6741
|
+
if (run.status === "completed") return { workflow, url: run.url, conclusion: run.conclusion ?? "unknown" };
|
|
6742
|
+
try {
|
|
6743
|
+
await deps.run("gh", ["run", "watch", String(run.databaseId), "--repo", ctx.repo, "--exit-status"]);
|
|
6744
|
+
return { workflow, url: run.url, conclusion: "success" };
|
|
6745
|
+
} catch {
|
|
6746
|
+
return { workflow, url: run.url, conclusion: "failure" };
|
|
6747
|
+
}
|
|
6748
|
+
}
|
|
6749
|
+
return { workflow, conclusion: "not-found" };
|
|
6750
|
+
}
|
|
6751
|
+
async function runHotfixRelease(deps, versionInput) {
|
|
6752
|
+
const ctx = await buildTrainApplyContext(deps);
|
|
6753
|
+
const { tag, version } = normalizeHotfixVersion(versionInput);
|
|
6754
|
+
const status = await deps.run("git", ["status", "--porcelain"]);
|
|
6755
|
+
if (status.trim()) throw new Error("working tree must be clean before hotfix release");
|
|
6756
|
+
await deps.run("git", ["fetch", "origin", "--tags"]);
|
|
6757
|
+
const pr2 = await findHotfixPr(deps, ctx, tag);
|
|
6758
|
+
if (!pr2) throw new Error(`no hotfix PR found for ${tag} (head ${hotfixBranch(tag)}, base main) \u2014 run mmi-cli hotfix start first`);
|
|
6759
|
+
if (pr2.state !== "MERGED") throw new Error(`hotfix PR #${pr2.number} for ${tag} is ${pr2.state}, not MERGED \u2014 merge it (human-initiated), then rerun`);
|
|
6760
|
+
const mergedSha = pr2.mergeCommit?.oid ?? "";
|
|
6761
|
+
if (!mergedSha) throw new Error(`hotfix PR #${pr2.number} reports no merge commit \u2014 cannot pin the release SHA`);
|
|
6762
|
+
await deps.run("git", ["merge-base", "--is-ancestor", mergedSha, "origin/main"]).catch(() => {
|
|
6763
|
+
throw new Error(`merged hotfix SHA ${mergedSha.slice(0, 7)} is not on origin/main \u2014 refusing to tag`);
|
|
6764
|
+
});
|
|
6765
|
+
const required = await discoverRequiredCheckContexts(deps, ctx, "main");
|
|
6766
|
+
const checks = await waitForRequiredTrainChecks(deps, ctx, mergedSha, required);
|
|
6767
|
+
const tagNote = await ensureTagPushed(deps, tag, mergedSha);
|
|
6768
|
+
let releaseNote;
|
|
6769
|
+
let releaseExists = false;
|
|
6770
|
+
try {
|
|
6771
|
+
await deps.run("gh", ["release", "view", tag, "--repo", ctx.repo, "--json", "tagName"]);
|
|
6772
|
+
releaseExists = true;
|
|
6773
|
+
} catch {
|
|
6774
|
+
}
|
|
6775
|
+
if (releaseExists) {
|
|
6776
|
+
releaseNote = `Release ${tag} already exists \u2014 resumed without recreating`;
|
|
6777
|
+
} else {
|
|
6778
|
+
const tagCommit = clean2(await deps.run("git", ["rev-parse", `${tag}^{commit}`]));
|
|
6779
|
+
await deps.run("gh", ["release", "create", tag, "--repo", ctx.repo, "--target", tagCommit, "--generate-notes", "--latest"]);
|
|
6780
|
+
releaseNote = `Release ${tag} created (target ${tagCommit.slice(0, 7)})`;
|
|
6781
|
+
}
|
|
6782
|
+
const runs = [];
|
|
6783
|
+
for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
|
|
6784
|
+
runs.push(await watchReleaseRun(deps, ctx, workflow, mergedSha));
|
|
6785
|
+
}
|
|
6786
|
+
const previousRef = clean2(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
|
|
6787
|
+
let verifyNote;
|
|
6788
|
+
const publishSucceeded = runs.find((r) => r.workflow === "publish.yml")?.conclusion === "success";
|
|
6789
|
+
try {
|
|
6790
|
+
await deps.run("git", ["-c", "advice.detachedHead=false", "checkout", tag]);
|
|
6791
|
+
const verifyArgs = ["scripts/release-distribution.mjs", "verify", version, ...publishSucceeded ? [] : ["--skip-npm-view"]];
|
|
6792
|
+
await deps.run("node", verifyArgs);
|
|
6793
|
+
verifyNote = `distribution verified at ${tag}${publishSucceeded ? " (npm included)" : " (npm view skipped \u2014 publish run not confirmed)"}`;
|
|
6794
|
+
} finally {
|
|
6795
|
+
if (previousRef && previousRef !== "HEAD") await deps.run("git", ["checkout", previousRef]);
|
|
6796
|
+
}
|
|
6797
|
+
return { ...ctx, command: "hotfix-release", tag, mergedSha, checks, tagNote, releaseNote, runs, verifyNote };
|
|
6798
|
+
}
|
|
6799
|
+
function versionAtLeast(actual, wanted) {
|
|
6800
|
+
const pa = actual.split(".").map(Number);
|
|
6801
|
+
const pw = wanted.split(".").map(Number);
|
|
6802
|
+
if (pa.length < 3 || pa.some(Number.isNaN) || pw.length < 3 || pw.some(Number.isNaN)) return false;
|
|
6803
|
+
for (let i = 0; i < 3; i += 1) {
|
|
6804
|
+
if (pa[i] !== pw[i]) return pa[i] > pw[i];
|
|
6805
|
+
}
|
|
6806
|
+
return true;
|
|
6807
|
+
}
|
|
6808
|
+
function deriveHotfixState(f) {
|
|
6809
|
+
if (!f.branchExists && !f.pr && !f.tagPushed && !f.releaseExists) {
|
|
6810
|
+
return { state: "not-started", next: `mmi-cli hotfix start --from <pr#|sha> (would mint ${f.tag})` };
|
|
6811
|
+
}
|
|
6812
|
+
if (!f.pr) {
|
|
6813
|
+
return { state: "branch-pushed (no PR)", next: `mmi-cli hotfix start --from <pr#|sha> (resumes at the PR step for ${f.tag})` };
|
|
6814
|
+
}
|
|
6815
|
+
if (f.pr.state === "OPEN") {
|
|
6816
|
+
return { state: "pr-open", next: `merge hotfix PR #${f.pr.number} (human-initiated), then mmi-cli hotfix release ${f.tag}` };
|
|
6817
|
+
}
|
|
6818
|
+
if (f.pr.state !== "MERGED") {
|
|
6819
|
+
return { state: `pr-${(f.pr.state ?? "unknown").toLowerCase()}`, next: `hotfix PR #${f.pr.number} is ${f.pr.state} \u2014 restart with mmi-cli hotfix start if the fix is still needed` };
|
|
6820
|
+
}
|
|
6821
|
+
if (!f.tagPushed || !f.releaseExists) {
|
|
6822
|
+
return { state: "pr-merged (not released)", next: `mmi-cli hotfix release ${f.tag}` };
|
|
6823
|
+
}
|
|
6824
|
+
if (!f.devDistribution.aligned) {
|
|
6825
|
+
return {
|
|
6826
|
+
state: `UNFINISHED \u2014 released but development distribution manifests behind (dev ${f.devDistribution.version} < ${f.version})`,
|
|
6827
|
+
next: `forward-bump development (never a back-merge): branch from origin/development, node scripts/release-distribution.mjs prepare ${f.version}, land by PR (/hotfix Step 5)`
|
|
6828
|
+
};
|
|
6829
|
+
}
|
|
6830
|
+
return { state: "complete", next: "nothing \u2014 pipeline complete (rc absorbs the fix at the next /rcand; /release guards coverage)" };
|
|
6831
|
+
}
|
|
6832
|
+
async function runHotfixStatus(deps, versionInput) {
|
|
6833
|
+
const ctx = await buildTrainApplyContext(deps);
|
|
6834
|
+
await deps.run("git", ["fetch", "origin", "--tags"]);
|
|
6835
|
+
let tag;
|
|
6836
|
+
let version;
|
|
6837
|
+
if (versionInput) {
|
|
6838
|
+
({ tag, version } = normalizeHotfixVersion(versionInput));
|
|
6839
|
+
} else {
|
|
6840
|
+
const latest = await deps.run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--merged", "origin/main", "--sort=-v:refname"]).then((out) => out.split("\n").map((s) => s.trim()).find((t) => /^v\d+\.\d+\.\d+$/.test(t)));
|
|
6841
|
+
if (latest) {
|
|
6842
|
+
const latestFacts = await gatherHotfixFacts(deps, ctx, latest, latest.slice(1));
|
|
6843
|
+
const latestDerived = deriveHotfixState(latestFacts);
|
|
6844
|
+
if (latestDerived.state !== "complete") {
|
|
6845
|
+
return { ...ctx, command: "hotfix-status", ...latestFacts, ...latestDerived };
|
|
6846
|
+
}
|
|
6847
|
+
}
|
|
6848
|
+
({ tag, version } = await deriveHotfixVersion(deps));
|
|
6849
|
+
}
|
|
6850
|
+
const facts = await gatherHotfixFacts(deps, ctx, tag, version);
|
|
6851
|
+
return { ...ctx, command: "hotfix-status", ...facts, ...deriveHotfixState(facts) };
|
|
6852
|
+
}
|
|
6853
|
+
async function gatherHotfixFacts(deps, ctx, tag, version) {
|
|
6854
|
+
const branchExists = Boolean(clean2(await deps.run("git", ["ls-remote", "origin", `refs/heads/${hotfixBranch(tag)}`])));
|
|
6855
|
+
const pr2 = await findHotfixPr(deps, ctx, tag);
|
|
6856
|
+
const remoteTag = clean2(await deps.run("git", ["ls-remote", "origin", `refs/tags/${tag}`]));
|
|
6857
|
+
const tagPushed = Boolean(remoteTag);
|
|
6858
|
+
const tagSha = remoteTag.split(/\s+/)[0] || "";
|
|
6859
|
+
let releaseExists = false;
|
|
6860
|
+
try {
|
|
6861
|
+
await deps.run("gh", ["release", "view", tag, "--repo", ctx.repo, "--json", "tagName"]);
|
|
6862
|
+
releaseExists = true;
|
|
6863
|
+
} catch {
|
|
6864
|
+
}
|
|
6865
|
+
const runs = [];
|
|
6866
|
+
if (releaseExists && tagSha) {
|
|
6867
|
+
for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
|
|
6868
|
+
try {
|
|
6869
|
+
const out = await deps.run("gh", [
|
|
6870
|
+
"run",
|
|
6871
|
+
"list",
|
|
6872
|
+
"--repo",
|
|
6873
|
+
ctx.repo,
|
|
6874
|
+
"--workflow",
|
|
6875
|
+
workflow,
|
|
6876
|
+
"--event",
|
|
6877
|
+
"release",
|
|
6878
|
+
"--limit",
|
|
6879
|
+
"10",
|
|
6880
|
+
"--json",
|
|
6881
|
+
"databaseId,url,headSha,status,conclusion"
|
|
6882
|
+
]);
|
|
6883
|
+
const run = JSON.parse(out).find((r) => r.headSha === tagSha);
|
|
6884
|
+
runs.push(run ? { workflow, url: run.url, conclusion: run.status === "completed" ? run.conclusion ?? "unknown" : run.status ?? "unknown" } : { workflow, conclusion: "not-found" });
|
|
6885
|
+
} catch {
|
|
6886
|
+
runs.push({ workflow, conclusion: "unknown" });
|
|
6887
|
+
}
|
|
6888
|
+
}
|
|
6889
|
+
}
|
|
6890
|
+
const npmVersion = await deps.run("npm", ["view", "@mutmutco/cli", "version", "--silent"]).then(clean2, () => "unknown");
|
|
6891
|
+
const devVersion = await deps.run("git", ["show", "origin/development:cli/package.json"]).then(
|
|
6892
|
+
(out) => {
|
|
6893
|
+
try {
|
|
6894
|
+
return JSON.parse(out).version ?? "unknown";
|
|
6895
|
+
} catch {
|
|
6896
|
+
return "unknown";
|
|
6897
|
+
}
|
|
6898
|
+
},
|
|
6899
|
+
() => "unknown"
|
|
6900
|
+
);
|
|
6901
|
+
const devDistribution = { version: devVersion, aligned: versionAtLeast(devVersion, version) };
|
|
6902
|
+
return { tag, version, branchExists, pr: pr2 ? { number: pr2.number, state: pr2.state, url: pr2.url } : null, tagPushed, releaseExists, runs, npmVersion, devDistribution };
|
|
6903
|
+
}
|
|
6904
|
+
|
|
6337
6905
|
// src/port-registry.ts
|
|
6338
6906
|
var import_node_fs4 = require("node:fs");
|
|
6339
6907
|
|
|
@@ -7684,8 +8252,8 @@ function resolveKbSource(rawBase) {
|
|
|
7684
8252
|
return { owner: m[1], repo: m[2], ref: m[3] };
|
|
7685
8253
|
}
|
|
7686
8254
|
function buildKbGetArgs(src, path2) {
|
|
7687
|
-
const
|
|
7688
|
-
return ["api", `repos/${src.owner}/${src.repo}/contents/${
|
|
8255
|
+
const clean3 = path2.replace(/^\/+/, "");
|
|
8256
|
+
return ["api", `repos/${src.owner}/${src.repo}/contents/${clean3}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
|
|
7689
8257
|
}
|
|
7690
8258
|
function buildKbTreeArgs(src) {
|
|
7691
8259
|
return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
|
|
@@ -9773,17 +10341,40 @@ async function runBoardRead(o) {
|
|
|
9773
10341
|
}
|
|
9774
10342
|
var board = program2.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
|
|
9775
10343
|
board.command("read", { isDefault: true }).description("read the board and print user-owned, claimable, and taken items").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--bundle-details", "fetch body/comments only for user-owned and claimable issues").option("--allow-partial", "return partial board results when later page/detail reads fail").action((o) => runBoardRead(o));
|
|
9776
|
-
board.command("claim <
|
|
10344
|
+
board.command("claim <issues...>").description("assign Todo issues and move their Project v2 Status to In Progress (one or more refs)").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--for <login>", "assign to this login instead of @me \u2014 agent claims on behalf of the master").option("--allow-partial", "return success JSON if assignment succeeds but the status move fails").action(async (issueRefs, o) => {
|
|
10345
|
+
if (issueRefs.length === 1) {
|
|
10346
|
+
const issueRef = issueRefs[0];
|
|
10347
|
+
try {
|
|
10348
|
+
const result = await claimBoardIssue({
|
|
10349
|
+
config: await loadConfigForBoardSelector(issueRef, o.repo),
|
|
10350
|
+
selector: issueRef,
|
|
10351
|
+
repo: o.repo,
|
|
10352
|
+
assignee: o.for,
|
|
10353
|
+
allowPartial: o.allowPartial
|
|
10354
|
+
});
|
|
10355
|
+
if (o.json) return console.log(JSON.stringify(result));
|
|
10356
|
+
console.log(result.partial ? `Partially claimed ${result.item.ref}: ${result.warning}` : `Claimed ${result.item.ref} - In Progress`);
|
|
10357
|
+
} catch (e) {
|
|
10358
|
+
fail(`board claim failed: ${e.message}`);
|
|
10359
|
+
}
|
|
10360
|
+
return;
|
|
10361
|
+
}
|
|
9777
10362
|
try {
|
|
9778
|
-
const
|
|
9779
|
-
config: await loadConfigForBoardSelector(
|
|
9780
|
-
|
|
10363
|
+
const bulk = await claimBoardIssues({
|
|
10364
|
+
config: await loadConfigForBoardSelector(issueRefs[0], o.repo),
|
|
10365
|
+
selectors: issueRefs,
|
|
9781
10366
|
repo: o.repo,
|
|
9782
10367
|
assignee: o.for,
|
|
9783
10368
|
allowPartial: o.allowPartial
|
|
9784
10369
|
});
|
|
9785
|
-
if (o.json)
|
|
9786
|
-
|
|
10370
|
+
if (o.json) {
|
|
10371
|
+
console.log(JSON.stringify(bulk.results));
|
|
10372
|
+
} else {
|
|
10373
|
+
for (const result of bulk.results) {
|
|
10374
|
+
console.log(result.claimed ? result.partial ? `Partially claimed ${result.ref}: ${result.warning}` : `Claimed ${result.ref} - In Progress` : `Skipped ${result.ref}: ${result.reason}`);
|
|
10375
|
+
}
|
|
10376
|
+
}
|
|
10377
|
+
if (bulk.failed > 0) process.exitCode = 1;
|
|
9787
10378
|
} catch (e) {
|
|
9788
10379
|
fail(`board claim failed: ${e.message}`);
|
|
9789
10380
|
}
|
|
@@ -10024,10 +10615,11 @@ program2.command("stage-live").description("explain that remote rc/live environm
|
|
|
10024
10615
|
});
|
|
10025
10616
|
var GH_TRAIN_TIMEOUT_MS = 3e4;
|
|
10026
10617
|
var GH_RUN_WATCH_TIMEOUT_MS = 20 * 6e4;
|
|
10618
|
+
var NODE_PREPARE_TIMEOUT_MS = 10 * 6e4;
|
|
10027
10619
|
function trainApplyDeps() {
|
|
10028
10620
|
return {
|
|
10029
10621
|
run: async (file, args) => {
|
|
10030
|
-
const timeout = file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
|
|
10622
|
+
const timeout = file === "node" && args[1] === "prepare" ? NODE_PREPARE_TIMEOUT_MS : file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
|
|
10031
10623
|
return (await execFileP4(file, args, { timeout })).stdout;
|
|
10032
10624
|
},
|
|
10033
10625
|
runSelf: async (args) => (await execFileP4(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
|
|
@@ -10052,15 +10644,14 @@ function renderTrainApply(commandName, r) {
|
|
|
10052
10644
|
function renderTenantRedeploy(r) {
|
|
10053
10645
|
return `mmi-cli tenant redeploy: ${r.repo} ${r.stage} (ref=${r.ref}) [${r.deployModel}]; ${renderDeployLine(r)}`;
|
|
10054
10646
|
}
|
|
10055
|
-
for (const commandName of ["rcand", "release"
|
|
10056
|
-
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the dispatched tenant-deploy.yml run and report its outcome (tenant-container)").option("--apply",
|
|
10647
|
+
for (const commandName of ["rcand", "release"]) {
|
|
10648
|
+
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the dispatched tenant-deploy.yml run and report its outcome (tenant-container)").option("--apply", "execute the guarded master-only train after explicit approval").action(async (o) => {
|
|
10057
10649
|
try {
|
|
10058
10650
|
await requireFreshTrainCli(commandName);
|
|
10059
10651
|
} catch (e) {
|
|
10060
10652
|
return fail(`${commandName}: ${e.message}`);
|
|
10061
10653
|
}
|
|
10062
10654
|
if (o.apply) {
|
|
10063
|
-
if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
|
|
10064
10655
|
try {
|
|
10065
10656
|
const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch });
|
|
10066
10657
|
return printLine(o.json ? JSON.stringify(result, null, 2) : renderTrainApply(commandName, result));
|
|
@@ -10072,6 +10663,51 @@ for (const commandName of ["rcand", "release", "hotfix"]) {
|
|
|
10072
10663
|
console.log(o.json ? JSON.stringify({ command: commandName, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps));
|
|
10073
10664
|
});
|
|
10074
10665
|
}
|
|
10666
|
+
function renderHotfixStart(r) {
|
|
10667
|
+
return [`mmi-cli hotfix start: ${r.tag} (${r.branch}, from ${r.source})${r.reused ? " [reused]" : ""}`, ...r.notes.map((n) => ` - ${n}`)].join("\n");
|
|
10668
|
+
}
|
|
10669
|
+
function renderHotfixRelease(r) {
|
|
10670
|
+
return [
|
|
10671
|
+
`mmi-cli hotfix release: ${r.tag} at ${r.mergedSha.slice(0, 7)} on ${r.repo}`,
|
|
10672
|
+
` - checks: ${r.checks}`,
|
|
10673
|
+
` - ${r.tagNote}`,
|
|
10674
|
+
` - ${r.releaseNote}`,
|
|
10675
|
+
...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.url ? ` (${run.url})` : ""}`),
|
|
10676
|
+
` - ${r.verifyNote}`,
|
|
10677
|
+
` - next: mmi-cli hotfix status ${r.tag} (no back-merge \u2014 development already has the fix; align its distribution manifests by PR if status says behind)`
|
|
10678
|
+
].join("\n");
|
|
10679
|
+
}
|
|
10680
|
+
function renderHotfixStatus(r) {
|
|
10681
|
+
return [
|
|
10682
|
+
`mmi-cli hotfix status: ${r.tag} on ${r.repo} \u2014 ${r.state}`,
|
|
10683
|
+
` - branch: ${r.branchExists ? "pushed" : "absent"} \xB7 PR: ${r.pr ? `#${r.pr.number} ${r.pr.state}` : "none"} \xB7 tag: ${r.tagPushed ? "pushed" : "absent"} \xB7 Release: ${r.releaseExists ? "exists" : "absent"}`,
|
|
10684
|
+
...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.url ? ` (${run.url})` : ""}`),
|
|
10685
|
+
` - npm @mutmutco/cli: ${r.npmVersion} \xB7 development manifests: ${r.devDistribution.version}${r.devDistribution.aligned ? " (aligned)" : " (behind)"}`,
|
|
10686
|
+
` - next: ${r.next}`
|
|
10687
|
+
].join("\n");
|
|
10688
|
+
}
|
|
10689
|
+
async function runHotfixSub(sub, body, json, render) {
|
|
10690
|
+
try {
|
|
10691
|
+
await requireFreshTrainCli("hotfix");
|
|
10692
|
+
const result = await body();
|
|
10693
|
+
printLine(json ? JSON.stringify(result, null, 2) : render(result));
|
|
10694
|
+
} catch (e) {
|
|
10695
|
+
fail(`hotfix ${sub}: ${e.message}`);
|
|
10696
|
+
}
|
|
10697
|
+
}
|
|
10698
|
+
var hotfixCmd = program2.command("hotfix").description("stepwise hotfix orchestrator: start \u2192 release, with status (bare command prints the dry-run plan; no back-merge \u2014 #839)").option("--json", "machine-readable output").option("--apply", "not a verb; use the hotfix subcommands (start/release/status)").action(async (o) => {
|
|
10699
|
+
try {
|
|
10700
|
+
await requireFreshTrainCli("hotfix");
|
|
10701
|
+
} catch (e) {
|
|
10702
|
+
return fail(`hotfix: ${e.message}`);
|
|
10703
|
+
}
|
|
10704
|
+
if (o.apply) return fail("hotfix: use the stepwise subcommands \u2014 mmi-cli hotfix start --from <pr#|sha> \xB7 release <vX.Y.Z> \xB7 status [vX.Y.Z]");
|
|
10705
|
+
const steps = trainPlan("hotfix");
|
|
10706
|
+
console.log(o.json ? JSON.stringify({ command: "hotfix", steps }, null, 2) : renderSteps("mmi-cli hotfix: dry-run plan", steps));
|
|
10707
|
+
});
|
|
10708
|
+
hotfixCmd.command("start").description("cherry-pick a merged development PR (or SHA) onto hotfix/vX.Y.Z from origin/main, bump the distribution, open the main-base PR").requiredOption("--from <pr#|sha>", "merged development PR number or commit SHA to cherry-pick").option("--json", "machine-readable output").action(async (o) => runHotfixSub("start", () => runHotfixStart(trainApplyDeps(), { from: o.from }), o.json, renderHotfixStart));
|
|
10709
|
+
hotfixCmd.command("release <version>").description("after the hotfix PR is merged + checks green: tag, GitHub Release, watch deploy/publish, verify distribution (idempotent)").option("--json", "machine-readable output").action(async (version, o) => runHotfixSub("release", () => runHotfixRelease(trainApplyDeps(), version), o.json, renderHotfixRelease));
|
|
10710
|
+
hotfixCmd.command("status [version]").description("derive the full hotfix pipeline state from live git/gh reads and name the exact next subcommand").option("--json", "machine-readable output").action(async (version, o) => runHotfixSub("status", () => runHotfixStatus(trainApplyDeps(), version), o.json, renderHotfixStatus));
|
|
10075
10711
|
var bootstrap = program2.command("bootstrap").description("plan repo bootstrap operations; mutations require master-admin approval").option("--repo <owner/repo>", "target repo").option("--class <class>", "deployable | content", "deployable").option("--json", "machine-readable output").option("--apply", "reserved for future bootstrap execution after explicit master-admin approval").action((o) => {
|
|
10076
10712
|
if (!o.repo) return fail("bootstrap: required option --repo <owner/repo> not specified");
|
|
10077
10713
|
if (o.apply) return fail("bootstrap: execution is not implemented yet; use the dry-run plan and the existing /bootstrap skill");
|
|
@@ -10287,6 +10923,16 @@ function readInstalledPlugins() {
|
|
|
10287
10923
|
return null;
|
|
10288
10924
|
}
|
|
10289
10925
|
}
|
|
10926
|
+
function installedPluginSources() {
|
|
10927
|
+
return ["claude", "codex"].map((surface) => {
|
|
10928
|
+
const recordPath = (0, import_node_path7.join)((0, import_node_os3.homedir)(), `.${surface}`, "plugins", "installed_plugins.json");
|
|
10929
|
+
try {
|
|
10930
|
+
return { surface, installed: JSON.parse((0, import_node_fs6.readFileSync)(recordPath, "utf8")), recordPath };
|
|
10931
|
+
} catch {
|
|
10932
|
+
return { surface, installed: null, recordPath };
|
|
10933
|
+
}
|
|
10934
|
+
});
|
|
10935
|
+
}
|
|
10290
10936
|
function readClaudeSettings() {
|
|
10291
10937
|
try {
|
|
10292
10938
|
return JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path7.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
|
|
@@ -10333,6 +10979,38 @@ function backupAndWriteInstalledPlugins(records, pluginId) {
|
|
|
10333
10979
|
return false;
|
|
10334
10980
|
}
|
|
10335
10981
|
}
|
|
10982
|
+
function cursorPluginCacheRoot() {
|
|
10983
|
+
return (0, import_node_path7.join)((0, import_node_os3.homedir)(), ".cursor", "plugins", "cache", "mmi", "mmi");
|
|
10984
|
+
}
|
|
10985
|
+
function cursorPluginCachePinSnapshots() {
|
|
10986
|
+
const root = cursorPluginCacheRoot();
|
|
10987
|
+
try {
|
|
10988
|
+
return (0, import_node_fs6.readdirSync)(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => {
|
|
10989
|
+
const path2 = (0, import_node_path7.join)(root, entry.name);
|
|
10990
|
+
const pluginJson = (0, import_node_path7.join)(path2, ".cursor-plugin", "plugin.json");
|
|
10991
|
+
const hooksJson = (0, import_node_path7.join)(path2, "hooks", "hooks.json");
|
|
10992
|
+
let isEmpty = true;
|
|
10993
|
+
try {
|
|
10994
|
+
isEmpty = (0, import_node_fs6.readdirSync)(path2).length === 0;
|
|
10995
|
+
} catch {
|
|
10996
|
+
isEmpty = true;
|
|
10997
|
+
}
|
|
10998
|
+
return {
|
|
10999
|
+
name: entry.name,
|
|
11000
|
+
path: path2,
|
|
11001
|
+
hasPluginJson: (0, import_node_fs6.existsSync)(pluginJson),
|
|
11002
|
+
hasHooksJson: (0, import_node_fs6.existsSync)(hooksJson),
|
|
11003
|
+
isEmpty
|
|
11004
|
+
};
|
|
11005
|
+
});
|
|
11006
|
+
} catch {
|
|
11007
|
+
return [];
|
|
11008
|
+
}
|
|
11009
|
+
}
|
|
11010
|
+
function hubCheckoutForCursorSeed() {
|
|
11011
|
+
const manifest = (0, import_node_path7.join)(process.cwd(), "plugins", "mmi", ".cursor-plugin", "plugin.json");
|
|
11012
|
+
return (0, import_node_fs6.existsSync)(manifest) ? process.cwd() : void 0;
|
|
11013
|
+
}
|
|
10336
11014
|
function mmiPluginCacheRootSnapshots() {
|
|
10337
11015
|
const roots = [
|
|
10338
11016
|
{ surface: "claude", root: (0, import_node_path7.join)((0, import_node_os3.homedir)(), ".claude", "plugins", "cache", "mmi", "mmi") },
|
|
@@ -10395,6 +11073,8 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10395
11073
|
else io.log(MMI_AGENTIC_ONBOARDING_GUIDE.url);
|
|
10396
11074
|
return;
|
|
10397
11075
|
}
|
|
11076
|
+
const repairLocal = !opts.json || Boolean(opts.apply);
|
|
11077
|
+
const repairFull = !opts.json && !opts.banner || Boolean(opts.apply);
|
|
10398
11078
|
const checks = [];
|
|
10399
11079
|
const REWRITE_KEY = "url.https://github.com/.insteadOf";
|
|
10400
11080
|
const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
|
|
@@ -10429,13 +11109,13 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10429
11109
|
repoVersion: readRepoVersion(),
|
|
10430
11110
|
releasedVersion
|
|
10431
11111
|
});
|
|
10432
|
-
if (
|
|
11112
|
+
if (repairFull) versionReport = await applyVersionAutoUpdate(versionReport, (m) => io.err(m));
|
|
10433
11113
|
if (!versionReport.ok) versionReport = { ...versionReport, fix: pluginRecoveryFix(surface) };
|
|
10434
11114
|
checks.push(versionReport);
|
|
10435
11115
|
checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "Hub API URL configured", fix: "set MMI_HUB_URL or use a current MMI CLI/plugin build" });
|
|
10436
11116
|
checks.push(buildAwsCrossAccountCheck({ callerArn }));
|
|
10437
11117
|
let cloneOk = cloneProbe;
|
|
10438
|
-
if (!cloneOk &&
|
|
11118
|
+
if (!cloneOk && repairFull) {
|
|
10439
11119
|
try {
|
|
10440
11120
|
await execFileP4("git", ["config", "--global", "--add", REWRITE_KEY, "git@github.com:"]);
|
|
10441
11121
|
cloneOk = true;
|
|
@@ -10453,7 +11133,7 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10453
11133
|
mirrorFrom: existingMirrorRecord(installed),
|
|
10454
11134
|
surface
|
|
10455
11135
|
});
|
|
10456
|
-
if (!pluginCheck.ok && pluginCheck.recordToInsert &&
|
|
11136
|
+
if (!pluginCheck.ok && pluginCheck.recordToInsert && repairLocal) {
|
|
10457
11137
|
if (writeProjectInstallRecord(pluginCheck.recordToInsert)) {
|
|
10458
11138
|
pluginCheck = { ...pluginCheck, ok: true };
|
|
10459
11139
|
io.err(` \u21BB repaired: registered mmi@mmi project install record \u2014 ${reloadHint} to load MMI commands`);
|
|
@@ -10461,7 +11141,7 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10461
11141
|
}
|
|
10462
11142
|
checks.push(pluginCheck);
|
|
10463
11143
|
let gitignoreCheck = buildGitignoreManagedBlockCheck({ isOrgRepo: Boolean(cfg.sagaApiUrl), content: readGitignore() });
|
|
10464
|
-
if (!gitignoreCheck.ok && gitignoreCheck.contentToWrite &&
|
|
11144
|
+
if (!gitignoreCheck.ok && gitignoreCheck.contentToWrite && repairFull) {
|
|
10465
11145
|
if (writeGitignore(gitignoreCheck.contentToWrite)) {
|
|
10466
11146
|
gitignoreCheck = { ...gitignoreCheck, ok: true };
|
|
10467
11147
|
const drift = gitignoreCheck.seeded ? "inserted the org-managed block" : [
|
|
@@ -10474,7 +11154,7 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10474
11154
|
}
|
|
10475
11155
|
checks.push(gitignoreCheck);
|
|
10476
11156
|
let driftCheck = buildPluginConfigDriftCheck({ isOrgRepo: Boolean(cfg.sagaApiUrl), installed, surface });
|
|
10477
|
-
if (!driftCheck.ok && driftCheck.recordsToWrite &&
|
|
11157
|
+
if (!driftCheck.ok && driftCheck.recordsToWrite && repairLocal) {
|
|
10478
11158
|
if (backupAndWriteInstalledPlugins(driftCheck.recordsToWrite, driftCheck.pluginId)) {
|
|
10479
11159
|
driftCheck = { ...driftCheck, ok: true };
|
|
10480
11160
|
io.err(` \u21BB repaired: collapsed mmi@mmi to one user-scope entry (backup at installed_plugins.json.bak) \u2014 ${reloadHint} to load MMI commands`);
|
|
@@ -10483,15 +11163,16 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10483
11163
|
checks.push(driftCheck);
|
|
10484
11164
|
let installedVersionCheck = buildInstalledPluginVersionCheck({
|
|
10485
11165
|
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
10486
|
-
|
|
11166
|
+
sources: installedPluginSources(),
|
|
10487
11167
|
releasedVersion,
|
|
10488
11168
|
surface
|
|
10489
11169
|
});
|
|
10490
|
-
if (!installedVersionCheck.ok &&
|
|
10491
|
-
|
|
11170
|
+
if (!installedVersionCheck.ok && repairFull) {
|
|
11171
|
+
const claudeStale = installedVersionCheck.staleSurfaces?.some((s) => s.surface === "claude") ?? false;
|
|
11172
|
+
if (claudeStale && await applyClaudePluginHeal(surface, (m) => io.err(m))) {
|
|
10492
11173
|
const healed = buildInstalledPluginVersionCheck({
|
|
10493
11174
|
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
10494
|
-
|
|
11175
|
+
sources: installedPluginSources(),
|
|
10495
11176
|
releasedVersion,
|
|
10496
11177
|
surface
|
|
10497
11178
|
});
|
|
@@ -10509,21 +11190,35 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10509
11190
|
releasedVersion,
|
|
10510
11191
|
installedVersions: installedPluginVersions(installed)
|
|
10511
11192
|
});
|
|
10512
|
-
if (!cacheCleanupCheck.ok && cacheCleanupCheck.quarantinePlan &&
|
|
11193
|
+
if (!cacheCleanupCheck.ok && cacheCleanupCheck.quarantinePlan && repairLocal) {
|
|
10513
11194
|
const moved = quarantinePluginCacheDirs(cacheCleanupCheck.quarantinePlan);
|
|
10514
11195
|
if (moved > 0) {
|
|
10515
11196
|
const surfaces = [...new Set(cacheCleanupCheck.leftovers?.map((entry) => entry.surface) ?? [])].join("/");
|
|
10516
11197
|
const names = cacheCleanupCheck.leftovers?.map((entry) => entry.name).join(", ");
|
|
10517
11198
|
io.err(` \u21BB quarantined ${moved} stale MMI plugin cache dir(s) for ${surfaces || "agent surfaces"}: ${names} \u2014 ${reloadHint} to load MMI commands`);
|
|
10518
11199
|
}
|
|
10519
|
-
cacheCleanupCheck =
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
11200
|
+
cacheCleanupCheck = {
|
|
11201
|
+
...buildMmiPluginCacheCleanupCheck({
|
|
11202
|
+
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
11203
|
+
roots: mmiPluginCacheRootSnapshots(),
|
|
11204
|
+
activeVersion: resolveVersion(),
|
|
11205
|
+
releasedVersion
|
|
11206
|
+
}),
|
|
11207
|
+
...moved > 0 ? { cleanedCount: moved } : {}
|
|
11208
|
+
};
|
|
10525
11209
|
}
|
|
10526
11210
|
checks.push(cacheCleanupCheck);
|
|
11211
|
+
const cursorCacheRoot = cursorPluginCacheRoot();
|
|
11212
|
+
checks.push(
|
|
11213
|
+
buildCursorPluginInstallCheck({
|
|
11214
|
+
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
11215
|
+
surface,
|
|
11216
|
+
cacheRoot: cursorCacheRoot,
|
|
11217
|
+
cacheRootExists: (0, import_node_fs6.existsSync)(cursorCacheRoot),
|
|
11218
|
+
pins: cursorPluginCachePinSnapshots() ?? [],
|
|
11219
|
+
hubCheckout: hubCheckoutForCursorSeed()
|
|
11220
|
+
})
|
|
11221
|
+
);
|
|
10527
11222
|
const gaps = checks.filter((c) => !c.ok);
|
|
10528
11223
|
const resources = doctorResourcesForGaps(gaps);
|
|
10529
11224
|
if (opts.json) {
|
|
@@ -10540,7 +11235,7 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10540
11235
|
io.log(gaps.length ? `
|
|
10541
11236
|
${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
|
|
10542
11237
|
}
|
|
10543
|
-
program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, Hub API default, plugin git clone, plugin install record, .gitignore managed block, plugin config drift, installed plugin version) 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((opts) => runDoctor(opts));
|
|
11238
|
+
program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, Hub API default, plugin git clone, plugin install record, .gitignore managed block, plugin config drift, installed plugin version, Cursor Team Marketplace plugin install) 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 (read-only inspection \u2014 performs no repairs)").option("--apply", "perform the same auto-repairs as the interactive run (combine with --json for a machine-readable repair run)").action((opts) => runDoctor(opts));
|
|
10544
11239
|
program2.command("session-start").description("run the SessionStart verbs (rules sync, saga session+show, saga health, doctor) in one process; docs sync runs detached").action(async () => {
|
|
10545
11240
|
try {
|
|
10546
11241
|
const hook = parseHookInput(await readStdin());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mutmutco/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.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",
|