@mutmutco/cli 2.14.1 → 2.16.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 +1509 -238
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -3393,7 +3393,7 @@ var program = new Command();
|
|
|
3393
3393
|
|
|
3394
3394
|
// src/index.ts
|
|
3395
3395
|
var import_promises2 = require("node:fs/promises");
|
|
3396
|
-
var
|
|
3396
|
+
var import_node_fs7 = require("node:fs");
|
|
3397
3397
|
var import_node_crypto3 = require("node:crypto");
|
|
3398
3398
|
|
|
3399
3399
|
// src/rules-sync.ts
|
|
@@ -3473,7 +3473,10 @@ function buildSessionStartPlan(verbs) {
|
|
|
3473
3473
|
parallel: [
|
|
3474
3474
|
{ name: "rules sync", run: verbs.rulesSync },
|
|
3475
3475
|
{ name: "saga show", run: verbs.sagaShow },
|
|
3476
|
-
{ name: "saga health", run: verbs.sagaHealth }
|
|
3476
|
+
{ name: "saga health", run: verbs.sagaHealth },
|
|
3477
|
+
// whoami (#879): the resolved human lands in the banner so agents act --for them without asking.
|
|
3478
|
+
// Identity reads are memoized process-wide, so this adds no extra gh/Hub round-trip.
|
|
3479
|
+
{ name: "whoami", run: verbs.whoami }
|
|
3477
3480
|
],
|
|
3478
3481
|
sequential: [{ name: "doctor", run: verbs.doctor }]
|
|
3479
3482
|
};
|
|
@@ -3488,6 +3491,29 @@ function northstarPointer() {
|
|
|
3488
3491
|
return "North Stars: run `mmi-cli northstar relevant` to load plans relevant to your task (`northstar list` for all).";
|
|
3489
3492
|
}
|
|
3490
3493
|
|
|
3494
|
+
// src/whoami.ts
|
|
3495
|
+
async function resolveWhoami(deps) {
|
|
3496
|
+
let session;
|
|
3497
|
+
try {
|
|
3498
|
+
session = await deps.hubSession();
|
|
3499
|
+
} catch {
|
|
3500
|
+
session = void 0;
|
|
3501
|
+
}
|
|
3502
|
+
if (session?.login) return { login: session.login, source: "hub-session", sessionExpiresAt: session.expiresAt };
|
|
3503
|
+
let ghLogin;
|
|
3504
|
+
try {
|
|
3505
|
+
ghLogin = await deps.ghLogin();
|
|
3506
|
+
} catch {
|
|
3507
|
+
ghLogin = void 0;
|
|
3508
|
+
}
|
|
3509
|
+
if (ghLogin) return { login: ghLogin, source: "github", sessionExpiresAt: session?.expiresAt };
|
|
3510
|
+
return { source: "unknown" };
|
|
3511
|
+
}
|
|
3512
|
+
function whoamiLine(report) {
|
|
3513
|
+
if (!report.login) return null;
|
|
3514
|
+
return `current human: ${report.login} (source: ${report.source}) \u2014 act for this login (e.g. claim --for ${report.login}); do not ask who the user is.`;
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3491
3517
|
// src/saga-capture.ts
|
|
3492
3518
|
function parseHookInput(stdin) {
|
|
3493
3519
|
try {
|
|
@@ -3501,7 +3527,7 @@ function parseHookInput(stdin) {
|
|
|
3501
3527
|
// src/index.ts
|
|
3502
3528
|
var import_node_child_process6 = require("node:child_process");
|
|
3503
3529
|
var import_node_util6 = require("node:util");
|
|
3504
|
-
var
|
|
3530
|
+
var import_node_path8 = require("node:path");
|
|
3505
3531
|
var import_node_os3 = require("node:os");
|
|
3506
3532
|
|
|
3507
3533
|
// src/saga-head-maintainer.ts
|
|
@@ -3823,6 +3849,49 @@ function defaultHubUrl() {
|
|
|
3823
3849
|
return process.env.MMI_HUB_URL || DEFAULT_HUB_URL;
|
|
3824
3850
|
}
|
|
3825
3851
|
|
|
3852
|
+
// src/client-version.ts
|
|
3853
|
+
var import_node_fs3 = require("node:fs");
|
|
3854
|
+
var import_node_path4 = require("node:path");
|
|
3855
|
+
|
|
3856
|
+
// ../infra/compat.mjs
|
|
3857
|
+
var CLIENT_VERSION_HEADER = "x-client-version";
|
|
3858
|
+
function parseSemver(s) {
|
|
3859
|
+
if (typeof s !== "string") return null;
|
|
3860
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:[-+]|$)/.exec(s.trim());
|
|
3861
|
+
if (!m) return null;
|
|
3862
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
|
|
3863
|
+
}
|
|
3864
|
+
function versionAtLeast(v, min) {
|
|
3865
|
+
const a = parseSemver(v);
|
|
3866
|
+
const b = parseSemver(min);
|
|
3867
|
+
if (!a || !b) return false;
|
|
3868
|
+
if (a.major !== b.major) return a.major > b.major;
|
|
3869
|
+
if (a.minor !== b.minor) return a.minor > b.minor;
|
|
3870
|
+
return a.patch >= b.patch;
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
// src/client-version.ts
|
|
3874
|
+
function resolveClientVersion() {
|
|
3875
|
+
try {
|
|
3876
|
+
const manifest = (0, import_node_path4.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
|
|
3877
|
+
return JSON.parse((0, import_node_fs3.readFileSync)(manifest, "utf8")).version || "0.0.0";
|
|
3878
|
+
} catch {
|
|
3879
|
+
try {
|
|
3880
|
+
const pkg = (0, import_node_path4.join)(__dirname, "..", "package.json");
|
|
3881
|
+
return JSON.parse((0, import_node_fs3.readFileSync)(pkg, "utf8")).version || "0.0.0";
|
|
3882
|
+
} catch {
|
|
3883
|
+
return "0.0.0";
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
function clientVersionHeaders() {
|
|
3888
|
+
return { [CLIENT_VERSION_HEADER]: resolveClientVersion() };
|
|
3889
|
+
}
|
|
3890
|
+
function upgradeRequiredError(res, body) {
|
|
3891
|
+
const minVersion = body && typeof body === "object" && typeof body.minVersion === "string" ? body.minVersion : "a newer version";
|
|
3892
|
+
return `Hub requires mmi-cli >= ${minVersion} \u2014 run mmi-cli doctor (installed ${resolveClientVersion()})`;
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3826
3895
|
// src/saga-health.ts
|
|
3827
3896
|
function buildHealth(i) {
|
|
3828
3897
|
const problems = [];
|
|
@@ -3885,6 +3954,30 @@ async function fetchWithRetry(fetchImpl, url, init, opts = {}) {
|
|
|
3885
3954
|
throw lastErr;
|
|
3886
3955
|
}
|
|
3887
3956
|
|
|
3957
|
+
// src/clean-exit.ts
|
|
3958
|
+
function globalDispatcher() {
|
|
3959
|
+
const g = globalThis;
|
|
3960
|
+
const sym = Object.getOwnPropertySymbols(g).find(
|
|
3961
|
+
(s) => s.description === "undici.globalDispatcher.1" || s.description?.startsWith("undici.globalDispatcher.")
|
|
3962
|
+
);
|
|
3963
|
+
return sym ? g[sym] : void 0;
|
|
3964
|
+
}
|
|
3965
|
+
function destroyHttpPool() {
|
|
3966
|
+
try {
|
|
3967
|
+
const dispatcher = globalDispatcher();
|
|
3968
|
+
if (dispatcher?.destroy) {
|
|
3969
|
+
void dispatcher.destroy();
|
|
3970
|
+
return true;
|
|
3971
|
+
}
|
|
3972
|
+
} catch {
|
|
3973
|
+
}
|
|
3974
|
+
return false;
|
|
3975
|
+
}
|
|
3976
|
+
function hardExit(code) {
|
|
3977
|
+
destroyHttpPool();
|
|
3978
|
+
process.exit(code);
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3888
3981
|
// src/saga-note.ts
|
|
3889
3982
|
var AGENT_SURFACE_TOKENS = ["claude", "codex", "cursor", "gemini"];
|
|
3890
3983
|
var ROUTE_LEVEL_403 = "saga API route-level 403 from HubSessionAuthorizer/session policy";
|
|
@@ -4301,6 +4394,7 @@ var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
|
|
|
4301
4394
|
var rawExecFileP2 = (0, import_node_util4.promisify)(import_node_child_process4.execFile);
|
|
4302
4395
|
var BOARD_GIT_TIMEOUT_MS = 1e4;
|
|
4303
4396
|
var WRITE_PROBE_CONCURRENCY = 8;
|
|
4397
|
+
var CLAIM_CONCURRENCY = 5;
|
|
4304
4398
|
var execFileP2 = (file, args, options = {}) => (
|
|
4305
4399
|
// encoding 'utf8' guarantees string output at runtime; the cast pins the type (promisify's
|
|
4306
4400
|
// overloads widen to string|Buffer when options is spread in).
|
|
@@ -4675,17 +4769,16 @@ async function showBoardItem(options, deps = {}) {
|
|
|
4675
4769
|
}
|
|
4676
4770
|
return item;
|
|
4677
4771
|
}
|
|
4678
|
-
async function
|
|
4772
|
+
async function prepareClaimContext(options, selectors, deps, collected) {
|
|
4679
4773
|
const cfg = resolveBoardConfig(options.config);
|
|
4680
4774
|
const client = deps.client ?? defaultGitHubClient();
|
|
4681
|
-
const
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
collected.items.push(fallback);
|
|
4775
|
+
for (const selector of selectors) {
|
|
4776
|
+
try {
|
|
4777
|
+
findBoardItem(collected.items, selector);
|
|
4778
|
+
} catch {
|
|
4779
|
+
const fallback = (await fetchIssueProjectItem(client, cfg, selector)).item;
|
|
4780
|
+
if (fallback) collected.items.push(fallback);
|
|
4781
|
+
}
|
|
4689
4782
|
}
|
|
4690
4783
|
const writable = await resolveWritableReposForClaimables(collected.items, client, options.allowPartial ?? false);
|
|
4691
4784
|
collected.warnings.push(...writable.warnings);
|
|
@@ -4698,8 +4791,12 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
4698
4791
|
warnings: collected.warnings,
|
|
4699
4792
|
partial: collected.partial
|
|
4700
4793
|
};
|
|
4701
|
-
|
|
4702
|
-
|
|
4794
|
+
return { cfg, client, items: collected.items, writable: writable.repos, report };
|
|
4795
|
+
}
|
|
4796
|
+
async function claimOneBoardItem(ctx, selector, options) {
|
|
4797
|
+
const { cfg, client, report } = ctx;
|
|
4798
|
+
const flatItem = findBoardItem(ctx.items, selector);
|
|
4799
|
+
if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !ctx.writable.has(flatItem.repository.toLowerCase())) {
|
|
4703
4800
|
throw new Error(`${flatItem.ref} is not claimable: viewer does not have write access to ${flatItem.repository}`);
|
|
4704
4801
|
}
|
|
4705
4802
|
let item = findClaimableItem(report, selector);
|
|
@@ -4736,6 +4833,49 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
4736
4833
|
partial: false
|
|
4737
4834
|
};
|
|
4738
4835
|
}
|
|
4836
|
+
async function claimBoardIssue(options, deps = {}) {
|
|
4837
|
+
const cfg = resolveBoardConfig(options.config);
|
|
4838
|
+
const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
|
|
4839
|
+
const selector = parseIssueSelector(options.selector, collected.repo);
|
|
4840
|
+
const ctx = await prepareClaimContext(options, [selector], deps, collected);
|
|
4841
|
+
return claimOneBoardItem(ctx, selector, options);
|
|
4842
|
+
}
|
|
4843
|
+
async function claimBoardIssues(options, deps = {}) {
|
|
4844
|
+
const cfg = resolveBoardConfig(options.config);
|
|
4845
|
+
const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
|
|
4846
|
+
const selectors = [];
|
|
4847
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4848
|
+
for (const raw of options.selectors) {
|
|
4849
|
+
const selector = parseIssueSelector(raw, collected.repo);
|
|
4850
|
+
const key = `${selector.repo.toLowerCase()}#${selector.number}`;
|
|
4851
|
+
if (seen.has(key)) continue;
|
|
4852
|
+
seen.add(key);
|
|
4853
|
+
selectors.push(selector);
|
|
4854
|
+
}
|
|
4855
|
+
const ctx = await prepareClaimContext(options, selectors, deps, collected);
|
|
4856
|
+
const results = new Array(selectors.length);
|
|
4857
|
+
let next = 0;
|
|
4858
|
+
const worker = async () => {
|
|
4859
|
+
while (next < selectors.length) {
|
|
4860
|
+
const index = next++;
|
|
4861
|
+
const selector = selectors[index];
|
|
4862
|
+
const ref = `${selector.repo}#${selector.number}`;
|
|
4863
|
+
try {
|
|
4864
|
+
const result = await claimOneBoardItem(ctx, selector, options);
|
|
4865
|
+
results[index] = { ref: result.item.ref, claimed: true, item: result.item, status: result.status, partial: result.partial, warning: result.warning };
|
|
4866
|
+
} catch (e) {
|
|
4867
|
+
results[index] = { ref, claimed: false, reason: e.message };
|
|
4868
|
+
}
|
|
4869
|
+
}
|
|
4870
|
+
};
|
|
4871
|
+
await Promise.all(Array.from({ length: Math.min(CLAIM_CONCURRENCY, selectors.length) }, () => worker()));
|
|
4872
|
+
return {
|
|
4873
|
+
viewer: ctx.report.viewer,
|
|
4874
|
+
repo: ctx.report.repo,
|
|
4875
|
+
results,
|
|
4876
|
+
failed: results.filter((result) => !result.claimed).length
|
|
4877
|
+
};
|
|
4878
|
+
}
|
|
4739
4879
|
async function setBoardItemPriority(client, cfg, itemId, priority) {
|
|
4740
4880
|
if (!isPriorityFieldConfigured(cfg)) return void 0;
|
|
4741
4881
|
const optionId = resolvePriorityOptionId(cfg, priority);
|
|
@@ -5315,6 +5455,7 @@ function trainPlan(command) {
|
|
|
5315
5455
|
{ label: "verify current branch is rc", gated: true },
|
|
5316
5456
|
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
5317
5457
|
{ label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
|
|
5458
|
+
{ label: "verify every main-only hotfix commit is covered by the rc candidate", command: "node scripts/hotfix-coverage.mjs", gated: true },
|
|
5318
5459
|
{ label: "merge rc to main", gated: true },
|
|
5319
5460
|
{ 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
5461
|
{ label: "tag release and publish GitHub Release", gated: true },
|
|
@@ -5323,10 +5464,10 @@ function trainPlan(command) {
|
|
|
5323
5464
|
];
|
|
5324
5465
|
}
|
|
5325
5466
|
return [
|
|
5326
|
-
{ label: "
|
|
5327
|
-
{ label: "
|
|
5328
|
-
{ label: "deploy prod", gated: true },
|
|
5329
|
-
{ label: "
|
|
5467
|
+
{ label: "verify the fix is merged on development (the only hotfix origin)", gated: true },
|
|
5468
|
+
{ label: "branch hotfix from main and cherry-pick the dev commits", command: "git cherry-pick -x <dev-sha>", gated: true },
|
|
5469
|
+
{ label: "land on main via PR, tag, deploy prod", gated: true },
|
|
5470
|
+
{ label: "forward-bump development distribution manifests (Hub only; no back-merge)", gated: true }
|
|
5330
5471
|
];
|
|
5331
5472
|
}
|
|
5332
5473
|
function bootstrapPlan(repo, repoClass) {
|
|
@@ -5405,9 +5546,9 @@ function stalePosixFields(config, shell2) {
|
|
|
5405
5546
|
}
|
|
5406
5547
|
function sanitizeLocalStage(local, stale) {
|
|
5407
5548
|
if (!stale.length) return local;
|
|
5408
|
-
const
|
|
5409
|
-
for (const field of stale) delete
|
|
5410
|
-
return
|
|
5549
|
+
const clean3 = { ...local };
|
|
5550
|
+
for (const field of stale) delete clean3[field];
|
|
5551
|
+
return clean3;
|
|
5411
5552
|
}
|
|
5412
5553
|
function staleNote(staleFields, outcome) {
|
|
5413
5554
|
const list = staleFields.join(", ");
|
|
@@ -5670,6 +5811,20 @@ function buildPluginConfigDriftCheck(input) {
|
|
|
5670
5811
|
}
|
|
5671
5812
|
var GITIGNORE_BLOCK_LABEL = "org .gitignore managed block (.playwright-mcp/, .claude/worktrees/, scratch *.png)";
|
|
5672
5813
|
var GITIGNORE_BLOCK_FIX = "run `mmi-cli doctor` to auto-insert the `# >>> mmi-managed >>>` block (or copy it from MMI-Hub's .gitignore)";
|
|
5814
|
+
var GITIGNORE_BLOCK_DEFERRED_FIX = "run `mmi-cli doctor --apply` (without --no-repo-writes) after the release train to write the org-managed .gitignore block, then stage & commit .gitignore";
|
|
5815
|
+
function applyGitignoreRepoWritePolicy(check, repoWritesAllowed) {
|
|
5816
|
+
if (repoWritesAllowed || check.ok || !check.contentToWrite) return check;
|
|
5817
|
+
return { ...check, fix: GITIGNORE_BLOCK_DEFERRED_FIX };
|
|
5818
|
+
}
|
|
5819
|
+
function decideGitignoreRepair(check, flags) {
|
|
5820
|
+
if (check.ok || !check.contentToWrite) return { action: "none", check };
|
|
5821
|
+
if (!flags.repoWritesAllowed) {
|
|
5822
|
+
if (!flags.repairFull) return { action: "none", check };
|
|
5823
|
+
return { action: "suppress", check: applyGitignoreRepoWritePolicy(check, false) };
|
|
5824
|
+
}
|
|
5825
|
+
if (flags.repairFull) return { action: "write", check };
|
|
5826
|
+
return { action: "none", check };
|
|
5827
|
+
}
|
|
5673
5828
|
function buildGitignoreManagedBlockCheck(input) {
|
|
5674
5829
|
const base = { ok: true, label: GITIGNORE_BLOCK_LABEL, fix: GITIGNORE_BLOCK_FIX };
|
|
5675
5830
|
if (!input.isOrgRepo) return base;
|
|
@@ -5679,7 +5834,7 @@ function buildGitignoreManagedBlockCheck(input) {
|
|
|
5679
5834
|
return { ...base, ok: false, contentToWrite: content, added, removed, seeded };
|
|
5680
5835
|
}
|
|
5681
5836
|
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";
|
|
5837
|
+
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
5838
|
function normalizeVersion(v) {
|
|
5684
5839
|
return v?.trim().replace(/^v/, "");
|
|
5685
5840
|
}
|
|
@@ -5714,7 +5869,7 @@ function buildMmiPluginCacheCleanupCheck(input) {
|
|
|
5714
5869
|
...base,
|
|
5715
5870
|
ok: false,
|
|
5716
5871
|
leftovers,
|
|
5717
|
-
|
|
5872
|
+
plannedCount: leftovers.length,
|
|
5718
5873
|
quarantinePlan: leftovers.map((entry) => ({
|
|
5719
5874
|
from: entry.path,
|
|
5720
5875
|
to: cachePathJoin(entry.root, ".mmi-quarantine", stamp, entry.name)
|
|
@@ -5764,10 +5919,71 @@ function pluginRecoveryFix(surface) {
|
|
|
5764
5919
|
return "npm install -g @mutmutco/cli@latest";
|
|
5765
5920
|
}
|
|
5766
5921
|
}
|
|
5922
|
+
var PLUGIN_UPDATE_RECIPES = {
|
|
5923
|
+
claude: ["claude plugin update mmi@mmi"],
|
|
5924
|
+
codex: ["codex plugin marketplace upgrade mmi", "codex plugin list # verify mmi@mmi shows the new version"],
|
|
5925
|
+
cli: ["npm install -g @mutmutco/cli@latest"]
|
|
5926
|
+
};
|
|
5927
|
+
function highestSemver(versions) {
|
|
5928
|
+
return versions.reduce((best, v) => {
|
|
5929
|
+
if (!isSemverVersion(v)) return best;
|
|
5930
|
+
if (best === void 0) return v;
|
|
5931
|
+
return compareVersions(v, best) > 0 ? v : best;
|
|
5932
|
+
}, void 0);
|
|
5933
|
+
}
|
|
5934
|
+
function buildPluginUpdateReport(input) {
|
|
5935
|
+
const claudePlugin = highestSemver(input.claudePluginVersions ?? []);
|
|
5936
|
+
const codexMarketplace = highestSemver(input.codexPluginVersions ?? []);
|
|
5937
|
+
const codexActiveCache = highestSemver(input.codexCacheVersions ?? []);
|
|
5938
|
+
return {
|
|
5939
|
+
versions: {
|
|
5940
|
+
...isSemverVersion(input.cliVersion) ? { cli: input.cliVersion } : {},
|
|
5941
|
+
...claudePlugin ? { claudePlugin } : {},
|
|
5942
|
+
...codexMarketplace ? { codexMarketplace } : {},
|
|
5943
|
+
...codexActiveCache ? { codexActiveCache } : {},
|
|
5944
|
+
...isSemverVersion(input.releasedVersion) ? { released: input.releasedVersion } : {}
|
|
5945
|
+
},
|
|
5946
|
+
recipes: PLUGIN_UPDATE_RECIPES
|
|
5947
|
+
};
|
|
5948
|
+
}
|
|
5949
|
+
function renderPluginUpdateReport(report) {
|
|
5950
|
+
const v = report.versions;
|
|
5951
|
+
const show = (x) => x ?? "unknown";
|
|
5952
|
+
return [
|
|
5953
|
+
"MMI versions:",
|
|
5954
|
+
` CLI: ${show(v.cli)}`,
|
|
5955
|
+
` Claude plugin: ${show(v.claudePlugin)}`,
|
|
5956
|
+
` Codex marketplace: ${show(v.codexMarketplace)}`,
|
|
5957
|
+
` Codex active cache: ${show(v.codexActiveCache)}`,
|
|
5958
|
+
` latest release: ${show(v.released)}`,
|
|
5959
|
+
"Update recipes (per surface):",
|
|
5960
|
+
` Claude: ${report.recipes.claude.join(" ; ")}`,
|
|
5961
|
+
` Codex: ${report.recipes.codex.join(" ; ")}`,
|
|
5962
|
+
` npm CLI: ${report.recipes.cli.join(" ; ")}`
|
|
5963
|
+
];
|
|
5964
|
+
}
|
|
5965
|
+
function buildDoctorJsonPayload(input) {
|
|
5966
|
+
return {
|
|
5967
|
+
ok: input.checks.every((c) => c.ok),
|
|
5968
|
+
checks: input.checks,
|
|
5969
|
+
updateReport: input.updateReport,
|
|
5970
|
+
...input.resources.length ? { resources: input.resources } : {}
|
|
5971
|
+
};
|
|
5972
|
+
}
|
|
5767
5973
|
var INSTALLED_PLUGIN_VERSION_LABEL = "installed MMI plugin version (vs latest release)";
|
|
5768
5974
|
function isSemverVersion(v) {
|
|
5769
5975
|
return typeof v === "string" && /^v?\d+\.\d+\.\d+/.test(v.trim());
|
|
5770
5976
|
}
|
|
5977
|
+
function staleRecordCommand(surface) {
|
|
5978
|
+
return surface === "codex" ? "codex plugin marketplace upgrade mmi && codex plugin add mmi@mmi" : "claude plugin marketplace update mmi && claude plugin update mmi@mmi";
|
|
5979
|
+
}
|
|
5980
|
+
function staleSurfacesFix(stale, releasedVersion) {
|
|
5981
|
+
const parts = stale.map((s) => {
|
|
5982
|
+
const at = s.recordPath ? ` (${s.recordPath})` : "";
|
|
5983
|
+
return `${s.surface} record${at} is at ${s.installedVersion}${releasedVersion ? ` < ${releasedVersion}` : ""} \u2014 run: ${staleRecordCommand(s.surface)}`;
|
|
5984
|
+
});
|
|
5985
|
+
return `stale installed-plugin record on ${stale.map((s) => s.surface).join(" + ")}: ${parts.join("; ")}`;
|
|
5986
|
+
}
|
|
5771
5987
|
function buildInstalledPluginVersionCheck(input) {
|
|
5772
5988
|
const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
|
|
5773
5989
|
const base = {
|
|
@@ -5776,31 +5992,113 @@ function buildInstalledPluginVersionCheck(input) {
|
|
|
5776
5992
|
fix: pluginRecoveryFix(input.surface),
|
|
5777
5993
|
pluginId
|
|
5778
5994
|
};
|
|
5779
|
-
if (!input.isOrgRepo) return base;
|
|
5780
|
-
const
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5995
|
+
if (!input.isOrgRepo || !isSemverVersion(input.releasedVersion)) return base;
|
|
5996
|
+
const stale = [];
|
|
5997
|
+
let sawRecord = false;
|
|
5998
|
+
let currentVersion;
|
|
5999
|
+
for (const source of input.sources) {
|
|
6000
|
+
const records = source.installed?.plugins?.[pluginId];
|
|
6001
|
+
if (!Array.isArray(records) || records.length === 0) continue;
|
|
6002
|
+
sawRecord = true;
|
|
6003
|
+
const installedVersion = bestRecord(records).version;
|
|
6004
|
+
if (!isSemverVersion(installedVersion)) continue;
|
|
6005
|
+
if (compareVersions(installedVersion, input.releasedVersion) >= 0) {
|
|
6006
|
+
currentVersion = currentVersion ?? installedVersion;
|
|
6007
|
+
} else {
|
|
6008
|
+
stale.push({ surface: source.surface, installedVersion, ...source.recordPath ? { recordPath: source.recordPath } : {} });
|
|
6009
|
+
}
|
|
6010
|
+
}
|
|
6011
|
+
if (!sawRecord) return base;
|
|
6012
|
+
if (stale.length === 0) {
|
|
6013
|
+
return { ...base, ...currentVersion ? { installedVersion: currentVersion } : {}, releasedVersion: input.releasedVersion };
|
|
5786
6014
|
}
|
|
5787
6015
|
return {
|
|
5788
6016
|
...base,
|
|
5789
6017
|
ok: false,
|
|
5790
|
-
|
|
5791
|
-
|
|
6018
|
+
fix: staleSurfacesFix(stale, input.releasedVersion),
|
|
6019
|
+
installedVersion: stale[0].installedVersion,
|
|
6020
|
+
releasedVersion: input.releasedVersion,
|
|
6021
|
+
staleSurfaces: stale
|
|
5792
6022
|
};
|
|
5793
6023
|
}
|
|
6024
|
+
var CURSOR_PLUGIN_INSTALL_LABEL = "Cursor Team Marketplace plugin install";
|
|
6025
|
+
var CURSOR_MARKETPLACE_INSTALL_GUIDE = "https://github.com/mutmutco/MMI-Hub/blob/development/docs/Guides/cursor-marketplace-install.md";
|
|
6026
|
+
var CURSOR_PLUGIN_JSON_REL = ".cursor-plugin/plugin.json";
|
|
6027
|
+
var CURSOR_HOOKS_JSON_REL = "hooks/hooks.json";
|
|
6028
|
+
function joinCachePath(root, ...parts) {
|
|
6029
|
+
const sep = root.includes("\\") ? "\\" : "/";
|
|
6030
|
+
return [root.replace(/[\\/]+$/, ""), ...parts].join(sep);
|
|
6031
|
+
}
|
|
6032
|
+
function cursorPluginInstallFix(input) {
|
|
6033
|
+
const logHint = "check %APPDATA%\\Cursor\\logs\\<session>\\window*\\exthost\\anysphere.cursor-agent-exec\\Cursor Plugins.*.log for `unable to get password from user`";
|
|
6034
|
+
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";
|
|
6035
|
+
const marketplaceRefresh = "in Cursor Dashboard \u2192 Settings \u2192 Plugins, click Update next to the MMI Team Marketplace, enable MMI, then restart Cursor";
|
|
6036
|
+
const guide = `full recovery: ${CURSOR_MARKETPLACE_INSTALL_GUIDE}`;
|
|
6037
|
+
if (input.reason === "missing-cache") {
|
|
6038
|
+
return `${marketplaceRefresh}; ${authSteps}; ${guide}`;
|
|
6039
|
+
}
|
|
6040
|
+
const pin = input.pinName ?? "<commit-pin>";
|
|
6041
|
+
const cacheDir = joinCachePath(input.cacheRoot, pin);
|
|
6042
|
+
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`;
|
|
6043
|
+
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}`;
|
|
6044
|
+
}
|
|
6045
|
+
function buildCursorPluginInstallCheck(input) {
|
|
6046
|
+
const base = {
|
|
6047
|
+
ok: true,
|
|
6048
|
+
label: CURSOR_PLUGIN_INSTALL_LABEL,
|
|
6049
|
+
fix: cursorPluginInstallFix({ reason: "missing-cache", cacheRoot: input.cacheRoot })
|
|
6050
|
+
};
|
|
6051
|
+
if (!input.isOrgRepo) return base;
|
|
6052
|
+
const shouldCheck = input.surface === "cursor" || input.cacheRootExists;
|
|
6053
|
+
if (!shouldCheck) return base;
|
|
6054
|
+
if (input.surface === "cursor" && input.pins.length === 0) {
|
|
6055
|
+
return {
|
|
6056
|
+
...base,
|
|
6057
|
+
ok: false,
|
|
6058
|
+
cacheRoot: input.cacheRoot,
|
|
6059
|
+
pins: [],
|
|
6060
|
+
reason: "missing-cache",
|
|
6061
|
+
fix: cursorPluginInstallFix({ reason: "missing-cache", cacheRoot: input.cacheRoot, hubCheckout: input.hubCheckout })
|
|
6062
|
+
};
|
|
6063
|
+
}
|
|
6064
|
+
for (const pin of input.pins) {
|
|
6065
|
+
if (!pin.hasPluginJson || !pin.hasHooksJson || pin.isEmpty) {
|
|
6066
|
+
return {
|
|
6067
|
+
...base,
|
|
6068
|
+
ok: false,
|
|
6069
|
+
cacheRoot: input.cacheRoot,
|
|
6070
|
+
pins: input.pins,
|
|
6071
|
+
reason: "incomplete-cache",
|
|
6072
|
+
fix: cursorPluginInstallFix({
|
|
6073
|
+
reason: "incomplete-cache",
|
|
6074
|
+
cacheRoot: input.cacheRoot,
|
|
6075
|
+
pinName: pin.name,
|
|
6076
|
+
hubCheckout: input.hubCheckout
|
|
6077
|
+
})
|
|
6078
|
+
};
|
|
6079
|
+
}
|
|
6080
|
+
}
|
|
6081
|
+
return { ...base, cacheRoot: input.cacheRoot, pins: input.pins };
|
|
6082
|
+
}
|
|
6083
|
+
var HUB_COMPAT_FIX = "update mmi-cli (npm i -g @mutmutco/cli) / refresh the MMI plugin, then rerun doctor";
|
|
6084
|
+
function buildHubCompatCheck(input) {
|
|
6085
|
+
const label = "Hub compatibility (client version vs Hub minimum)";
|
|
6086
|
+
const min = input.versionInfo?.minClientVersion;
|
|
6087
|
+
if (!input.isOrgRepo || !min || !parseSemver(min) || !parseSemver(input.installedVersion)) {
|
|
6088
|
+
return { ok: true, label, fix: HUB_COMPAT_FIX };
|
|
6089
|
+
}
|
|
6090
|
+
return { ok: versionAtLeast(input.installedVersion, min), label: `${label}: requires >= ${min}`, fix: HUB_COMPAT_FIX };
|
|
6091
|
+
}
|
|
5794
6092
|
|
|
5795
6093
|
// src/stage-runner.ts
|
|
5796
6094
|
var import_node_child_process5 = require("node:child_process");
|
|
5797
|
-
var
|
|
5798
|
-
var
|
|
6095
|
+
var import_node_fs4 = require("node:fs");
|
|
6096
|
+
var import_node_path5 = require("node:path");
|
|
5799
6097
|
var import_node_net = require("node:net");
|
|
5800
6098
|
var import_node_util5 = require("node:util");
|
|
5801
6099
|
var execFileP3 = (0, import_node_util5.promisify)(import_node_child_process5.execFile);
|
|
5802
6100
|
function stageStatePath(cwd = process.cwd()) {
|
|
5803
|
-
return (0,
|
|
6101
|
+
return (0, import_node_path5.join)(cwd, "tmp", "stage", "state.json");
|
|
5804
6102
|
}
|
|
5805
6103
|
var POSIX_ONLY_VERBS = ["cp", "mv", "rm", "ln", "cat", "touch", "chmod", "export"];
|
|
5806
6104
|
function posixOnlyShellProblems(command, field, platform = process.platform) {
|
|
@@ -5862,9 +6160,9 @@ async function shell(command, cwd, timeoutMs) {
|
|
|
5862
6160
|
});
|
|
5863
6161
|
}
|
|
5864
6162
|
function readState(path2) {
|
|
5865
|
-
if (!(0,
|
|
6163
|
+
if (!(0, import_node_fs4.existsSync)(path2)) return null;
|
|
5866
6164
|
try {
|
|
5867
|
-
return JSON.parse((0,
|
|
6165
|
+
return JSON.parse((0, import_node_fs4.readFileSync)(path2, "utf8"));
|
|
5868
6166
|
} catch {
|
|
5869
6167
|
return null;
|
|
5870
6168
|
}
|
|
@@ -5916,7 +6214,7 @@ async function stopStage(opts = {}) {
|
|
|
5916
6214
|
return { ok: true, action: "stop", statePath, message: "no previous stage state found" };
|
|
5917
6215
|
}
|
|
5918
6216
|
await killTree(state.pid);
|
|
5919
|
-
(0,
|
|
6217
|
+
(0, import_node_fs4.rmSync)(statePath, { force: true });
|
|
5920
6218
|
return { ok: true, action: "stop", statePath, pid: state.pid, message: `stopped previous stage pid ${state.pid}` };
|
|
5921
6219
|
}
|
|
5922
6220
|
async function startStage(config = {}, opts = {}) {
|
|
@@ -5925,7 +6223,7 @@ async function startStage(config = {}, opts = {}) {
|
|
|
5925
6223
|
const cwd = opts.cwd ?? process.cwd();
|
|
5926
6224
|
const statePath = opts.statePath ?? stageStatePath(cwd);
|
|
5927
6225
|
const dir = statePath.slice(0, Math.max(statePath.lastIndexOf("/"), statePath.lastIndexOf("\\")));
|
|
5928
|
-
(0,
|
|
6226
|
+
(0, import_node_fs4.mkdirSync)(dir, { recursive: true });
|
|
5929
6227
|
let stagePort;
|
|
5930
6228
|
if (config.portRange) {
|
|
5931
6229
|
const [s, e] = config.portRange;
|
|
@@ -5935,9 +6233,9 @@ async function startStage(config = {}, opts = {}) {
|
|
|
5935
6233
|
}
|
|
5936
6234
|
const sub = (s) => s != null && stagePort != null ? s.replace(/\$\{?STAGE_PORT\}?/g, String(stagePort)) : s;
|
|
5937
6235
|
if (config.ensureEnv) {
|
|
5938
|
-
const target = (0,
|
|
5939
|
-
const example = (0,
|
|
5940
|
-
if (!(0,
|
|
6236
|
+
const target = (0, import_node_path5.join)(cwd, config.ensureEnv.target);
|
|
6237
|
+
const example = (0, import_node_path5.join)(cwd, config.ensureEnv.example);
|
|
6238
|
+
if (!(0, import_node_fs4.existsSync)(target) && (0, import_node_fs4.existsSync)(example)) (0, import_node_fs4.copyFileSync)(example, target);
|
|
5941
6239
|
}
|
|
5942
6240
|
const extraEnv = {};
|
|
5943
6241
|
for (const [k, v] of Object.entries(config.env ?? {})) extraEnv[k] = sub(v) ?? v;
|
|
@@ -5961,12 +6259,12 @@ async function startStage(config = {}, opts = {}) {
|
|
|
5961
6259
|
healthUrl: sub(config.healthUrl?.trim()) || void 0,
|
|
5962
6260
|
port: stagePort
|
|
5963
6261
|
};
|
|
5964
|
-
(0,
|
|
6262
|
+
(0, import_node_fs4.writeFileSync)(statePath, JSON.stringify(state, null, 2), "utf8");
|
|
5965
6263
|
try {
|
|
5966
6264
|
if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4, config.healthAnyStatus);
|
|
5967
6265
|
} catch (e) {
|
|
5968
6266
|
await killTree(state.pid);
|
|
5969
|
-
(0,
|
|
6267
|
+
(0, import_node_fs4.rmSync)(statePath, { force: true });
|
|
5970
6268
|
throw e;
|
|
5971
6269
|
}
|
|
5972
6270
|
const result = { ok: true, action: "start", statePath, pid: state.pid, port: stagePort, message: `started stage pid ${state.pid}${stagePort != null ? ` on port ${stagePort}` : ""}` };
|
|
@@ -6036,14 +6334,18 @@ function requireValue(value, label) {
|
|
|
6036
6334
|
if (!value) throw new Error(`${label} could not be resolved`);
|
|
6037
6335
|
return value;
|
|
6038
6336
|
}
|
|
6039
|
-
function releaseTagFromRcTag(tag) {
|
|
6040
|
-
return tag.replace(/-rc\.\d+$/, "");
|
|
6041
|
-
}
|
|
6042
6337
|
async function verifyHubDistributionVersion(deps, model, releaseTag) {
|
|
6043
6338
|
if (model !== "hub-serverless") return;
|
|
6044
6339
|
await deps.run("node", ["scripts/release-distribution.mjs", "verify", releaseTag, "--skip-npm-view"]);
|
|
6045
6340
|
}
|
|
6046
|
-
var ORG_SPINE_FILES = [
|
|
6341
|
+
var ORG_SPINE_FILES = [
|
|
6342
|
+
"AGENTS.md",
|
|
6343
|
+
"CLAUDE.md",
|
|
6344
|
+
"GEMINI.md",
|
|
6345
|
+
".claude/settings.json",
|
|
6346
|
+
".claude/output-styles/mmi-plain.md",
|
|
6347
|
+
".cursor/rules/mmi-plain-language.mdc"
|
|
6348
|
+
];
|
|
6047
6349
|
function isSpinePath(path2) {
|
|
6048
6350
|
return ORG_SPINE_FILES.includes(path2);
|
|
6049
6351
|
}
|
|
@@ -6060,9 +6362,9 @@ async function predictMergeConflicts(deps, ours, theirs) {
|
|
|
6060
6362
|
return files;
|
|
6061
6363
|
}
|
|
6062
6364
|
}
|
|
6063
|
-
async function
|
|
6365
|
+
async function mergeWithSpineResolution(deps, sourceRef, label, resolve) {
|
|
6064
6366
|
try {
|
|
6065
|
-
await deps.run("git", ["merge",
|
|
6367
|
+
await deps.run("git", ["merge", sourceRef, "--no-edit"]);
|
|
6066
6368
|
return;
|
|
6067
6369
|
} catch {
|
|
6068
6370
|
}
|
|
@@ -6071,10 +6373,10 @@ async function mergeRcWithSpineResolution(deps) {
|
|
|
6071
6373
|
if (unmerged.length === 0 || nonSpine.length > 0) {
|
|
6072
6374
|
await deps.run("git", ["merge", "--abort"]);
|
|
6073
6375
|
throw new Error(
|
|
6074
|
-
unmerged.length === 0 ?
|
|
6376
|
+
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
6377
|
);
|
|
6076
6378
|
}
|
|
6077
|
-
await deps.run("git", ["checkout",
|
|
6379
|
+
await deps.run("git", ["checkout", `--${resolve}`, "--", ...unmerged]);
|
|
6078
6380
|
await deps.run("git", ["add", "--", ...unmerged]);
|
|
6079
6381
|
await deps.run("git", ["commit", "--no-edit"]);
|
|
6080
6382
|
}
|
|
@@ -6115,9 +6417,10 @@ var HUB_REPO2 = "mutmutco/MMI-Hub";
|
|
|
6115
6417
|
var CORRELATE_ATTEMPTS = 5;
|
|
6116
6418
|
var CORRELATE_DELAY_MS = 1500;
|
|
6117
6419
|
var CORRELATE_SKEW_SLACK_MS = 1e4;
|
|
6118
|
-
var
|
|
6119
|
-
var
|
|
6120
|
-
var
|
|
6420
|
+
var TRAIN_CHECK_RUNS_JQ = "[.check_runs[]|{name:.name,status:.status,conclusion:.conclusion}]";
|
|
6421
|
+
var TRAIN_COMMIT_STATUS_JQ = "[.statuses[]|{context:.context,state:.state}]";
|
|
6422
|
+
var TRAIN_PROTECTION_CONTEXTS_JQ = "[.contexts[]]";
|
|
6423
|
+
var TRAIN_RULES_CONTEXTS_JQ = '[.[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context]';
|
|
6121
6424
|
var TRAIN_CHECK_ATTEMPTS = 40;
|
|
6122
6425
|
var TRAIN_CHECK_DELAY_MS = 15e3;
|
|
6123
6426
|
async function correlateTenantRun(deps, since) {
|
|
@@ -6157,51 +6460,124 @@ async function watchTenantRun(deps, runId) {
|
|
|
6157
6460
|
return "failure";
|
|
6158
6461
|
}
|
|
6159
6462
|
}
|
|
6160
|
-
function
|
|
6463
|
+
function isNotFoundError(e) {
|
|
6464
|
+
const msg = `${e.message ?? e} ${String(e.stderr ?? "")}`;
|
|
6465
|
+
return /HTTP 404|Not Found|\(404\)/i.test(msg);
|
|
6466
|
+
}
|
|
6467
|
+
function parseStringArray(out, label) {
|
|
6161
6468
|
const parsed = JSON.parse(out);
|
|
6162
|
-
if (!Array.isArray(parsed)) throw new Error(
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6169
|
-
|
|
6469
|
+
if (!Array.isArray(parsed)) throw new Error(`${label} response was not an array`);
|
|
6470
|
+
return parsed.filter((v) => typeof v === "string");
|
|
6471
|
+
}
|
|
6472
|
+
async function discoverRequiredCheckContexts(deps, ctx, branch) {
|
|
6473
|
+
const contexts = /* @__PURE__ */ new Set();
|
|
6474
|
+
try {
|
|
6475
|
+
const out = await deps.run("gh", ["api", `repos/${ctx.repo}/branches/${branch}/protection/required_status_checks`, "--jq", TRAIN_PROTECTION_CONTEXTS_JQ]);
|
|
6476
|
+
for (const c of parseStringArray(out, "branch protection required_status_checks")) contexts.add(c);
|
|
6477
|
+
} catch (e) {
|
|
6478
|
+
if (!isNotFoundError(e)) throw new Error(`could not read branch protection for ${ctx.repo}@${branch}: ${e.message ?? e}`);
|
|
6479
|
+
}
|
|
6480
|
+
try {
|
|
6481
|
+
const out = await deps.run("gh", ["api", `repos/${ctx.repo}/rules/branches/${branch}`, "--jq", TRAIN_RULES_CONTEXTS_JQ]);
|
|
6482
|
+
for (const c of parseStringArray(out, "branch rules required_status_checks")) contexts.add(c);
|
|
6483
|
+
} catch (e) {
|
|
6484
|
+
if (!isNotFoundError(e)) throw new Error(`could not read branch rules for ${ctx.repo}@${branch}: ${e.message ?? e}`);
|
|
6170
6485
|
}
|
|
6171
|
-
return
|
|
6486
|
+
return [...contexts];
|
|
6172
6487
|
}
|
|
6173
|
-
function
|
|
6174
|
-
|
|
6488
|
+
function resolveContextState(context, checkRuns, statuses) {
|
|
6489
|
+
let sawFailure = false;
|
|
6490
|
+
for (const r of checkRuns) {
|
|
6491
|
+
if (r.name !== context) continue;
|
|
6492
|
+
if (r.status === "completed") {
|
|
6493
|
+
if (r.conclusion === "success") return "success";
|
|
6494
|
+
sawFailure = true;
|
|
6495
|
+
}
|
|
6496
|
+
}
|
|
6497
|
+
for (const s of statuses) {
|
|
6498
|
+
if (s.context !== context) continue;
|
|
6499
|
+
if (s.state === "success") return "success";
|
|
6500
|
+
if (s.state === "failure" || s.state === "error") sawFailure = true;
|
|
6501
|
+
}
|
|
6502
|
+
return sawFailure ? "failed" : "pending";
|
|
6175
6503
|
}
|
|
6176
|
-
async function waitForRequiredTrainChecks(deps, ctx, sha) {
|
|
6504
|
+
async function waitForRequiredTrainChecks(deps, ctx, sha, required) {
|
|
6505
|
+
if (required.length === 0) {
|
|
6506
|
+
return "no required status checks configured on the target branch \u2014 check wait skipped (GitHub push gate is the backstop)";
|
|
6507
|
+
}
|
|
6177
6508
|
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6178
6509
|
let lastStatus = "not checked";
|
|
6179
6510
|
let lastError;
|
|
6180
6511
|
for (let attempt = 0; attempt < TRAIN_CHECK_ATTEMPTS; attempt++) {
|
|
6181
6512
|
if (attempt > 0) await sleep(TRAIN_CHECK_DELAY_MS);
|
|
6182
|
-
let
|
|
6513
|
+
let checkRuns;
|
|
6514
|
+
let statuses;
|
|
6183
6515
|
try {
|
|
6184
|
-
const
|
|
6185
|
-
|
|
6516
|
+
const runsOut = await deps.run("gh", ["api", `repos/${ctx.repo}/commits/${sha}/check-runs`, "--jq", TRAIN_CHECK_RUNS_JQ]);
|
|
6517
|
+
const parsedRuns = JSON.parse(runsOut);
|
|
6518
|
+
if (!Array.isArray(parsedRuns)) throw new Error("check-runs response was not an array");
|
|
6519
|
+
checkRuns = parsedRuns;
|
|
6520
|
+
const statusOut = await deps.run("gh", ["api", `repos/${ctx.repo}/commits/${sha}/status`, "--jq", TRAIN_COMMIT_STATUS_JQ]);
|
|
6521
|
+
const parsedStatuses = JSON.parse(statusOut);
|
|
6522
|
+
if (!Array.isArray(parsedStatuses)) throw new Error("commit status response was not an array");
|
|
6523
|
+
statuses = parsedStatuses;
|
|
6186
6524
|
lastError = void 0;
|
|
6187
6525
|
} catch (e) {
|
|
6188
6526
|
lastError = e.message || String(e);
|
|
6189
6527
|
continue;
|
|
6190
6528
|
}
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
return conclusion != null && conclusion !== "success";
|
|
6195
|
-
});
|
|
6529
|
+
const states = required.map((c) => [c, resolveContextState(c, checkRuns, statuses)]);
|
|
6530
|
+
lastStatus = states.map(([c, s]) => `${c}=${s}`).join(", ");
|
|
6531
|
+
const failed = states.filter(([, s]) => s === "failed").map(([c]) => c);
|
|
6196
6532
|
if (failed.length > 0) {
|
|
6197
6533
|
throw new Error(`required train check failed: ${failed.join(", ")} (${lastStatus})`);
|
|
6198
6534
|
}
|
|
6199
|
-
if (
|
|
6535
|
+
if (states.every(([, s]) => s === "success")) {
|
|
6536
|
+
return `required checks passed: ${required.join(", ")}`;
|
|
6537
|
+
}
|
|
6200
6538
|
}
|
|
6201
6539
|
throw new Error(
|
|
6202
6540
|
`timed out waiting for required train checks on ${sha}: ${lastError ? `last error: ${lastError}` : lastStatus}`
|
|
6203
6541
|
);
|
|
6204
6542
|
}
|
|
6543
|
+
async function ensureTagPushed(deps, tag, sha) {
|
|
6544
|
+
const remoteOut = await deps.run("git", ["ls-remote", "origin", `refs/tags/${tag}`]);
|
|
6545
|
+
const remoteSha = clean(remoteOut).split(/\s+/)[0] || "";
|
|
6546
|
+
let localSha = "";
|
|
6547
|
+
try {
|
|
6548
|
+
localSha = clean(await deps.run("git", ["rev-parse", "--verify", `refs/tags/${tag}^{commit}`]));
|
|
6549
|
+
} catch {
|
|
6550
|
+
}
|
|
6551
|
+
if (remoteSha) {
|
|
6552
|
+
if (remoteSha !== sha) {
|
|
6553
|
+
throw new Error(
|
|
6554
|
+
`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.`
|
|
6555
|
+
);
|
|
6556
|
+
}
|
|
6557
|
+
if (localSha && localSha !== sha) {
|
|
6558
|
+
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`);
|
|
6559
|
+
}
|
|
6560
|
+
return `tag ${tag} already on origin at ${sha.slice(0, 7)} \u2014 resumed without re-pushing`;
|
|
6561
|
+
}
|
|
6562
|
+
if (localSha && localSha !== sha) {
|
|
6563
|
+
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`);
|
|
6564
|
+
}
|
|
6565
|
+
if (!localSha) await deps.run("git", ["tag", tag, sha]);
|
|
6566
|
+
await deps.run("git", ["push", "origin", tag]);
|
|
6567
|
+
return `tag ${tag} pushed at ${sha.slice(0, 7)}`;
|
|
6568
|
+
}
|
|
6569
|
+
async function resolveRcResumeTag(deps, base, sha) {
|
|
6570
|
+
const out = await deps.run("git", ["ls-remote", "--tags", "origin", `refs/tags/${base}-rc.*`]);
|
|
6571
|
+
const onSha = clean(out).split("\n").map((line) => line.trim()).filter(Boolean).map((line) => line.split(/\s+/)).filter(([refSha]) => refSha === sha).map(([, ref]) => ref.replace(/^refs\/tags\//, "").replace(/\^\{\}$/, "")).filter((ref) => new RegExp(`^${base.replace(/\./g, "\\.")}-rc\\.\\d+$`).test(ref));
|
|
6572
|
+
const unique = [...new Set(onSha)];
|
|
6573
|
+
if (unique.length === 0) return { tag: null };
|
|
6574
|
+
const rcNum = (t) => Number.parseInt(t.replace(/^.*-rc\./, ""), 10);
|
|
6575
|
+
const sorted = unique.sort((a, b) => rcNum(b) - rcNum(a));
|
|
6576
|
+
const newest = sorted[0];
|
|
6577
|
+
const leftovers = sorted.slice(1);
|
|
6578
|
+
const note = leftovers.length > 0 ? `resuming existing RC tag ${newest} already on ${sha.slice(0, 7)} (newest of ${sorted.length}); harmless leftover tag(s) on the same SHA: ${leftovers.join(", ")}` : `resuming existing RC tag ${newest} already on ${sha.slice(0, 7)} instead of minting a fresh rc`;
|
|
6579
|
+
return { tag: newest, note };
|
|
6580
|
+
}
|
|
6205
6581
|
async function dispatchDeploy(deps, ctx, stage2, ref, model, watch) {
|
|
6206
6582
|
if (model === "tenant-container") {
|
|
6207
6583
|
const since = (deps.now ?? Date.now)();
|
|
@@ -6262,18 +6638,21 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6262
6638
|
"nothing to promote: origin/development is not ahead of origin/rc"
|
|
6263
6639
|
);
|
|
6264
6640
|
const deployModel2 = await preflight(deps, ctx, "rc");
|
|
6265
|
-
const
|
|
6266
|
-
await verifyHubDistributionVersion(deps, deployModel2,
|
|
6641
|
+
const releaseBase = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release base");
|
|
6642
|
+
await verifyHubDistributionVersion(deps, deployModel2, releaseBase);
|
|
6267
6643
|
await deps.run("git", ["checkout", "rc"]);
|
|
6268
6644
|
await deps.run("git", ["pull", "--ff-only", "origin", "rc"]);
|
|
6269
6645
|
await deps.run("git", ["merge", "development", "--no-edit"]);
|
|
6270
|
-
await deps.run("git", ["tag", tag2]);
|
|
6271
|
-
await deps.run("git", ["push", "origin", tag2]);
|
|
6272
6646
|
const rcSha = requireValue(clean(await deps.run("git", ["rev-parse", "rc"])), "rc sha");
|
|
6273
|
-
await
|
|
6647
|
+
const resume = await resolveRcResumeTag(deps, releaseBase, rcSha);
|
|
6648
|
+
const tag2 = resume.tag ?? requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "rc"])), "rc tag");
|
|
6649
|
+
const resumeNote = resume.tag ? resume.note : void 0;
|
|
6650
|
+
await ensureTagPushed(deps, tag2, rcSha);
|
|
6651
|
+
const requiredChecks2 = await discoverRequiredCheckContexts(deps, ctx, "rc");
|
|
6652
|
+
const checks2 = await waitForRequiredTrainChecks(deps, ctx, rcSha, requiredChecks2);
|
|
6274
6653
|
await deps.run("git", ["push", "origin", "rc"]);
|
|
6275
6654
|
const d2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch);
|
|
6276
|
-
return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, deployStatus: d2.deployStatus };
|
|
6655
|
+
return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, checks: checks2, resumeNote, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, deployStatus: d2.deployStatus };
|
|
6277
6656
|
}
|
|
6278
6657
|
await requireBranch(deps, "rc");
|
|
6279
6658
|
ensurePositiveCount(
|
|
@@ -6294,22 +6673,24 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6294
6673
|
if (predicted.length === 0) {
|
|
6295
6674
|
await deps.run("git", ["merge", "rc", "--no-edit"]);
|
|
6296
6675
|
} else {
|
|
6297
|
-
await
|
|
6676
|
+
await mergeWithSpineResolution(deps, "rc", "rc -> main", "theirs");
|
|
6298
6677
|
}
|
|
6299
6678
|
const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
|
|
6300
6679
|
await verifyHubDistributionVersion(deps, deployModel, tag);
|
|
6301
|
-
await deps.run("git", ["tag", tag]);
|
|
6302
|
-
await deps.run("git", ["push", "origin", tag]);
|
|
6303
6680
|
const releaseSha = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
|
|
6304
|
-
await
|
|
6681
|
+
await ensureTagPushed(deps, tag, releaseSha);
|
|
6682
|
+
const requiredChecks = await discoverRequiredCheckContexts(deps, ctx, "main");
|
|
6683
|
+
const checks = await waitForRequiredTrainChecks(deps, ctx, releaseSha, requiredChecks);
|
|
6305
6684
|
await deps.run("git", ["push", "origin", "main"]);
|
|
6306
|
-
await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
|
|
6685
|
+
const releaseUrl = clean(await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo])) || void 0;
|
|
6686
|
+
const announceNote = deps.announce ? (await deps.announce({ repo: ctx.repo, tag, summaryFile: options.announceSummaryFile })).note : void 0;
|
|
6307
6687
|
const d = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch);
|
|
6308
6688
|
const retirement = await retireRcRuntime(deps, ctx, deployModel, d.deployStatus, releasedRcSha);
|
|
6309
6689
|
await deps.run("git", ["checkout", "development"]);
|
|
6310
6690
|
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
6311
6691
|
await deps.run("git", ["merge", "main", "--no-edit"]);
|
|
6312
6692
|
await deps.run("git", ["push", "origin", "development"]);
|
|
6693
|
+
const environments = await buildEnvironments(deps, ctx, deployModel, d.deployStatus, retirement);
|
|
6313
6694
|
return {
|
|
6314
6695
|
...ctx,
|
|
6315
6696
|
command,
|
|
@@ -6318,14 +6699,50 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6318
6699
|
tag,
|
|
6319
6700
|
deployModel,
|
|
6320
6701
|
promoted: true,
|
|
6702
|
+
checks,
|
|
6321
6703
|
dispatch: d.note,
|
|
6322
6704
|
runId: d.runId,
|
|
6323
6705
|
runUrl: d.runUrl,
|
|
6324
6706
|
deployStatus: d.deployStatus,
|
|
6325
6707
|
rcRetirement: retirement.status,
|
|
6326
|
-
rcRetirementNote: retirement.note
|
|
6708
|
+
rcRetirementNote: retirement.note,
|
|
6709
|
+
rcRetirementCategory: retirement.category,
|
|
6710
|
+
announceNote,
|
|
6711
|
+
release: { tag, url: releaseUrl, targetSha: releaseSha },
|
|
6712
|
+
environments
|
|
6327
6713
|
};
|
|
6328
6714
|
}
|
|
6715
|
+
async function buildEnvironments(deps, ctx, model, deployStatus, retirement) {
|
|
6716
|
+
if (model !== "tenant-container") return void 0;
|
|
6717
|
+
const domains = deps.fetchEdgeDomains ? await deps.fetchEdgeDomains(ctx.slug).catch(() => null) : null;
|
|
6718
|
+
const mainDomains = domains?.main;
|
|
6719
|
+
const rcDomains = domains?.rc;
|
|
6720
|
+
const healthStatus = deployStatus === "success" ? "success" : deployStatus === "failure" ? "failure" : "pending";
|
|
6721
|
+
const main = { healthStatus };
|
|
6722
|
+
if (mainDomains?.length) {
|
|
6723
|
+
main.domains = mainDomains;
|
|
6724
|
+
main.healthUrl = `https://${mainDomains[0]}/`;
|
|
6725
|
+
}
|
|
6726
|
+
const rc = { retirement: retirement.status };
|
|
6727
|
+
if (retirement.category) rc.retirementCategory = retirement.category;
|
|
6728
|
+
if (rcDomains?.length) rc.domains = rcDomains;
|
|
6729
|
+
return { main, rc };
|
|
6730
|
+
}
|
|
6731
|
+
var RETIRE_CATEGORIES = /* @__PURE__ */ new Set([
|
|
6732
|
+
"retired",
|
|
6733
|
+
"retired-edge-pending",
|
|
6734
|
+
"ssm-command-failed",
|
|
6735
|
+
"wait-timeout",
|
|
6736
|
+
"transport-failed"
|
|
6737
|
+
]);
|
|
6738
|
+
function retireCategoryFrom(text) {
|
|
6739
|
+
try {
|
|
6740
|
+
const c = JSON.parse(text).category;
|
|
6741
|
+
return typeof c === "string" && RETIRE_CATEGORIES.has(c) ? c : void 0;
|
|
6742
|
+
} catch {
|
|
6743
|
+
return void 0;
|
|
6744
|
+
}
|
|
6745
|
+
}
|
|
6329
6746
|
async function retireRcRuntime(deps, ctx, model, deployStatus, releasedRcSha) {
|
|
6330
6747
|
if (model !== "tenant-container") {
|
|
6331
6748
|
return { status: "not-applicable", note: `${model} has no co-resident rc runtime to retire` };
|
|
@@ -6350,16 +6767,28 @@ async function retireRcRuntime(deps, ctx, model, deployStatus, releasedRcSha) {
|
|
|
6350
6767
|
}
|
|
6351
6768
|
const out = await deps.runSelf(["tenant", "control", ctx.repo, "rc", "retire"]);
|
|
6352
6769
|
let commandId = "";
|
|
6770
|
+
let category = retireCategoryFrom(out);
|
|
6353
6771
|
try {
|
|
6354
6772
|
commandId = String(JSON.parse(out).commandId ?? "");
|
|
6355
6773
|
} catch {
|
|
6356
6774
|
}
|
|
6775
|
+
if (category === "retired-edge-pending") {
|
|
6776
|
+
return {
|
|
6777
|
+
status: "retired",
|
|
6778
|
+
category,
|
|
6779
|
+
note: `rc runtime retired; edge vhost reconcile pending (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept`
|
|
6780
|
+
};
|
|
6781
|
+
}
|
|
6782
|
+
category = category ?? "retired";
|
|
6357
6783
|
return {
|
|
6358
6784
|
status: "retired",
|
|
6785
|
+
category,
|
|
6359
6786
|
note: `rc runtime retired (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept; /rcand or tenant redeploy recreates rc next cycle`
|
|
6360
6787
|
};
|
|
6361
6788
|
} catch (e) {
|
|
6362
|
-
|
|
6789
|
+
const err = e;
|
|
6790
|
+
const category = retireCategoryFrom(err.stdout ?? "") ?? "transport-failed";
|
|
6791
|
+
return { status: "failed", category, note: `rc retirement failed (the release itself succeeded): ${err.message}` };
|
|
6363
6792
|
}
|
|
6364
6793
|
}
|
|
6365
6794
|
async function runTenantRedeploy(deps, options) {
|
|
@@ -6388,8 +6817,458 @@ async function runTenantRedeploy(deps, options) {
|
|
|
6388
6817
|
return { ...ctx, command: "tenant-redeploy", stage: stage2, ref, deployModel, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, deployStatus: d.deployStatus };
|
|
6389
6818
|
}
|
|
6390
6819
|
|
|
6820
|
+
// src/hotfix-apply.ts
|
|
6821
|
+
var HOTFIX_RELEASE_WORKFLOWS = ["deploy.yml", "publish.yml"];
|
|
6822
|
+
var HOTFIX_RUN_FIND_ATTEMPTS = 10;
|
|
6823
|
+
var HOTFIX_RUN_FIND_DELAY_MS = 15e3;
|
|
6824
|
+
function clean2(out) {
|
|
6825
|
+
return out.trim();
|
|
6826
|
+
}
|
|
6827
|
+
function sleeper(deps) {
|
|
6828
|
+
return deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6829
|
+
}
|
|
6830
|
+
function normalizeHotfixVersion(input) {
|
|
6831
|
+
const m = /^v?(\d+\.\d+\.\d+)$/.exec(input.trim());
|
|
6832
|
+
if (!m) throw new Error(`hotfix version must be vX.Y.Z (PATCH release), got "${input}"`);
|
|
6833
|
+
return { tag: `v${m[1]}`, version: m[1] };
|
|
6834
|
+
}
|
|
6835
|
+
async function deriveHotfixVersion(deps) {
|
|
6836
|
+
const out = await deps.run("git", ["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--merged", "origin/main", "--sort=-v:refname"]);
|
|
6837
|
+
const baseTag = out.split("\n").map((s) => s.trim()).find((t) => /^v\d+\.\d+\.\d+$/.test(t));
|
|
6838
|
+
if (!baseTag) throw new Error("no vX.Y.Z release tag found on origin/main \u2014 cannot derive the hotfix PATCH version");
|
|
6839
|
+
const [major, minor, patch] = baseTag.slice(1).split(".").map(Number);
|
|
6840
|
+
const version = `${major}.${minor}.${patch + 1}`;
|
|
6841
|
+
return { tag: `v${version}`, version, baseTag };
|
|
6842
|
+
}
|
|
6843
|
+
function hotfixBranch(tag) {
|
|
6844
|
+
return `hotfix/${tag}`;
|
|
6845
|
+
}
|
|
6846
|
+
async function findHotfixPr(deps, ctx, tag) {
|
|
6847
|
+
const out = await deps.run("gh", [
|
|
6848
|
+
"pr",
|
|
6849
|
+
"list",
|
|
6850
|
+
"--repo",
|
|
6851
|
+
ctx.repo,
|
|
6852
|
+
"--head",
|
|
6853
|
+
hotfixBranch(tag),
|
|
6854
|
+
"--base",
|
|
6855
|
+
"main",
|
|
6856
|
+
"--state",
|
|
6857
|
+
"all",
|
|
6858
|
+
"--limit",
|
|
6859
|
+
"1",
|
|
6860
|
+
"--json",
|
|
6861
|
+
"number,state,url,mergeCommit"
|
|
6862
|
+
]);
|
|
6863
|
+
const rows = JSON.parse(out || "[]");
|
|
6864
|
+
return rows[0] ?? null;
|
|
6865
|
+
}
|
|
6866
|
+
async function resolveHotfixSource(deps, ctx, from) {
|
|
6867
|
+
const prMatch = /^#?(\d+)$/.exec(from.trim());
|
|
6868
|
+
if (prMatch) {
|
|
6869
|
+
const num = prMatch[1];
|
|
6870
|
+
const out = await deps.run("gh", ["pr", "view", num, "--repo", ctx.repo, "--json", "state,baseRefName,mergeCommit"]);
|
|
6871
|
+
const pr2 = JSON.parse(out);
|
|
6872
|
+
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`);
|
|
6873
|
+
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`);
|
|
6874
|
+
const sha2 = pr2.mergeCommit?.oid;
|
|
6875
|
+
if (!sha2) throw new Error(`PR #${num} has no merge commit recorded \u2014 name the commit SHA explicitly`);
|
|
6876
|
+
return { sha: sha2, label: `PR #${num} (${sha2.slice(0, 7)})` };
|
|
6877
|
+
}
|
|
6878
|
+
const sha = clean2(await deps.run("git", ["rev-parse", "--verify", `${from}^{commit}`]));
|
|
6879
|
+
if (!sha) throw new Error(`could not resolve commit ${from}`);
|
|
6880
|
+
return { sha, label: sha.slice(0, 7) };
|
|
6881
|
+
}
|
|
6882
|
+
async function runHotfixStart(deps, options) {
|
|
6883
|
+
const ctx = await buildTrainApplyContext(deps);
|
|
6884
|
+
const status = await deps.run("git", ["status", "--porcelain"]);
|
|
6885
|
+
if (status.trim()) throw new Error("working tree must be clean before hotfix start");
|
|
6886
|
+
await deps.run("git", ["fetch", "origin", "--tags"]);
|
|
6887
|
+
const { tag, version } = await deriveHotfixVersion(deps);
|
|
6888
|
+
const branch = hotfixBranch(tag);
|
|
6889
|
+
const notes = [];
|
|
6890
|
+
const existingPr = await findHotfixPr(deps, ctx, tag);
|
|
6891
|
+
if (existingPr) {
|
|
6892
|
+
return {
|
|
6893
|
+
...ctx,
|
|
6894
|
+
command: "hotfix-start",
|
|
6895
|
+
tag,
|
|
6896
|
+
version,
|
|
6897
|
+
branch,
|
|
6898
|
+
source: options.from,
|
|
6899
|
+
prUrl: existingPr.url,
|
|
6900
|
+
reused: true,
|
|
6901
|
+
notes: [`hotfix PR for ${tag} already exists (#${existingPr.number}, ${existingPr.state}) \u2014 reused; next: merge it, then mmi-cli hotfix release ${tag}`]
|
|
6902
|
+
};
|
|
6903
|
+
}
|
|
6904
|
+
const { sha, label } = await resolveHotfixSource(deps, ctx, options.from);
|
|
6905
|
+
const remoteBranch = clean2(await deps.run("git", ["ls-remote", "origin", `refs/heads/${branch}`]));
|
|
6906
|
+
if (remoteBranch) {
|
|
6907
|
+
await deps.run("git", ["checkout", branch]);
|
|
6908
|
+
await deps.run("git", ["pull", "--ff-only", "origin", branch]);
|
|
6909
|
+
notes.push(`branch ${branch} already on origin \u2014 reused (cherry-pick/bump assumed present; PR step resumes)`);
|
|
6910
|
+
} else {
|
|
6911
|
+
await deps.run("git", ["checkout", "-B", branch, "origin/main"]);
|
|
6912
|
+
try {
|
|
6913
|
+
await deps.run("git", ["cherry-pick", "-x", sha]);
|
|
6914
|
+
} catch (e) {
|
|
6915
|
+
await deps.run("git", ["cherry-pick", "--abort"]);
|
|
6916
|
+
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})`);
|
|
6917
|
+
}
|
|
6918
|
+
notes.push(`cherry-picked ${label} onto ${branch} (from origin/main, -x trailer recorded)`);
|
|
6919
|
+
await deps.run("node", ["scripts/release-distribution.mjs", "prepare", version]);
|
|
6920
|
+
const changedFiles = (await deps.run("node", ["scripts/release-distribution.mjs", "changed-files"])).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
6921
|
+
await deps.run("git", ["add", "--", ...changedFiles]);
|
|
6922
|
+
const staged = await deps.run("git", ["diff", "--cached", "--name-only"]);
|
|
6923
|
+
if (staged.trim()) {
|
|
6924
|
+
await deps.run("git", ["commit", "-m", `hotfix ${tag}: lock plugin set + @mutmutco/cli distribution to ${version}`]);
|
|
6925
|
+
notes.push(`distribution prepared + committed for ${version} (${changedFiles.length} locked paths)`);
|
|
6926
|
+
} else {
|
|
6927
|
+
notes.push("distribution prepare produced no changes \u2014 nothing extra committed");
|
|
6928
|
+
}
|
|
6929
|
+
await deps.run("git", ["push", "-u", "origin", branch]);
|
|
6930
|
+
}
|
|
6931
|
+
const prUrl = clean2(await deps.run("gh", [
|
|
6932
|
+
"pr",
|
|
6933
|
+
"create",
|
|
6934
|
+
"--repo",
|
|
6935
|
+
ctx.repo,
|
|
6936
|
+
"--base",
|
|
6937
|
+
"main",
|
|
6938
|
+
"--head",
|
|
6939
|
+
branch,
|
|
6940
|
+
"--title",
|
|
6941
|
+
`[hotfix] ${tag}`,
|
|
6942
|
+
"--body",
|
|
6943
|
+
`Hotfix ${tag}: cherry-pick of ${label} onto origin/main with the locked distribution bump.
|
|
6944
|
+
|
|
6945
|
+
Merge this PR (human-initiated), then run \`mmi-cli hotfix release ${tag}\`.`
|
|
6946
|
+
]));
|
|
6947
|
+
notes.push(`opened hotfix PR ${prUrl} \u2014 merge it (human-initiated), then: mmi-cli hotfix release ${tag}`);
|
|
6948
|
+
return { ...ctx, command: "hotfix-start", tag, version, branch, source: label, prUrl, reused: Boolean(remoteBranch), notes };
|
|
6949
|
+
}
|
|
6950
|
+
async function watchReleaseRun(deps, ctx, workflow, sha) {
|
|
6951
|
+
const sleep = sleeper(deps);
|
|
6952
|
+
for (let attempt = 0; attempt < HOTFIX_RUN_FIND_ATTEMPTS; attempt++) {
|
|
6953
|
+
if (attempt > 0) await sleep(HOTFIX_RUN_FIND_DELAY_MS);
|
|
6954
|
+
let rows;
|
|
6955
|
+
try {
|
|
6956
|
+
const out = await deps.run("gh", [
|
|
6957
|
+
"run",
|
|
6958
|
+
"list",
|
|
6959
|
+
"--repo",
|
|
6960
|
+
ctx.repo,
|
|
6961
|
+
"--workflow",
|
|
6962
|
+
workflow,
|
|
6963
|
+
"--event",
|
|
6964
|
+
"release",
|
|
6965
|
+
"--limit",
|
|
6966
|
+
"10",
|
|
6967
|
+
"--json",
|
|
6968
|
+
"databaseId,url,headSha,status,conclusion"
|
|
6969
|
+
]);
|
|
6970
|
+
rows = JSON.parse(out);
|
|
6971
|
+
} catch {
|
|
6972
|
+
continue;
|
|
6973
|
+
}
|
|
6974
|
+
const run = rows.find((r) => r.headSha === sha && typeof r.databaseId === "number");
|
|
6975
|
+
if (!run) continue;
|
|
6976
|
+
if (run.status === "completed") return { workflow, url: run.url, conclusion: run.conclusion ?? "unknown" };
|
|
6977
|
+
try {
|
|
6978
|
+
await deps.run("gh", ["run", "watch", String(run.databaseId), "--repo", ctx.repo, "--exit-status"]);
|
|
6979
|
+
return { workflow, url: run.url, conclusion: "success" };
|
|
6980
|
+
} catch {
|
|
6981
|
+
return { workflow, url: run.url, conclusion: "failure" };
|
|
6982
|
+
}
|
|
6983
|
+
}
|
|
6984
|
+
return { workflow, conclusion: "not-found" };
|
|
6985
|
+
}
|
|
6986
|
+
async function runHotfixRelease(deps, versionInput, options = {}) {
|
|
6987
|
+
const ctx = await buildTrainApplyContext(deps);
|
|
6988
|
+
const { tag, version } = normalizeHotfixVersion(versionInput);
|
|
6989
|
+
const status = await deps.run("git", ["status", "--porcelain"]);
|
|
6990
|
+
if (status.trim()) throw new Error("working tree must be clean before hotfix release");
|
|
6991
|
+
await deps.run("git", ["fetch", "origin", "--tags"]);
|
|
6992
|
+
const pr2 = await findHotfixPr(deps, ctx, tag);
|
|
6993
|
+
if (!pr2) throw new Error(`no hotfix PR found for ${tag} (head ${hotfixBranch(tag)}, base main) \u2014 run mmi-cli hotfix start first`);
|
|
6994
|
+
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`);
|
|
6995
|
+
const mergedSha = pr2.mergeCommit?.oid ?? "";
|
|
6996
|
+
if (!mergedSha) throw new Error(`hotfix PR #${pr2.number} reports no merge commit \u2014 cannot pin the release SHA`);
|
|
6997
|
+
await deps.run("git", ["merge-base", "--is-ancestor", mergedSha, "origin/main"]).catch(() => {
|
|
6998
|
+
throw new Error(`merged hotfix SHA ${mergedSha.slice(0, 7)} is not on origin/main \u2014 refusing to tag`);
|
|
6999
|
+
});
|
|
7000
|
+
const required = await discoverRequiredCheckContexts(deps, ctx, "main");
|
|
7001
|
+
const checks = await waitForRequiredTrainChecks(deps, ctx, mergedSha, required);
|
|
7002
|
+
const tagNote = await ensureTagPushed(deps, tag, mergedSha);
|
|
7003
|
+
let releaseNote;
|
|
7004
|
+
let releaseExists = false;
|
|
7005
|
+
try {
|
|
7006
|
+
await deps.run("gh", ["release", "view", tag, "--repo", ctx.repo, "--json", "tagName"]);
|
|
7007
|
+
releaseExists = true;
|
|
7008
|
+
} catch {
|
|
7009
|
+
}
|
|
7010
|
+
let announceNote;
|
|
7011
|
+
if (releaseExists) {
|
|
7012
|
+
releaseNote = `Release ${tag} already exists \u2014 resumed without recreating`;
|
|
7013
|
+
} else {
|
|
7014
|
+
const tagCommit = clean2(await deps.run("git", ["rev-parse", `${tag}^{commit}`]));
|
|
7015
|
+
await deps.run("gh", ["release", "create", tag, "--repo", ctx.repo, "--target", tagCommit, "--generate-notes", "--latest"]);
|
|
7016
|
+
releaseNote = `Release ${tag} created (target ${tagCommit.slice(0, 7)})`;
|
|
7017
|
+
if (deps.announce) {
|
|
7018
|
+
announceNote = (await deps.announce({ repo: ctx.repo, tag, summaryFile: options.announceSummaryFile })).note;
|
|
7019
|
+
}
|
|
7020
|
+
}
|
|
7021
|
+
const runs = [];
|
|
7022
|
+
for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
|
|
7023
|
+
runs.push(await watchReleaseRun(deps, ctx, workflow, mergedSha));
|
|
7024
|
+
}
|
|
7025
|
+
const previousRef = clean2(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
|
|
7026
|
+
let verifyNote;
|
|
7027
|
+
const publishSucceeded = runs.find((r) => r.workflow === "publish.yml")?.conclusion === "success";
|
|
7028
|
+
try {
|
|
7029
|
+
await deps.run("git", ["-c", "advice.detachedHead=false", "checkout", tag]);
|
|
7030
|
+
const verifyArgs = ["scripts/release-distribution.mjs", "verify", version, ...publishSucceeded ? [] : ["--skip-npm-view"]];
|
|
7031
|
+
await deps.run("node", verifyArgs);
|
|
7032
|
+
verifyNote = `distribution verified at ${tag}${publishSucceeded ? " (npm included)" : " (npm view skipped \u2014 publish run not confirmed)"}`;
|
|
7033
|
+
} finally {
|
|
7034
|
+
if (previousRef && previousRef !== "HEAD") await deps.run("git", ["checkout", previousRef]);
|
|
7035
|
+
}
|
|
7036
|
+
return { ...ctx, command: "hotfix-release", tag, mergedSha, checks, tagNote, releaseNote, runs, verifyNote, announceNote };
|
|
7037
|
+
}
|
|
7038
|
+
function versionAtLeast2(actual, wanted) {
|
|
7039
|
+
const pa = actual.split(".").map(Number);
|
|
7040
|
+
const pw = wanted.split(".").map(Number);
|
|
7041
|
+
if (pa.length < 3 || pa.some(Number.isNaN) || pw.length < 3 || pw.some(Number.isNaN)) return false;
|
|
7042
|
+
for (let i = 0; i < 3; i += 1) {
|
|
7043
|
+
if (pa[i] !== pw[i]) return pa[i] > pw[i];
|
|
7044
|
+
}
|
|
7045
|
+
return true;
|
|
7046
|
+
}
|
|
7047
|
+
function deriveHotfixState(f) {
|
|
7048
|
+
if (!f.branchExists && !f.pr && !f.tagPushed && !f.releaseExists) {
|
|
7049
|
+
return { state: "not-started", next: `mmi-cli hotfix start --from <pr#|sha> (would mint ${f.tag})` };
|
|
7050
|
+
}
|
|
7051
|
+
if (!f.pr) {
|
|
7052
|
+
return { state: "branch-pushed (no PR)", next: `mmi-cli hotfix start --from <pr#|sha> (resumes at the PR step for ${f.tag})` };
|
|
7053
|
+
}
|
|
7054
|
+
if (f.pr.state === "OPEN") {
|
|
7055
|
+
return { state: "pr-open", next: `merge hotfix PR #${f.pr.number} (human-initiated), then mmi-cli hotfix release ${f.tag}` };
|
|
7056
|
+
}
|
|
7057
|
+
if (f.pr.state !== "MERGED") {
|
|
7058
|
+
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` };
|
|
7059
|
+
}
|
|
7060
|
+
if (!f.tagPushed || !f.releaseExists) {
|
|
7061
|
+
return { state: "pr-merged (not released)", next: `mmi-cli hotfix release ${f.tag}` };
|
|
7062
|
+
}
|
|
7063
|
+
if (!f.devDistribution.aligned) {
|
|
7064
|
+
return {
|
|
7065
|
+
state: `UNFINISHED \u2014 released but development distribution manifests behind (dev ${f.devDistribution.version} < ${f.version})`,
|
|
7066
|
+
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)`
|
|
7067
|
+
};
|
|
7068
|
+
}
|
|
7069
|
+
return { state: "complete", next: "nothing \u2014 pipeline complete (rc absorbs the fix at the next /rcand; /release guards coverage)" };
|
|
7070
|
+
}
|
|
7071
|
+
async function runHotfixStatus(deps, versionInput) {
|
|
7072
|
+
const ctx = await buildTrainApplyContext(deps);
|
|
7073
|
+
await deps.run("git", ["fetch", "origin", "--tags"]);
|
|
7074
|
+
let tag;
|
|
7075
|
+
let version;
|
|
7076
|
+
if (versionInput) {
|
|
7077
|
+
({ tag, version } = normalizeHotfixVersion(versionInput));
|
|
7078
|
+
} else {
|
|
7079
|
+
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)));
|
|
7080
|
+
if (latest) {
|
|
7081
|
+
const latestFacts = await gatherHotfixFacts(deps, ctx, latest, latest.slice(1));
|
|
7082
|
+
const latestDerived = deriveHotfixState(latestFacts);
|
|
7083
|
+
if (latestDerived.state !== "complete") {
|
|
7084
|
+
return { ...ctx, command: "hotfix-status", ...latestFacts, ...latestDerived };
|
|
7085
|
+
}
|
|
7086
|
+
}
|
|
7087
|
+
({ tag, version } = await deriveHotfixVersion(deps));
|
|
7088
|
+
}
|
|
7089
|
+
const facts = await gatherHotfixFacts(deps, ctx, tag, version);
|
|
7090
|
+
return { ...ctx, command: "hotfix-status", ...facts, ...deriveHotfixState(facts) };
|
|
7091
|
+
}
|
|
7092
|
+
async function gatherHotfixFacts(deps, ctx, tag, version) {
|
|
7093
|
+
const branchExists = Boolean(clean2(await deps.run("git", ["ls-remote", "origin", `refs/heads/${hotfixBranch(tag)}`])));
|
|
7094
|
+
const pr2 = await findHotfixPr(deps, ctx, tag);
|
|
7095
|
+
const remoteTag = clean2(await deps.run("git", ["ls-remote", "origin", `refs/tags/${tag}`]));
|
|
7096
|
+
const tagPushed = Boolean(remoteTag);
|
|
7097
|
+
const tagSha = remoteTag.split(/\s+/)[0] || "";
|
|
7098
|
+
let releaseExists = false;
|
|
7099
|
+
try {
|
|
7100
|
+
await deps.run("gh", ["release", "view", tag, "--repo", ctx.repo, "--json", "tagName"]);
|
|
7101
|
+
releaseExists = true;
|
|
7102
|
+
} catch {
|
|
7103
|
+
}
|
|
7104
|
+
const runs = [];
|
|
7105
|
+
if (releaseExists && tagSha) {
|
|
7106
|
+
for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
|
|
7107
|
+
try {
|
|
7108
|
+
const out = await deps.run("gh", [
|
|
7109
|
+
"run",
|
|
7110
|
+
"list",
|
|
7111
|
+
"--repo",
|
|
7112
|
+
ctx.repo,
|
|
7113
|
+
"--workflow",
|
|
7114
|
+
workflow,
|
|
7115
|
+
"--event",
|
|
7116
|
+
"release",
|
|
7117
|
+
"--limit",
|
|
7118
|
+
"10",
|
|
7119
|
+
"--json",
|
|
7120
|
+
"databaseId,url,headSha,status,conclusion"
|
|
7121
|
+
]);
|
|
7122
|
+
const run = JSON.parse(out).find((r) => r.headSha === tagSha);
|
|
7123
|
+
runs.push(run ? { workflow, url: run.url, conclusion: run.status === "completed" ? run.conclusion ?? "unknown" : run.status ?? "unknown" } : { workflow, conclusion: "not-found" });
|
|
7124
|
+
} catch {
|
|
7125
|
+
runs.push({ workflow, conclusion: "unknown" });
|
|
7126
|
+
}
|
|
7127
|
+
}
|
|
7128
|
+
}
|
|
7129
|
+
const npmVersion = await deps.run("npm", ["view", "@mutmutco/cli", "version", "--silent"]).then(clean2, () => "unknown");
|
|
7130
|
+
const devVersion = await deps.run("git", ["show", "origin/development:cli/package.json"]).then(
|
|
7131
|
+
(out) => {
|
|
7132
|
+
try {
|
|
7133
|
+
return JSON.parse(out).version ?? "unknown";
|
|
7134
|
+
} catch {
|
|
7135
|
+
return "unknown";
|
|
7136
|
+
}
|
|
7137
|
+
},
|
|
7138
|
+
() => "unknown"
|
|
7139
|
+
);
|
|
7140
|
+
const devDistribution = { version: devVersion, aligned: versionAtLeast2(devVersion, version) };
|
|
7141
|
+
return { tag, version, branchExists, pr: pr2 ? { number: pr2.number, state: pr2.state, url: pr2.url } : null, tagPushed, releaseExists, runs, npmVersion, devDistribution };
|
|
7142
|
+
}
|
|
7143
|
+
|
|
7144
|
+
// src/release-announce.ts
|
|
7145
|
+
var ANNOUNCE_REPO = "mutmutco/MMI-Hub";
|
|
7146
|
+
var SSM_REGION = "eu-central-1";
|
|
7147
|
+
var SSM_TOKEN_PARAM = "/mmi-future/shared/SLACK_BOT_TOKEN";
|
|
7148
|
+
var SSM_CHANNEL_PARAM = "/mmi-future/shared/SLACK_ALERTS_CHANNEL";
|
|
7149
|
+
var SLACK_TIMEOUT_MS = 1e4;
|
|
7150
|
+
var MAX_BULLETS = 6;
|
|
7151
|
+
var MAX_SUMMARY_LINES = 8;
|
|
7152
|
+
function escapeMrkdwn(text) {
|
|
7153
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
7154
|
+
}
|
|
7155
|
+
function bulletToLine(raw) {
|
|
7156
|
+
const m = /^[*-]\s+(.*)$/.exec(raw.trim());
|
|
7157
|
+
if (!m) return null;
|
|
7158
|
+
let text = m[1].trim();
|
|
7159
|
+
const tail = /\s+by\s+@([\w[\]-]+)\s+in\s+(\S+)\s*$/.exec(text);
|
|
7160
|
+
let link = "";
|
|
7161
|
+
if (tail) {
|
|
7162
|
+
const author = tail[1].toLowerCase();
|
|
7163
|
+
if (author.includes("dependabot") || author.includes("[bot]") || author === "github-actions") return null;
|
|
7164
|
+
const url = tail[2];
|
|
7165
|
+
const pr2 = /\/pull\/(\d+)$/.exec(url);
|
|
7166
|
+
link = pr2 ? ` (<${url}|#${pr2[1]}>)` : "";
|
|
7167
|
+
text = text.slice(0, tail.index).trim();
|
|
7168
|
+
}
|
|
7169
|
+
if (!text) return null;
|
|
7170
|
+
return `\u2022 ${escapeMrkdwn(text)}${link}`;
|
|
7171
|
+
}
|
|
7172
|
+
function curateReleaseNotes(notesMd, maxBullets = MAX_BULLETS) {
|
|
7173
|
+
const lines = [];
|
|
7174
|
+
let bullets = 0;
|
|
7175
|
+
let dropped = 0;
|
|
7176
|
+
let section = "";
|
|
7177
|
+
let pendingHeader = "";
|
|
7178
|
+
for (const raw of notesMd.split("\n")) {
|
|
7179
|
+
const header = /^(#{2,3})\s+(.*)$/.exec(raw.trim());
|
|
7180
|
+
if (header) {
|
|
7181
|
+
section = header[2].trim();
|
|
7182
|
+
if (/what's changed/i.test(section) || /new contributors/i.test(section)) {
|
|
7183
|
+
pendingHeader = "";
|
|
7184
|
+
continue;
|
|
7185
|
+
}
|
|
7186
|
+
pendingHeader = `*${escapeMrkdwn(section)}*`;
|
|
7187
|
+
continue;
|
|
7188
|
+
}
|
|
7189
|
+
if (/new contributors/i.test(section)) continue;
|
|
7190
|
+
const line = bulletToLine(raw);
|
|
7191
|
+
if (!line) continue;
|
|
7192
|
+
if (bullets >= maxBullets) {
|
|
7193
|
+
dropped += 1;
|
|
7194
|
+
continue;
|
|
7195
|
+
}
|
|
7196
|
+
if (pendingHeader) {
|
|
7197
|
+
lines.push(pendingHeader);
|
|
7198
|
+
pendingHeader = "";
|
|
7199
|
+
}
|
|
7200
|
+
lines.push(line);
|
|
7201
|
+
bullets += 1;
|
|
7202
|
+
}
|
|
7203
|
+
if (dropped > 0) lines.push(`\u2026and ${dropped} more`);
|
|
7204
|
+
return lines;
|
|
7205
|
+
}
|
|
7206
|
+
function formatAnnouncement(args) {
|
|
7207
|
+
const name = args.repo.split("/")[1] ?? args.repo;
|
|
7208
|
+
return [
|
|
7209
|
+
`:rocket: *${name} ${args.tag} released*`,
|
|
7210
|
+
"",
|
|
7211
|
+
...args.lines,
|
|
7212
|
+
"",
|
|
7213
|
+
`<${args.releaseUrl}|Release notes>`
|
|
7214
|
+
].join("\n");
|
|
7215
|
+
}
|
|
7216
|
+
function summaryFileLines(content) {
|
|
7217
|
+
return content.split("\n").map((l) => l.trim()).filter(Boolean).slice(0, MAX_SUMMARY_LINES).map((l) => escapeMrkdwn(l.replace(/^[•*-]\s*/, ""))).filter(Boolean).map((l) => `\u2022 ${l}`);
|
|
7218
|
+
}
|
|
7219
|
+
async function ssmParameter(deps, name, decrypt) {
|
|
7220
|
+
const args = [
|
|
7221
|
+
"ssm",
|
|
7222
|
+
"get-parameter",
|
|
7223
|
+
"--region",
|
|
7224
|
+
SSM_REGION,
|
|
7225
|
+
"--name",
|
|
7226
|
+
name,
|
|
7227
|
+
"--query",
|
|
7228
|
+
"Parameter.Value",
|
|
7229
|
+
"--output",
|
|
7230
|
+
"text",
|
|
7231
|
+
...decrypt ? ["--with-decryption"] : []
|
|
7232
|
+
];
|
|
7233
|
+
const value = (await deps.run("aws", args)).trim();
|
|
7234
|
+
if (!value || value === "None") throw new Error(`SSM parameter ${name} is empty`);
|
|
7235
|
+
return value;
|
|
7236
|
+
}
|
|
7237
|
+
async function announceRelease(deps, args) {
|
|
7238
|
+
if (args.repo !== ANNOUNCE_REPO) {
|
|
7239
|
+
return { status: "skipped", note: `announce is Hub-only \u2014 ${args.repo} skipped` };
|
|
7240
|
+
}
|
|
7241
|
+
try {
|
|
7242
|
+
const viewRaw = await deps.run("gh", ["release", "view", args.tag, "--repo", args.repo, "--json", "body,url"]);
|
|
7243
|
+
const view = JSON.parse(viewRaw);
|
|
7244
|
+
const releaseUrl = view.url ?? `https://github.com/${args.repo}/releases/tag/${args.tag}`;
|
|
7245
|
+
let lines = [];
|
|
7246
|
+
if (args.summaryFile) {
|
|
7247
|
+
if (!deps.readFile) throw new Error("summary file given but deps.readFile is missing");
|
|
7248
|
+
lines = summaryFileLines(await deps.readFile(args.summaryFile));
|
|
7249
|
+
}
|
|
7250
|
+
if (lines.length === 0) lines = curateReleaseNotes(view.body ?? "");
|
|
7251
|
+
if (lines.length === 0) lines = ["\u2022 maintenance release \u2014 see the notes for details"];
|
|
7252
|
+
const token = await ssmParameter(deps, SSM_TOKEN_PARAM, true);
|
|
7253
|
+
const channel = await ssmParameter(deps, SSM_CHANNEL_PARAM, false);
|
|
7254
|
+
const text = formatAnnouncement({ repo: args.repo, tag: args.tag, lines, releaseUrl });
|
|
7255
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
7256
|
+
const res = await fetchImpl("https://slack.com/api/chat.postMessage", {
|
|
7257
|
+
method: "POST",
|
|
7258
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" },
|
|
7259
|
+
body: JSON.stringify({ channel, text, unfurl_links: false }),
|
|
7260
|
+
signal: AbortSignal.timeout(SLACK_TIMEOUT_MS)
|
|
7261
|
+
});
|
|
7262
|
+
const json = await res.json().catch(() => ({}));
|
|
7263
|
+
if (!json.ok) throw new Error(`slack postMessage failed: ${json.error ?? `http ${res.status}`}`);
|
|
7264
|
+
return { status: "announced", note: `announced ${args.tag} to the alerts channel` };
|
|
7265
|
+
} catch (e) {
|
|
7266
|
+
return { status: "failed", note: `announce failed (release unaffected): ${e.message}` };
|
|
7267
|
+
}
|
|
7268
|
+
}
|
|
7269
|
+
|
|
6391
7270
|
// src/port-registry.ts
|
|
6392
|
-
var
|
|
7271
|
+
var import_node_fs5 = require("node:fs");
|
|
6393
7272
|
|
|
6394
7273
|
// ../infra/port-geometry.mjs
|
|
6395
7274
|
var PORT_BLOCK = 100;
|
|
@@ -6403,8 +7282,8 @@ function nextPortBlock(registry2) {
|
|
|
6403
7282
|
return [base, base + PORT_SPAN];
|
|
6404
7283
|
}
|
|
6405
7284
|
function loadPortRegistry(path2) {
|
|
6406
|
-
if (!(0,
|
|
6407
|
-
const raw = JSON.parse((0,
|
|
7285
|
+
if (!(0, import_node_fs5.existsSync)(path2)) return {};
|
|
7286
|
+
const raw = JSON.parse((0, import_node_fs5.readFileSync)(path2, "utf8"));
|
|
6408
7287
|
const out = {};
|
|
6409
7288
|
for (const [key, value] of Object.entries(raw)) {
|
|
6410
7289
|
if (Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number")) {
|
|
@@ -6418,9 +7297,9 @@ function ensurePortRange(repo, path2) {
|
|
|
6418
7297
|
const existing = registry2[repo];
|
|
6419
7298
|
if (existing) return existing;
|
|
6420
7299
|
const range = nextPortBlock(registry2);
|
|
6421
|
-
const raw = (0,
|
|
7300
|
+
const raw = (0, import_node_fs5.existsSync)(path2) ? JSON.parse((0, import_node_fs5.readFileSync)(path2, "utf8")) : {};
|
|
6422
7301
|
raw[repo] = range;
|
|
6423
|
-
(0,
|
|
7302
|
+
(0, import_node_fs5.writeFileSync)(path2, JSON.stringify(raw, null, 2) + "\n", "utf8");
|
|
6424
7303
|
return range;
|
|
6425
7304
|
}
|
|
6426
7305
|
function portCursorSeed(registry2) {
|
|
@@ -7004,8 +7883,8 @@ function renderBootstrapVerifyReport(report) {
|
|
|
7004
7883
|
|
|
7005
7884
|
// src/hub-auth.ts
|
|
7006
7885
|
var import_node_crypto2 = require("node:crypto");
|
|
7007
|
-
var
|
|
7008
|
-
var
|
|
7886
|
+
var import_node_fs6 = require("node:fs");
|
|
7887
|
+
var import_node_path6 = require("node:path");
|
|
7009
7888
|
var import_node_os2 = require("node:os");
|
|
7010
7889
|
var REFRESH_WINDOW_MS = 10 * 60 * 1e3;
|
|
7011
7890
|
var EXCHANGE_TIMEOUT_MS = 8e3;
|
|
@@ -7019,15 +7898,15 @@ function tokenFingerprint(token) {
|
|
|
7019
7898
|
function defaultHubSessionCachePath(env = process.env) {
|
|
7020
7899
|
if (env.MMI_HUB_SESSION_CACHE) return env.MMI_HUB_SESSION_CACHE;
|
|
7021
7900
|
if (process.platform === "win32") {
|
|
7022
|
-
const base2 = env.LOCALAPPDATA || (0,
|
|
7023
|
-
return (0,
|
|
7901
|
+
const base2 = env.LOCALAPPDATA || (0, import_node_path6.join)((0, import_node_os2.homedir)(), "AppData", "Local");
|
|
7902
|
+
return (0, import_node_path6.join)(base2, "MMI Future", "mmi-cli", "hub-session.json");
|
|
7024
7903
|
}
|
|
7025
|
-
const base = env.XDG_STATE_HOME || (0,
|
|
7026
|
-
return (0,
|
|
7904
|
+
const base = env.XDG_STATE_HOME || (0, import_node_path6.join)((0, import_node_os2.homedir)(), ".mmi");
|
|
7905
|
+
return (0, import_node_path6.join)(base, "mmi-cli", "hub-session.json");
|
|
7027
7906
|
}
|
|
7028
7907
|
function readCache(path2, apiUrl, now, githubTokenFingerprint) {
|
|
7029
7908
|
try {
|
|
7030
|
-
const session = JSON.parse((0,
|
|
7909
|
+
const session = JSON.parse((0, import_node_fs6.readFileSync)(path2, "utf8"));
|
|
7031
7910
|
if (!session.token || !session.expiresAt || session.apiUrl !== apiUrl) return null;
|
|
7032
7911
|
if (session.githubTokenFingerprint !== githubTokenFingerprint) return null;
|
|
7033
7912
|
if (new Date(session.expiresAt).getTime() <= now.getTime() + REFRESH_WINDOW_MS) return null;
|
|
@@ -7037,16 +7916,16 @@ function readCache(path2, apiUrl, now, githubTokenFingerprint) {
|
|
|
7037
7916
|
}
|
|
7038
7917
|
}
|
|
7039
7918
|
function writeCache(path2, session) {
|
|
7040
|
-
(0,
|
|
7919
|
+
(0, import_node_fs6.mkdirSync)((0, import_node_path6.dirname)(path2), { recursive: true });
|
|
7041
7920
|
const tmp = `${path2}.${process.pid}.${Date.now()}.tmp`;
|
|
7042
|
-
(0,
|
|
7921
|
+
(0, import_node_fs6.writeFileSync)(tmp, JSON.stringify(session, null, 2) + "\n", { encoding: "utf8", mode: 384 });
|
|
7043
7922
|
try {
|
|
7044
|
-
(0,
|
|
7923
|
+
(0, import_node_fs6.chmodSync)(tmp, 384);
|
|
7045
7924
|
} catch {
|
|
7046
7925
|
}
|
|
7047
|
-
(0,
|
|
7926
|
+
(0, import_node_fs6.renameSync)(tmp, path2);
|
|
7048
7927
|
try {
|
|
7049
|
-
(0,
|
|
7928
|
+
(0, import_node_fs6.chmodSync)(path2, 384);
|
|
7050
7929
|
} catch {
|
|
7051
7930
|
}
|
|
7052
7931
|
}
|
|
@@ -7064,7 +7943,7 @@ async function hubAuthSession(deps) {
|
|
|
7064
7943
|
const res = await fetchWithRetry(
|
|
7065
7944
|
deps.fetch ?? fetch,
|
|
7066
7945
|
`${apiUrl}/auth/session`,
|
|
7067
|
-
{ method: "POST", headers: { Authorization: `Bearer ${ghToken}` } },
|
|
7946
|
+
{ method: "POST", headers: { ...clientVersionHeaders(), Authorization: `Bearer ${ghToken}` } },
|
|
7068
7947
|
{ attempts: EXCHANGE_ATTEMPTS, timeoutMs: EXCHANGE_TIMEOUT_MS }
|
|
7069
7948
|
);
|
|
7070
7949
|
if (!res.ok) return void 0;
|
|
@@ -7219,9 +8098,11 @@ var PROJECTS_ENVELOPE_KEY = "projects";
|
|
|
7219
8098
|
|
|
7220
8099
|
// src/registry-client.ts
|
|
7221
8100
|
var DEFAULT_TIMEOUT_MS2 = 8e3;
|
|
8101
|
+
var WAITED_TENANT_CONTROL_TIMEOUT_MS = 13e3;
|
|
7222
8102
|
var RETRY_ATTEMPTS = 3;
|
|
7223
8103
|
function retriedFetch(deps, url, init) {
|
|
7224
|
-
|
|
8104
|
+
const headers = { ...clientVersionHeaders(), ...init.headers };
|
|
8105
|
+
return fetchWithRetry(deps.fetch ?? fetch, url, { ...init, headers }, {
|
|
7225
8106
|
attempts: RETRY_ATTEMPTS,
|
|
7226
8107
|
timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2
|
|
7227
8108
|
});
|
|
@@ -7235,6 +8116,7 @@ async function fetchTrainAuthority(repo, deps) {
|
|
|
7235
8116
|
method: "GET",
|
|
7236
8117
|
headers: { Authorization: `Bearer ${token}` }
|
|
7237
8118
|
});
|
|
8119
|
+
if (res.status === 426) return { ok: false, error: upgradeRequiredError(res, await res.json().catch(() => null)) };
|
|
7238
8120
|
if (!res.ok) return { ok: false, error: `train-authority HTTP ${res.status}` };
|
|
7239
8121
|
const body = await res.json();
|
|
7240
8122
|
if (typeof body?.train !== "boolean" || !body.role) return { ok: false, error: "malformed train-authority response" };
|
|
@@ -7275,6 +8157,7 @@ async function fetchProjectBySlugChecked(slug, deps) {
|
|
|
7275
8157
|
headers: { Authorization: `Bearer ${token}` }
|
|
7276
8158
|
});
|
|
7277
8159
|
if (res.status === 404) return { ok: true, project: null };
|
|
8160
|
+
if (res.status === 426) return { ok: false, error: upgradeRequiredError(res, await res.json().catch(() => null)) };
|
|
7278
8161
|
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
|
7279
8162
|
return { ok: true, project: await res.json() };
|
|
7280
8163
|
} catch (e) {
|
|
@@ -7317,14 +8200,17 @@ async function fetchOrgConfig(deps) {
|
|
|
7317
8200
|
return null;
|
|
7318
8201
|
}
|
|
7319
8202
|
}
|
|
7320
|
-
async function postJson(pathSuffix, payload, deps, method = "POST") {
|
|
8203
|
+
async function postJson(pathSuffix, payload, deps, method = "POST", opts = {}) {
|
|
7321
8204
|
if (!deps.baseUrl) return { ok: false, status: 0, body: null, error: "no Hub API URL (set MMI_HUB_URL or use a current MMI CLI/plugin build)" };
|
|
7322
8205
|
const token = await deps.token();
|
|
7323
8206
|
if (!token) return { ok: false, status: 0, body: null, error: "no Hub session token (run `gh auth login`)" };
|
|
8207
|
+
const timeoutMs = opts.timeoutMs ?? deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
|
|
8208
|
+
const sendOnce = (url, init) => fetchWithRetry(deps.fetch ?? fetch, url, init, { attempts: 1, timeoutMs });
|
|
8209
|
+
const send = opts.noRetry ? sendOnce : (url, init) => fetchWithRetry(deps.fetch ?? fetch, url, init, { attempts: RETRY_ATTEMPTS, timeoutMs });
|
|
7324
8210
|
try {
|
|
7325
|
-
const res = await
|
|
8211
|
+
const res = await send(`${deps.baseUrl.replace(/\/$/, "")}${pathSuffix}`, {
|
|
7326
8212
|
method,
|
|
7327
|
-
headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
8213
|
+
headers: { ...clientVersionHeaders(), Authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
7328
8214
|
body: JSON.stringify(payload)
|
|
7329
8215
|
});
|
|
7330
8216
|
let body = null;
|
|
@@ -7347,10 +8233,15 @@ async function attestAppGaps(slug, repo, deps) {
|
|
|
7347
8233
|
return postJson(`/projects/${encodeURIComponent(slug)}/attest-app`, { repo }, deps);
|
|
7348
8234
|
}
|
|
7349
8235
|
async function tenantControl(payload, deps) {
|
|
7350
|
-
|
|
8236
|
+
const noRetry = payload.action === "retire";
|
|
8237
|
+
const timeoutMs = payload.wait ? WAITED_TENANT_CONTROL_TIMEOUT_MS : void 0;
|
|
8238
|
+
return postJson("/tenant-control", payload, deps, "POST", { noRetry, timeoutMs });
|
|
7351
8239
|
}
|
|
7352
8240
|
|
|
7353
8241
|
// src/project-readiness.ts
|
|
8242
|
+
function dnsErrorToResolution(code) {
|
|
8243
|
+
return code === "ENOTFOUND" || code === "EAI_NONAME" ? false : void 0;
|
|
8244
|
+
}
|
|
7354
8245
|
var STAGES = ["dev", "rc", "main"];
|
|
7355
8246
|
var DEFAULT_RUNTIME_SECRET_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
|
|
7356
8247
|
function slugOfRepo(repoOrSlug) {
|
|
@@ -7368,8 +8259,17 @@ function projectRequiresGoogleOAuth(meta, model) {
|
|
|
7368
8259
|
if (projectType !== "web-app") return false;
|
|
7369
8260
|
return Boolean(meta.oauth && typeof meta.oauth === "object");
|
|
7370
8261
|
}
|
|
7371
|
-
function
|
|
7372
|
-
|
|
8262
|
+
function declaresNoPublicEdge(meta) {
|
|
8263
|
+
const ed = meta?.edgeDomains;
|
|
8264
|
+
return Boolean(ed && typeof ed === "object" && !Array.isArray(ed) && Object.keys(ed).length === 0);
|
|
8265
|
+
}
|
|
8266
|
+
function isNoEdgeTenantWorker(meta, model) {
|
|
8267
|
+
return model === "tenant-container" && declaresNoPublicEdge(meta);
|
|
8268
|
+
}
|
|
8269
|
+
function projectRequiresDeployCoords(model, stage2, meta) {
|
|
8270
|
+
if (model !== "tenant-container") return false;
|
|
8271
|
+
if (stage2 && isNoEdgeTenantWorker(meta, model)) return stage2 === "main";
|
|
8272
|
+
return true;
|
|
7373
8273
|
}
|
|
7374
8274
|
function projectRequiresDeployState(model, stage2) {
|
|
7375
8275
|
return model === "hub-serverless" && stage2 !== "dev";
|
|
@@ -7378,6 +8278,7 @@ function stageRequiredSecrets(stage2, meta) {
|
|
|
7378
8278
|
const contract = meta.requiredRuntimeSecrets;
|
|
7379
8279
|
const extra = !Array.isArray(contract) && Array.isArray(contract?.[stage2]) ? contract[stage2] ?? [] : [];
|
|
7380
8280
|
const model = resolveDeployModel(meta, meta.repos?.[0] ?? "");
|
|
8281
|
+
if (isNoEdgeTenantWorker(meta, model) && stage2 !== "main") return [];
|
|
7381
8282
|
const defaults = projectRequiresGoogleOAuth(meta, model) ? DEFAULT_RUNTIME_SECRET_NAMES : [];
|
|
7382
8283
|
return [.../* @__PURE__ */ new Set([...defaults, ...extra])];
|
|
7383
8284
|
}
|
|
@@ -7433,6 +8334,9 @@ function appGapsFor(meta, model, slug, projectType) {
|
|
|
7433
8334
|
"Make app config fail clearly for missing required env in prod/rc instead of relying on hidden defaults.",
|
|
7434
8335
|
"Keep app-owned README.md and architecture.md aligned with v2 central deploy/secrets reality."
|
|
7435
8336
|
];
|
|
8337
|
+
if (isNoEdgeTenantWorker(meta, model)) {
|
|
8338
|
+
gaps.unshift("No public edge declared (`edgeDomains: {}`): worker/outbound-only tenant; skip Cloudflare edge auto-heal, OAuth defaults, and dev/rc remote deploy coords unless META explicitly adds them.");
|
|
8339
|
+
}
|
|
7436
8340
|
if (contractUndeclared) {
|
|
7437
8341
|
gaps.unshift(CONTRACT_UNDECLARED_LINE);
|
|
7438
8342
|
}
|
|
@@ -7442,6 +8346,31 @@ function appGapsFor(meta, model, slug, projectType) {
|
|
|
7442
8346
|
if (!meta) gaps.unshift("No app-owned repo changes can be planned precisely until Hub registry META exists.");
|
|
7443
8347
|
return gaps;
|
|
7444
8348
|
}
|
|
8349
|
+
function edgeDomainsByStage(meta) {
|
|
8350
|
+
const ed = meta?.edgeDomains;
|
|
8351
|
+
if (!ed || typeof ed !== "object" || Array.isArray(ed)) return {};
|
|
8352
|
+
const out = {};
|
|
8353
|
+
for (const stage2 of STAGES) {
|
|
8354
|
+
const v = ed[stage2];
|
|
8355
|
+
if (typeof v === "string" && v.trim()) out[stage2] = v.trim();
|
|
8356
|
+
}
|
|
8357
|
+
return out;
|
|
8358
|
+
}
|
|
8359
|
+
async function probeEdgeDomains(meta, resolveDns) {
|
|
8360
|
+
const byStage = edgeDomainsByStage(meta);
|
|
8361
|
+
const results = await Promise.all(
|
|
8362
|
+
Object.entries(byStage).map(async ([stage2, host]) => {
|
|
8363
|
+
let resolved;
|
|
8364
|
+
try {
|
|
8365
|
+
resolved = await resolveDns(host);
|
|
8366
|
+
} catch {
|
|
8367
|
+
resolved = void 0;
|
|
8368
|
+
}
|
|
8369
|
+
return resolved === false ? { stage: stage2, host } : void 0;
|
|
8370
|
+
})
|
|
8371
|
+
);
|
|
8372
|
+
return results.filter((r) => r !== void 0);
|
|
8373
|
+
}
|
|
7445
8374
|
function contractByStage(contract) {
|
|
7446
8375
|
return contract && !Array.isArray(contract) ? contract : {};
|
|
7447
8376
|
}
|
|
@@ -7572,7 +8501,7 @@ async function buildV2Doctor(repoOrSlug, deps) {
|
|
|
7572
8501
|
secretsError = e?.message || "secrets list failed";
|
|
7573
8502
|
}
|
|
7574
8503
|
const deployCoords = Object.fromEntries(await Promise.all(STAGES.map(async (stage2) => {
|
|
7575
|
-
const required = projectRequiresDeployCoords(model);
|
|
8504
|
+
const required = projectRequiresDeployCoords(model, stage2, meta);
|
|
7576
8505
|
return [stage2, { required, ok: required ? await deps.hasDeployCoords(slug, stage2) : true }];
|
|
7577
8506
|
})));
|
|
7578
8507
|
const deployState = Object.fromEntries(await Promise.all(STAGES.map(async (stage2) => {
|
|
@@ -7587,6 +8516,7 @@ async function buildV2Doctor(repoOrSlug, deps) {
|
|
|
7587
8516
|
}));
|
|
7588
8517
|
const metaMissing = ["class", "projectType", "deployModel", "vaultPath", "kbPointer"].filter((key) => meta[key] === void 0);
|
|
7589
8518
|
const ok = !secretsError && metaMissing.length === 0 && Object.values(deployCoords).every((v) => v.ok) && Object.values(secrets2).every((v) => v.missing.length === 0);
|
|
8519
|
+
const edgeDomainWarnings = deps.resolveDns ? await probeEdgeDomains(meta, deps.resolveDns) : [];
|
|
7590
8520
|
return {
|
|
7591
8521
|
ok,
|
|
7592
8522
|
repo,
|
|
@@ -7598,6 +8528,7 @@ async function buildV2Doctor(repoOrSlug, deps) {
|
|
|
7598
8528
|
secretsError,
|
|
7599
8529
|
autoHealAvailable: Object.keys(autoHeal.patch),
|
|
7600
8530
|
appOwnedGaps: autoHeal.appOwnedGaps,
|
|
8531
|
+
...edgeDomainWarnings.length ? { edgeDomainWarnings } : {},
|
|
7601
8532
|
appAttested: appAttestationOf(meta) ?? void 0
|
|
7602
8533
|
};
|
|
7603
8534
|
}
|
|
@@ -7625,6 +8556,9 @@ function renderReadinessIssueBody(existingBody, report, opts = {}) {
|
|
|
7625
8556
|
`- META: ${report.hubOwned.meta.ok ? "ok" : `missing ${report.hubOwned.meta.missing.join(", ")}`}`,
|
|
7626
8557
|
...stageLines,
|
|
7627
8558
|
...report.secretsError ? [`- secrets UNVERIFIED (treated as not ready): ${report.secretsError}`] : [],
|
|
8559
|
+
...(report.edgeDomainWarnings ?? []).map(
|
|
8560
|
+
(w) => `- \u26A0 edge domain does not resolve in DNS (advisory): ${w.stage} \u2192 ${w.host}; verify the registry edgeDomains value against the live public host`
|
|
8561
|
+
),
|
|
7628
8562
|
"",
|
|
7629
8563
|
"### Auto-heal applied / available",
|
|
7630
8564
|
...opts.healed?.length ? opts.healed.map((x) => `- ${x}`) : report.autoHealAvailable.map((x) => `- ${x}`),
|
|
@@ -7666,6 +8600,29 @@ function parseRuntimeSecretsVar(raw) {
|
|
|
7666
8600
|
}
|
|
7667
8601
|
return out;
|
|
7668
8602
|
}
|
|
8603
|
+
function parseEdgeDomainsVar(raw) {
|
|
8604
|
+
let parsed;
|
|
8605
|
+
try {
|
|
8606
|
+
parsed = JSON.parse(raw);
|
|
8607
|
+
} catch {
|
|
8608
|
+
throw new Error('project set: edgeDomains must be JSON, e.g. {"dev":"dev.example.co","rc":"rc.example.co","main":"example.co"}');
|
|
8609
|
+
}
|
|
8610
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
8611
|
+
throw new Error("project set: edgeDomains must be a {dev,rc,main} map of domain strings");
|
|
8612
|
+
}
|
|
8613
|
+
const map = parsed;
|
|
8614
|
+
const out = {};
|
|
8615
|
+
for (const [stage2, domain] of Object.entries(map)) {
|
|
8616
|
+
if (!RUNTIME_SECRET_STAGES.includes(stage2)) {
|
|
8617
|
+
throw new Error(`project set: edgeDomains stage "${stage2}" \u2014 expected only ${RUNTIME_SECRET_STAGES.join("/")}`);
|
|
8618
|
+
}
|
|
8619
|
+
if (typeof domain !== "string" || !domain.trim()) {
|
|
8620
|
+
throw new Error(`project set: edgeDomains.${stage2} must be a non-empty domain string`);
|
|
8621
|
+
}
|
|
8622
|
+
out[stage2] = domain.trim();
|
|
8623
|
+
}
|
|
8624
|
+
return out;
|
|
8625
|
+
}
|
|
7669
8626
|
function buildProjectSetPatch(input) {
|
|
7670
8627
|
const patch = {};
|
|
7671
8628
|
if (input.class) {
|
|
@@ -7697,6 +8654,8 @@ function buildProjectSetPatch(input) {
|
|
|
7697
8654
|
patch[key] = n;
|
|
7698
8655
|
} else if (key === "requiredRuntimeSecrets") {
|
|
7699
8656
|
patch[key] = parseRuntimeSecretsVar(raw);
|
|
8657
|
+
} else if (key === "edgeDomains") {
|
|
8658
|
+
patch[key] = parseEdgeDomainsVar(raw);
|
|
7700
8659
|
} else {
|
|
7701
8660
|
patch[key] = raw;
|
|
7702
8661
|
}
|
|
@@ -7738,8 +8697,8 @@ function resolveKbSource(rawBase) {
|
|
|
7738
8697
|
return { owner: m[1], repo: m[2], ref: m[3] };
|
|
7739
8698
|
}
|
|
7740
8699
|
function buildKbGetArgs(src, path2) {
|
|
7741
|
-
const
|
|
7742
|
-
return ["api", `repos/${src.owner}/${src.repo}/contents/${
|
|
8700
|
+
const clean3 = path2.replace(/^\/+/, "");
|
|
8701
|
+
return ["api", `repos/${src.owner}/${src.repo}/contents/${clean3}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
|
|
7743
8702
|
}
|
|
7744
8703
|
function buildKbTreeArgs(src) {
|
|
7745
8704
|
return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
|
|
@@ -7756,7 +8715,7 @@ function parseKbTree(stdout, prefix) {
|
|
|
7756
8715
|
}
|
|
7757
8716
|
|
|
7758
8717
|
// src/plan.ts
|
|
7759
|
-
var
|
|
8718
|
+
var import_node_path7 = require("node:path");
|
|
7760
8719
|
|
|
7761
8720
|
// src/frontmatter.ts
|
|
7762
8721
|
function splitFrontmatter(content) {
|
|
@@ -7839,8 +8798,8 @@ function rankPlansByRelevance(plans, signals, opts = {}) {
|
|
|
7839
8798
|
|
|
7840
8799
|
// src/plan.ts
|
|
7841
8800
|
var PLANS_DIR = "plans";
|
|
7842
|
-
var META_FILE = (0,
|
|
7843
|
-
var planPath = (slug) => (0,
|
|
8801
|
+
var META_FILE = (0, import_node_path7.join)(PLANS_DIR, ".plan-meta.json");
|
|
8802
|
+
var planPath = (slug) => (0, import_node_path7.join)(PLANS_DIR, `${slug}.md`);
|
|
7844
8803
|
var metaKey = (project2, slug) => `${project2}/${slug}`;
|
|
7845
8804
|
function parseMeta(raw) {
|
|
7846
8805
|
if (!raw) return {};
|
|
@@ -7888,6 +8847,10 @@ ${next.join("\n")}
|
|
|
7888
8847
|
---
|
|
7889
8848
|
${body.replace(/^\n+/, "")}`;
|
|
7890
8849
|
}
|
|
8850
|
+
async function httpFailMessage(op, res) {
|
|
8851
|
+
if (res.status === 426) return upgradeRequiredError(res, await res.json().catch(() => null));
|
|
8852
|
+
return `plan ${op} failed: HTTP ${res.status}`;
|
|
8853
|
+
}
|
|
7891
8854
|
async function planPush(deps, slug, opts = {}) {
|
|
7892
8855
|
const raw = deps.readLocal(slug);
|
|
7893
8856
|
if (raw == null) {
|
|
@@ -7919,7 +8882,7 @@ async function planPush(deps, slug, opts = {}) {
|
|
|
7919
8882
|
deps.err(staleHint(slug));
|
|
7920
8883
|
return false;
|
|
7921
8884
|
} else {
|
|
7922
|
-
deps.err(
|
|
8885
|
+
deps.err(await httpFailMessage("push", res));
|
|
7923
8886
|
return false;
|
|
7924
8887
|
}
|
|
7925
8888
|
}
|
|
@@ -7943,7 +8906,7 @@ async function planPull(deps, slug, opts = {}) {
|
|
|
7943
8906
|
return false;
|
|
7944
8907
|
}
|
|
7945
8908
|
if (!res.ok) {
|
|
7946
|
-
deps.err(
|
|
8909
|
+
deps.err(await httpFailMessage("pull", res));
|
|
7947
8910
|
return false;
|
|
7948
8911
|
}
|
|
7949
8912
|
const doc = await res.json();
|
|
@@ -7961,6 +8924,7 @@ async function fetchPlanList(deps, project2) {
|
|
|
7961
8924
|
headers: await deps.headers(),
|
|
7962
8925
|
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
7963
8926
|
});
|
|
8927
|
+
if (res.status === 426) throw new Error(upgradeRequiredError(res, await res.json().catch(() => null)));
|
|
7964
8928
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
7965
8929
|
const { plans } = await res.json();
|
|
7966
8930
|
return plans ?? [];
|
|
@@ -8016,7 +8980,7 @@ async function planDelete(deps, slug, opts = {}) {
|
|
|
8016
8980
|
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
8017
8981
|
});
|
|
8018
8982
|
if (!res.ok) {
|
|
8019
|
-
deps.err(
|
|
8983
|
+
deps.err(await httpFailMessage("delete", res));
|
|
8020
8984
|
return;
|
|
8021
8985
|
}
|
|
8022
8986
|
deps.removeLocal(slug);
|
|
@@ -8103,6 +9067,52 @@ function formatVaultPointer(p) {
|
|
|
8103
9067
|
}
|
|
8104
9068
|
var TIMEOUT_MS2 = 8e3;
|
|
8105
9069
|
var repoOf = (slug) => `${OWNER2}/${slug}`;
|
|
9070
|
+
var RECALL_REGIONS = ["us-east-1", "us-west-2", "eu-central-1", "ap-northeast-1"];
|
|
9071
|
+
var PROVIDER_VERIFY_TIMEOUT_MS = 8e3;
|
|
9072
|
+
function secretLeafName(key) {
|
|
9073
|
+
return key.split("/").pop() ?? key;
|
|
9074
|
+
}
|
|
9075
|
+
function providerForSecretKey(key) {
|
|
9076
|
+
return secretLeafName(key) === "RECALL_API_KEY" ? "recall" : void 0;
|
|
9077
|
+
}
|
|
9078
|
+
function recallUsageUrl(region) {
|
|
9079
|
+
const end = /* @__PURE__ */ new Date();
|
|
9080
|
+
const start = new Date(end.getTime() - 60 * 60 * 1e3);
|
|
9081
|
+
const qs = new URLSearchParams({ start: start.toISOString(), end: end.toISOString() });
|
|
9082
|
+
return `https://${region}.recall.ai/api/v1/billing/usage/?${qs.toString()}`;
|
|
9083
|
+
}
|
|
9084
|
+
async function verifyRecallApiKey(deps, value) {
|
|
9085
|
+
const checked = [];
|
|
9086
|
+
for (const region of RECALL_REGIONS) {
|
|
9087
|
+
try {
|
|
9088
|
+
const res = await deps.fetch(recallUsageUrl(region), {
|
|
9089
|
+
method: "GET",
|
|
9090
|
+
headers: { Authorization: `Token ${value}` },
|
|
9091
|
+
signal: AbortSignal.timeout(PROVIDER_VERIFY_TIMEOUT_MS)
|
|
9092
|
+
});
|
|
9093
|
+
if (res.ok) {
|
|
9094
|
+
return { ok: true, provider: "recall", message: `validated RECALL_API_KEY with Recall (${region})`, checked };
|
|
9095
|
+
}
|
|
9096
|
+
checked.push(`${region}: HTTP ${res.status}`);
|
|
9097
|
+
} catch (e) {
|
|
9098
|
+
checked.push(`${region}: ${e.message}`);
|
|
9099
|
+
}
|
|
9100
|
+
}
|
|
9101
|
+
return {
|
|
9102
|
+
ok: false,
|
|
9103
|
+
provider: "recall",
|
|
9104
|
+
message: `Recall rejected the key in all configured regions (${checked.join("; ")})`,
|
|
9105
|
+
checked
|
|
9106
|
+
};
|
|
9107
|
+
}
|
|
9108
|
+
async function verifySecretValue(deps, key, value, _opts) {
|
|
9109
|
+
const provider = providerForSecretKey(key);
|
|
9110
|
+
if (!provider) {
|
|
9111
|
+
return { ok: false, message: `no provider verifier configured for ${secretLeafName(key)}`, checked: [] };
|
|
9112
|
+
}
|
|
9113
|
+
if (provider === "recall") return verifyRecallApiKey(deps, value);
|
|
9114
|
+
return { ok: false, provider, message: `no provider verifier configured for ${secretLeafName(key)}`, checked: [] };
|
|
9115
|
+
}
|
|
8106
9116
|
async function vaultSlug(deps, opts) {
|
|
8107
9117
|
return (opts.repo ? opts.repo.split("/").pop() : await deps.slug()).toLowerCase();
|
|
8108
9118
|
}
|
|
@@ -8132,6 +9142,10 @@ function errorDetail(body) {
|
|
|
8132
9142
|
const error = typeof body.error === "string" ? body.error : "";
|
|
8133
9143
|
return error ? `: ${error}` : "";
|
|
8134
9144
|
}
|
|
9145
|
+
async function upgradeMessage(res, body) {
|
|
9146
|
+
if (res.status !== 426) return null;
|
|
9147
|
+
return upgradeRequiredError(res, body ?? await readJsonBody(res));
|
|
9148
|
+
}
|
|
8135
9149
|
async function fetchSecretValue(deps, key, opts) {
|
|
8136
9150
|
if (!isValidSecretKey(key)) return null;
|
|
8137
9151
|
const repo = await targetRepo(deps, opts);
|
|
@@ -8164,7 +9178,7 @@ async function secretsList(deps, opts) {
|
|
|
8164
9178
|
return;
|
|
8165
9179
|
}
|
|
8166
9180
|
if (!res.ok) {
|
|
8167
|
-
deps.err(`secrets list failed: HTTP ${res.status}${await readErr(res)}`);
|
|
9181
|
+
deps.err(await upgradeMessage(res) ?? `secrets list failed: HTTP ${res.status}${await readErr(res)}`);
|
|
8168
9182
|
return;
|
|
8169
9183
|
}
|
|
8170
9184
|
const { secrets: secrets2 } = await res.json();
|
|
@@ -8197,7 +9211,7 @@ async function secretsPreflight(deps, opts) {
|
|
|
8197
9211
|
return false;
|
|
8198
9212
|
}
|
|
8199
9213
|
if (!res.ok) {
|
|
8200
|
-
deps.err(`secrets preflight failed: HTTP ${res.status}${await readErr(res)}`);
|
|
9214
|
+
deps.err(await upgradeMessage(res) ?? `secrets preflight failed: HTTP ${res.status}${await readErr(res)}`);
|
|
8201
9215
|
return false;
|
|
8202
9216
|
}
|
|
8203
9217
|
const { secrets: secrets2 } = await res.json();
|
|
@@ -8232,7 +9246,7 @@ async function secretsGet(deps, key, opts) {
|
|
|
8232
9246
|
return false;
|
|
8233
9247
|
}
|
|
8234
9248
|
deps.err(
|
|
8235
|
-
res.status === 403 ? `secrets get: not authorized for ${key} (HTTP 403)${errorDetail(body)}` : `secrets get failed: HTTP ${res.status}${errorDetail(body)}`
|
|
9249
|
+
await upgradeMessage(res, body) ?? (res.status === 403 ? `secrets get: not authorized for ${key} (HTTP 403)${errorDetail(body)}` : `secrets get failed: HTTP ${res.status}${errorDetail(body)}`)
|
|
8236
9250
|
);
|
|
8237
9251
|
return false;
|
|
8238
9252
|
}
|
|
@@ -8240,6 +9254,28 @@ async function secretsGet(deps, key, opts) {
|
|
|
8240
9254
|
deps.log(value ?? "");
|
|
8241
9255
|
return true;
|
|
8242
9256
|
}
|
|
9257
|
+
async function secretsVerify(deps, key, opts) {
|
|
9258
|
+
if (!isValidSecretKey(key)) {
|
|
9259
|
+
deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
9260
|
+
return false;
|
|
9261
|
+
}
|
|
9262
|
+
if (!providerForSecretKey(key)) {
|
|
9263
|
+
deps.err(`no provider verifier configured for ${secretLeafName(key)}`);
|
|
9264
|
+
return false;
|
|
9265
|
+
}
|
|
9266
|
+
const value = await fetchSecretValue(deps, key, opts);
|
|
9267
|
+
if (!value) {
|
|
9268
|
+
deps.err(`secrets verify: could not read ${key}; no value was printed`);
|
|
9269
|
+
return false;
|
|
9270
|
+
}
|
|
9271
|
+
const result = await verifySecretValue(deps, key, value, opts);
|
|
9272
|
+
if (result.ok) {
|
|
9273
|
+
deps.log(result.message);
|
|
9274
|
+
return true;
|
|
9275
|
+
}
|
|
9276
|
+
deps.err(result.message);
|
|
9277
|
+
return false;
|
|
9278
|
+
}
|
|
8243
9279
|
async function secretsRequest(deps, key, opts) {
|
|
8244
9280
|
if (!isValidSecretKey(key)) {
|
|
8245
9281
|
deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
@@ -8260,7 +9296,7 @@ async function secretsRequest(deps, key, opts) {
|
|
|
8260
9296
|
});
|
|
8261
9297
|
const body = await readJsonBody(res);
|
|
8262
9298
|
if (!res.ok) {
|
|
8263
|
-
deps.err(`secrets request failed: HTTP ${res.status}${errorDetail(body)}`);
|
|
9299
|
+
deps.err(await upgradeMessage(res, body) ?? `secrets request failed: HTTP ${res.status}${errorDetail(body)}`);
|
|
8264
9300
|
return false;
|
|
8265
9301
|
}
|
|
8266
9302
|
if (opts.json) {
|
|
@@ -8290,21 +9326,34 @@ async function putSecret(deps, key, value, opts) {
|
|
|
8290
9326
|
});
|
|
8291
9327
|
if (!res.ok) {
|
|
8292
9328
|
deps.err(
|
|
8293
|
-
res.status === 403 ? `secrets set: not authorized to write ${key} (HTTP 403)${await readErr(res)}` : `secrets set failed: HTTP ${res.status}${await readErr(res)}`
|
|
9329
|
+
await upgradeMessage(res) ?? (res.status === 403 ? `secrets set: not authorized to write ${key} (HTTP 403)${await readErr(res)}` : `secrets set failed: HTTP ${res.status}${await readErr(res)}`)
|
|
8294
9330
|
);
|
|
8295
9331
|
return false;
|
|
8296
9332
|
}
|
|
9333
|
+
const provider = providerForSecretKey(key);
|
|
9334
|
+
if (provider) {
|
|
9335
|
+
const result = await verifySecretValue(deps, key, value, opts);
|
|
9336
|
+
if (!result.ok) {
|
|
9337
|
+
deps.err(`set ${key} (${classifyTier(await vaultSlug(deps, opts), key)} tier), but ${result.message}`);
|
|
9338
|
+
return false;
|
|
9339
|
+
}
|
|
9340
|
+
deps.log(`set ${key} (${classifyTier(await vaultSlug(deps, opts), key)} tier); ${result.message}`);
|
|
9341
|
+
return true;
|
|
9342
|
+
}
|
|
8297
9343
|
deps.log(`set ${key} (${classifyTier(await vaultSlug(deps, opts), key)} tier)`);
|
|
8298
9344
|
return true;
|
|
8299
9345
|
}
|
|
8300
9346
|
async function secretsSet(deps, key, opts) {
|
|
8301
|
-
if (!isValidSecretKey(key))
|
|
9347
|
+
if (!isValidSecretKey(key)) {
|
|
9348
|
+
deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
9349
|
+
return false;
|
|
9350
|
+
}
|
|
8302
9351
|
const value = await deps.readSecretValue(`value for ${key} (input hidden; will not be echoed): `);
|
|
8303
9352
|
if (!value) {
|
|
8304
9353
|
deps.err("secrets set: empty value \u2014 aborted (nothing written)");
|
|
8305
|
-
return;
|
|
9354
|
+
return false;
|
|
8306
9355
|
}
|
|
8307
|
-
|
|
9356
|
+
return putSecret(deps, key, value, opts);
|
|
8308
9357
|
}
|
|
8309
9358
|
async function secretsEdit(deps, key, opts) {
|
|
8310
9359
|
return secretsSet(deps, key, opts);
|
|
@@ -8320,7 +9369,7 @@ async function secretsRemove(deps, key, opts) {
|
|
|
8320
9369
|
});
|
|
8321
9370
|
if (!res.ok) {
|
|
8322
9371
|
deps.err(
|
|
8323
|
-
res.status === 403 ? `secrets rm: not authorized to remove ${key} (HTTP 403)${await readErr(res)}` : `secrets rm failed: HTTP ${res.status}${await readErr(res)}`
|
|
9372
|
+
await upgradeMessage(res) ?? (res.status === 403 ? `secrets rm: not authorized to remove ${key} (HTTP 403)${await readErr(res)}` : `secrets rm failed: HTTP ${res.status}${await readErr(res)}`)
|
|
8324
9373
|
);
|
|
8325
9374
|
return;
|
|
8326
9375
|
}
|
|
@@ -8335,7 +9384,7 @@ async function secretsGrant(deps, repo, login, key, _opts) {
|
|
|
8335
9384
|
});
|
|
8336
9385
|
if (!res.ok) {
|
|
8337
9386
|
deps.err(
|
|
8338
|
-
res.status === 403 ? `secrets grant: master-admin only (HTTP 403)${await readErr(res)}` : `secrets grant failed: HTTP ${res.status}${await readErr(res)}`
|
|
9387
|
+
await upgradeMessage(res) ?? (res.status === 403 ? `secrets grant: master-admin only (HTTP 403)${await readErr(res)}` : `secrets grant failed: HTTP ${res.status}${await readErr(res)}`)
|
|
8339
9388
|
);
|
|
8340
9389
|
return;
|
|
8341
9390
|
}
|
|
@@ -8350,7 +9399,7 @@ async function secretsRevoke(deps, repo, login, key, _opts) {
|
|
|
8350
9399
|
});
|
|
8351
9400
|
if (!res.ok) {
|
|
8352
9401
|
deps.err(
|
|
8353
|
-
res.status === 403 ? `secrets revoke: master-admin only (HTTP 403)${await readErr(res)}` : `secrets revoke failed: HTTP ${res.status}${await readErr(res)}`
|
|
9402
|
+
await upgradeMessage(res) ?? (res.status === 403 ? `secrets revoke: master-admin only (HTTP 403)${await readErr(res)}` : `secrets revoke failed: HTTP ${res.status}${await readErr(res)}`)
|
|
8354
9403
|
);
|
|
8355
9404
|
return;
|
|
8356
9405
|
}
|
|
@@ -8487,7 +9536,8 @@ async function awsCallerArn() {
|
|
|
8487
9536
|
async function hubHeaders(extra = {}) {
|
|
8488
9537
|
const cfg = await loadConfig();
|
|
8489
9538
|
const t = await hubAuthToken({ baseUrl: cfg.sagaApiUrl ?? defaultHubUrl(), githubToken });
|
|
8490
|
-
|
|
9539
|
+
const base = { ...clientVersionHeaders(), ...extra };
|
|
9540
|
+
return t ? { ...base, Authorization: `Bearer ${t}` } : base;
|
|
8491
9541
|
}
|
|
8492
9542
|
async function loadConfig() {
|
|
8493
9543
|
let file = {};
|
|
@@ -8550,7 +9600,7 @@ function sessionDeps() {
|
|
|
8550
9600
|
env: process.env,
|
|
8551
9601
|
readPersisted: () => {
|
|
8552
9602
|
try {
|
|
8553
|
-
return (0,
|
|
9603
|
+
return (0, import_node_fs7.readFileSync)(SESSION_FILE, "utf8");
|
|
8554
9604
|
} catch {
|
|
8555
9605
|
return null;
|
|
8556
9606
|
}
|
|
@@ -8563,8 +9613,8 @@ function sessionDeps() {
|
|
|
8563
9613
|
var resolveSessionId = () => resolveSession(sessionDeps());
|
|
8564
9614
|
function persistSession(id) {
|
|
8565
9615
|
try {
|
|
8566
|
-
(0,
|
|
8567
|
-
(0,
|
|
9616
|
+
(0, import_node_fs7.mkdirSync)(".mmi", { recursive: true });
|
|
9617
|
+
(0, import_node_fs7.writeFileSync)(SESSION_FILE, id, "utf8");
|
|
8568
9618
|
} catch {
|
|
8569
9619
|
}
|
|
8570
9620
|
}
|
|
@@ -8683,22 +9733,20 @@ async function applyGcPlan(plan2, remote) {
|
|
|
8683
9733
|
}
|
|
8684
9734
|
return result;
|
|
8685
9735
|
}
|
|
8686
|
-
function
|
|
9736
|
+
async function fetchHubVersionInfo(baseUrl) {
|
|
9737
|
+
if (!baseUrl) return null;
|
|
8687
9738
|
try {
|
|
8688
|
-
const
|
|
8689
|
-
|
|
9739
|
+
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/version`, { signal: AbortSignal.timeout(4e3) });
|
|
9740
|
+
if (!res.ok) return null;
|
|
9741
|
+
const body = await res.json();
|
|
9742
|
+
return body && typeof body === "object" ? body : null;
|
|
8690
9743
|
} catch {
|
|
8691
|
-
|
|
8692
|
-
const pkg = (0, import_node_path7.join)(__dirname, "..", "package.json");
|
|
8693
|
-
return JSON.parse((0, import_node_fs6.readFileSync)(pkg, "utf8")).version || "0.0.0";
|
|
8694
|
-
} catch {
|
|
8695
|
-
return "0.0.0";
|
|
8696
|
-
}
|
|
9744
|
+
return null;
|
|
8697
9745
|
}
|
|
8698
9746
|
}
|
|
8699
9747
|
function readRepoVersion() {
|
|
8700
9748
|
try {
|
|
8701
|
-
return JSON.parse((0,
|
|
9749
|
+
return JSON.parse((0, import_node_fs7.readFileSync)((0, import_node_path8.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
|
|
8702
9750
|
} catch {
|
|
8703
9751
|
return void 0;
|
|
8704
9752
|
}
|
|
@@ -8737,7 +9785,7 @@ async function applyVersionAutoUpdate(report, log) {
|
|
|
8737
9785
|
}
|
|
8738
9786
|
async function requireFreshTrainCli(commandName) {
|
|
8739
9787
|
const report = buildVersionLagReport({
|
|
8740
|
-
currentVersion:
|
|
9788
|
+
currentVersion: resolveClientVersion(),
|
|
8741
9789
|
repoVersion: readRepoVersion(),
|
|
8742
9790
|
releasedVersion: await fetchReleasedVersion()
|
|
8743
9791
|
});
|
|
@@ -8767,7 +9815,7 @@ async function applyClaudePluginHeal(surface, log) {
|
|
|
8767
9815
|
return true;
|
|
8768
9816
|
}
|
|
8769
9817
|
var program2 = new Command();
|
|
8770
|
-
program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(
|
|
9818
|
+
program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveClientVersion());
|
|
8771
9819
|
async function runRulesSync(opts, io = consoleIo) {
|
|
8772
9820
|
const cfg = await loadConfig();
|
|
8773
9821
|
if (isRulesSource(cfg.orgRulesSource)) {
|
|
@@ -8777,7 +9825,13 @@ async function runRulesSync(opts, io = consoleIo) {
|
|
|
8777
9825
|
const base = resolveRulesBase(cfg.orgRulesSource, DEFAULT_RULES_SOURCE);
|
|
8778
9826
|
const token = await githubToken();
|
|
8779
9827
|
let changed = 0;
|
|
8780
|
-
const files = [
|
|
9828
|
+
const files = [
|
|
9829
|
+
"AGENTS.md",
|
|
9830
|
+
"CLAUDE.md",
|
|
9831
|
+
".claude/settings.json",
|
|
9832
|
+
".claude/output-styles/mmi-plain.md",
|
|
9833
|
+
".cursor/rules/mmi-plain-language.mdc"
|
|
9834
|
+
];
|
|
8781
9835
|
const fetched = await Promise.all(files.map(async (file) => {
|
|
8782
9836
|
try {
|
|
8783
9837
|
const url = `${base}/${file}`;
|
|
@@ -8795,10 +9849,10 @@ async function runRulesSync(opts, io = consoleIo) {
|
|
|
8795
9849
|
for (const entry of fetched) {
|
|
8796
9850
|
if ("error" in entry) continue;
|
|
8797
9851
|
const { file, source } = entry;
|
|
8798
|
-
const current = (0,
|
|
9852
|
+
const current = (0, import_node_fs7.existsSync)(file) ? await (0, import_promises2.readFile)(file, "utf8") : null;
|
|
8799
9853
|
if (needsUpdate(source, current)) {
|
|
8800
9854
|
const slash = file.lastIndexOf("/");
|
|
8801
|
-
if (slash > 0) (0,
|
|
9855
|
+
if (slash > 0) (0, import_node_fs7.mkdirSync)(file.slice(0, slash), { recursive: true });
|
|
8802
9856
|
await (0, import_promises2.writeFile)(file, normalizeEol(source), "utf8");
|
|
8803
9857
|
changed++;
|
|
8804
9858
|
if (!opts.quiet) io.log(`mmi-cli rules: updated ${file}`);
|
|
@@ -8808,7 +9862,7 @@ async function runRulesSync(opts, io = consoleIo) {
|
|
|
8808
9862
|
return failures.length === 0;
|
|
8809
9863
|
}
|
|
8810
9864
|
var rules = program2.command("rules").description("org rules delivery");
|
|
8811
|
-
rules.command("sync").option("--quiet", "stay silent unless something changed or errored").description("fetch AGENTS.md / CLAUDE.md / .claude/settings.json from MMI-Hub and write them verbatim (org-owned, whole-file)").action(async (opts) => {
|
|
9865
|
+
rules.command("sync").option("--quiet", "stay silent unless something changed or errored").description("fetch the org-delivered files (AGENTS.md / CLAUDE.md / .claude/settings.json / output style / Cursor rule) from MMI-Hub and write them verbatim (org-owned, whole-file)").action(async (opts) => {
|
|
8812
9866
|
if (!await runRulesSync(opts)) process.exitCode = 1;
|
|
8813
9867
|
});
|
|
8814
9868
|
async function runDocsSync(opts, io = consoleIo) {
|
|
@@ -8824,7 +9878,7 @@ async function runDocsSync(opts, io = consoleIo) {
|
|
|
8824
9878
|
return null;
|
|
8825
9879
|
}
|
|
8826
9880
|
},
|
|
8827
|
-
localContent: async (f) => (0,
|
|
9881
|
+
localContent: async (f) => (0, import_node_fs7.existsSync)(f) ? await (0, import_promises2.readFile)(f, "utf8") : null,
|
|
8828
9882
|
writeDoc: async (f, c) => {
|
|
8829
9883
|
await (0, import_promises2.writeFile)(f, c, "utf8");
|
|
8830
9884
|
}
|
|
@@ -8972,6 +10026,18 @@ async function runSagaHealth(o, io = consoleIo) {
|
|
|
8972
10026
|
io.log(`saga health: ${report.ok ? "OK" : "NOT OK"}`);
|
|
8973
10027
|
if (report.problems.length) io.log(report.problems.map((p) => ` - ${p}`).join("\n"));
|
|
8974
10028
|
}
|
|
10029
|
+
async function runWhoami(io = consoleIo) {
|
|
10030
|
+
const cfg = await loadConfig();
|
|
10031
|
+
const report = await resolveWhoami({
|
|
10032
|
+
hubSession: () => hubAuthSession({ baseUrl: cfg.sagaApiUrl ?? defaultHubUrl(), githubToken }),
|
|
10033
|
+
ghLogin: githubLogin
|
|
10034
|
+
});
|
|
10035
|
+
io.log(JSON.stringify(report));
|
|
10036
|
+
return report;
|
|
10037
|
+
}
|
|
10038
|
+
program2.command("whoami").description('resolve the logged-in human: {login, source, sessionExpiresAt} JSON; source "unknown" (exit 0) when neither the Hub session nor gh can name them').action(async () => {
|
|
10039
|
+
await runWhoami();
|
|
10040
|
+
});
|
|
8975
10041
|
saga.command("health").option("--json", "machine-readable output").option("--banner", "one-line SessionStart banner; silent when healthy").option("--quiet", "suppress detail output").description("zero-write health check: auth, backend reachability, resolved key").action((o) => runSagaHealth(o));
|
|
8976
10042
|
program2.command("gc").description("dry-run cleanup for merged/closed PR branches and stale tracking refs").option("--dry-run", "show what would be deleted (default)").option("--apply", "delete only the listed clean merged/closed PR branches and stale tracking refs").option("--json", "machine-readable output").option("--remote <name>", "remote name", "origin").option("--limit <n>", "PRs to inspect per state", "200").action(async (o) => {
|
|
8977
10043
|
if (o.apply && o.dryRun) return fail("gc: choose either --dry-run or --apply");
|
|
@@ -9100,7 +10166,7 @@ function scheduleRelatedDiscovery(o) {
|
|
|
9100
10166
|
}
|
|
9101
10167
|
}
|
|
9102
10168
|
function makePlanDeps(cfg, io = consoleIo) {
|
|
9103
|
-
const ensureDir = () => (0,
|
|
10169
|
+
const ensureDir = () => (0, import_node_fs7.mkdirSync)(PLANS_DIR, { recursive: true });
|
|
9104
10170
|
return {
|
|
9105
10171
|
apiUrl: cfg.sagaApiUrl,
|
|
9106
10172
|
fetch: (url, init = {}) => fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(1e4) }),
|
|
@@ -9108,31 +10174,31 @@ function makePlanDeps(cfg, io = consoleIo) {
|
|
|
9108
10174
|
project: async () => (await sagaKey(cfg)).project,
|
|
9109
10175
|
readLocal: (slug) => {
|
|
9110
10176
|
try {
|
|
9111
|
-
return (0,
|
|
10177
|
+
return (0, import_node_fs7.readFileSync)(planPath(slug), "utf8");
|
|
9112
10178
|
} catch {
|
|
9113
10179
|
return null;
|
|
9114
10180
|
}
|
|
9115
10181
|
},
|
|
9116
10182
|
writeLocal: (slug, content) => {
|
|
9117
10183
|
ensureDir();
|
|
9118
|
-
(0,
|
|
10184
|
+
(0, import_node_fs7.writeFileSync)(planPath(slug), content, "utf8");
|
|
9119
10185
|
},
|
|
9120
10186
|
removeLocal: (slug) => {
|
|
9121
10187
|
try {
|
|
9122
|
-
(0,
|
|
10188
|
+
(0, import_node_fs7.rmSync)(planPath(slug));
|
|
9123
10189
|
} catch {
|
|
9124
10190
|
}
|
|
9125
10191
|
},
|
|
9126
10192
|
readMetaRaw: () => {
|
|
9127
10193
|
try {
|
|
9128
|
-
return (0,
|
|
10194
|
+
return (0, import_node_fs7.readFileSync)(META_FILE, "utf8");
|
|
9129
10195
|
} catch {
|
|
9130
10196
|
return null;
|
|
9131
10197
|
}
|
|
9132
10198
|
},
|
|
9133
10199
|
writeMetaRaw: (raw) => {
|
|
9134
10200
|
ensureDir();
|
|
9135
|
-
(0,
|
|
10201
|
+
(0, import_node_fs7.writeFileSync)(META_FILE, raw, "utf8");
|
|
9136
10202
|
},
|
|
9137
10203
|
log: (m) => io.log(m),
|
|
9138
10204
|
err: (m) => io.err(m),
|
|
@@ -9273,8 +10339,18 @@ secrets.command("request <key>").description("approved escalation: create a Hub
|
|
|
9273
10339
|
const ok = await secretsRequest(d, key, o);
|
|
9274
10340
|
if (!ok) process.exitCode = 1;
|
|
9275
10341
|
}));
|
|
9276
|
-
secrets.command("
|
|
9277
|
-
|
|
10342
|
+
secrets.command("verify <key>").description("validate a known provider secret without printing its value").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets(async (d) => {
|
|
10343
|
+
const ok = await secretsVerify(d, key, o);
|
|
10344
|
+
if (!ok) process.exitCode = 1;
|
|
10345
|
+
}));
|
|
10346
|
+
secrets.command("set <key>").description("write/rotate a secret; value is read from stdin (never an argument)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets(async (d) => {
|
|
10347
|
+
const ok = await secretsSet(d, key, o);
|
|
10348
|
+
if (!ok) process.exitCode = 1;
|
|
10349
|
+
}));
|
|
10350
|
+
secrets.command("edit <key>").description("alias for set \u2014 replace a secret value (read from stdin)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets(async (d) => {
|
|
10351
|
+
const ok = await secretsEdit(d, key, o);
|
|
10352
|
+
if (!ok) process.exitCode = 1;
|
|
10353
|
+
}));
|
|
9278
10354
|
secrets.command("rm <key>").description("remove a secret (project tier self-serve; org tier needs a grant)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsRemove(d, key, o)));
|
|
9279
10355
|
secrets.command("use <key>").description("print guidance on consuming a secret without committing it (no value)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsUse(d, key, o)));
|
|
9280
10356
|
secrets.command("grant <repo> <login> <key>").description("MASTER-ONLY: grant a project-admin standing access to a specific org-tier secret").action((repo, login, key) => withSecrets((d) => secretsGrant(d, repo, login, key, {})));
|
|
@@ -9297,7 +10373,13 @@ function reportWrite(label, res) {
|
|
|
9297
10373
|
var tenant = program2.command("tenant").description("tenant runtime control through Hub authority");
|
|
9298
10374
|
tenant.command("control <owner/repo> <stage> <action>").description("run bounded service control (status/start/stop/restart, plus rc-only retire) for a tenant; project-admin dev/rc, master main").option("--json", "machine-readable output").action(async (repo, stage2, action) => {
|
|
9299
10375
|
const cfg = await loadConfig();
|
|
9300
|
-
const
|
|
10376
|
+
const wait = action === "status" || action === "retire";
|
|
10377
|
+
const res = await tenantControl({ repo, stage: stage2, action, wait }, registryClientDeps(cfg));
|
|
10378
|
+
const body = res.body;
|
|
10379
|
+
if (!res.ok && body?.category) {
|
|
10380
|
+
console.log(JSON.stringify(body));
|
|
10381
|
+
return fail(`tenant control ${stage2} ${action}: ${body.category}`);
|
|
10382
|
+
}
|
|
9301
10383
|
reportWrite("tenant control", res);
|
|
9302
10384
|
});
|
|
9303
10385
|
tenant.command("redeploy <owner/repo> <stage>").description("re-dispatch the central tenant-deploy.yml for an already-promoted ref (no re-tag/merge); train-authority gated").option("--ref <ref>", "ref to deploy (defaults to the stage branch rc/main \u2014 the promoted ref)").option("--watch", "block on the dispatched run and report its outcome (gh run watch --exit-status)").option("--json", "machine-readable output").action(async (repo, stage2, o) => {
|
|
@@ -9309,9 +10391,18 @@ tenant.command("redeploy <owner/repo> <stage>").description("re-dispatch the cen
|
|
|
9309
10391
|
return fail(`tenant redeploy: ${e.message}`);
|
|
9310
10392
|
}
|
|
9311
10393
|
});
|
|
10394
|
+
async function resolveDnsBounded(host, timeoutMs = 3e3) {
|
|
10395
|
+
const { lookup } = await import("node:dns/promises");
|
|
10396
|
+
const probe = lookup(host).then(() => true).catch((e) => dnsErrorToResolution(e?.code));
|
|
10397
|
+
const timeout = new Promise((resolve) => {
|
|
10398
|
+
setTimeout(() => resolve(void 0), timeoutMs).unref?.();
|
|
10399
|
+
});
|
|
10400
|
+
return Promise.race([probe, timeout]);
|
|
10401
|
+
}
|
|
9312
10402
|
async function v2ReadinessDeps(cfg) {
|
|
9313
10403
|
const reg = registryClientDeps(cfg);
|
|
9314
10404
|
return {
|
|
10405
|
+
resolveDns: (host) => resolveDnsBounded(host),
|
|
9315
10406
|
// Checked read (#727/#733): the doctor distinguishes a FAILED read (degraded report) from a 404.
|
|
9316
10407
|
getProject: (slug) => fetchProjectBySlugChecked(slug, reg),
|
|
9317
10408
|
hasDeployCoords: async (slug, stage2) => {
|
|
@@ -9753,7 +10844,7 @@ async function remoteBranchExists(branch) {
|
|
|
9753
10844
|
var COMPOSE_TIMEOUT_MS = 12e4;
|
|
9754
10845
|
function teardownWorktreeStage(worktreePath) {
|
|
9755
10846
|
return runWorktreeStageTeardown(worktreePath, {
|
|
9756
|
-
hasStageState: (wt) => (0,
|
|
10847
|
+
hasStageState: (wt) => (0, import_node_fs7.existsSync)(stageStatePath(wt)),
|
|
9757
10848
|
stopRecordedStage: async (wt) => (await stopStage({ cwd: wt })).pid,
|
|
9758
10849
|
listComposeProjects: async () => {
|
|
9759
10850
|
const { stdout } = await execFileP4("docker", ["compose", "ls", "--all", "--format", "json"], { timeout: GC_GH_TIMEOUT_MS });
|
|
@@ -9827,17 +10918,40 @@ async function runBoardRead(o) {
|
|
|
9827
10918
|
}
|
|
9828
10919
|
var board = program2.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
|
|
9829
10920
|
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));
|
|
9830
|
-
board.command("claim <
|
|
10921
|
+
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) => {
|
|
10922
|
+
if (issueRefs.length === 1) {
|
|
10923
|
+
const issueRef = issueRefs[0];
|
|
10924
|
+
try {
|
|
10925
|
+
const result = await claimBoardIssue({
|
|
10926
|
+
config: await loadConfigForBoardSelector(issueRef, o.repo),
|
|
10927
|
+
selector: issueRef,
|
|
10928
|
+
repo: o.repo,
|
|
10929
|
+
assignee: o.for,
|
|
10930
|
+
allowPartial: o.allowPartial
|
|
10931
|
+
});
|
|
10932
|
+
if (o.json) return console.log(JSON.stringify(result));
|
|
10933
|
+
console.log(result.partial ? `Partially claimed ${result.item.ref}: ${result.warning}` : `Claimed ${result.item.ref} - In Progress`);
|
|
10934
|
+
} catch (e) {
|
|
10935
|
+
fail(`board claim failed: ${e.message}`);
|
|
10936
|
+
}
|
|
10937
|
+
return;
|
|
10938
|
+
}
|
|
9831
10939
|
try {
|
|
9832
|
-
const
|
|
9833
|
-
config: await loadConfigForBoardSelector(
|
|
9834
|
-
|
|
10940
|
+
const bulk = await claimBoardIssues({
|
|
10941
|
+
config: await loadConfigForBoardSelector(issueRefs[0], o.repo),
|
|
10942
|
+
selectors: issueRefs,
|
|
9835
10943
|
repo: o.repo,
|
|
9836
10944
|
assignee: o.for,
|
|
9837
10945
|
allowPartial: o.allowPartial
|
|
9838
10946
|
});
|
|
9839
|
-
if (o.json)
|
|
9840
|
-
|
|
10947
|
+
if (o.json) {
|
|
10948
|
+
console.log(JSON.stringify(bulk.results));
|
|
10949
|
+
} else {
|
|
10950
|
+
for (const result of bulk.results) {
|
|
10951
|
+
console.log(result.claimed ? result.partial ? `Partially claimed ${result.ref}: ${result.warning}` : `Claimed ${result.ref} - In Progress` : `Skipped ${result.ref}: ${result.reason}`);
|
|
10952
|
+
}
|
|
10953
|
+
}
|
|
10954
|
+
if (bulk.failed > 0) process.exitCode = 1;
|
|
9841
10955
|
} catch (e) {
|
|
9842
10956
|
fail(`board claim failed: ${e.message}`);
|
|
9843
10957
|
}
|
|
@@ -9917,7 +11031,7 @@ function rawValues(flag) {
|
|
|
9917
11031
|
return out;
|
|
9918
11032
|
}
|
|
9919
11033
|
function printLine(value) {
|
|
9920
|
-
(0,
|
|
11034
|
+
(0, import_node_fs7.writeSync)(1, `${value}
|
|
9921
11035
|
`);
|
|
9922
11036
|
}
|
|
9923
11037
|
function stageKeepAlive() {
|
|
@@ -9934,8 +11048,8 @@ async function resolveStage() {
|
|
|
9934
11048
|
local,
|
|
9935
11049
|
shell: shellFor(),
|
|
9936
11050
|
registry: { deployModel: project2?.deployModel, portRange, error: read.ok ? void 0 : read.error },
|
|
9937
|
-
hasCompose: (0,
|
|
9938
|
-
hasEnvExample: (0,
|
|
11051
|
+
hasCompose: (0, import_node_fs7.existsSync)((0, import_node_path8.join)(process.cwd(), "docker-compose.yml")),
|
|
11052
|
+
hasEnvExample: (0, import_node_fs7.existsSync)((0, import_node_path8.join)(process.cwd(), ".env.example"))
|
|
9939
11053
|
});
|
|
9940
11054
|
}
|
|
9941
11055
|
function stageStepsFor(res, stops = true) {
|
|
@@ -9958,9 +11072,9 @@ function reportedStageUrl(res, result) {
|
|
|
9958
11072
|
return result.port != null ? stageUrlForPort(result.port) : res.derived.url;
|
|
9959
11073
|
}
|
|
9960
11074
|
program2.command("port-range <repo>").description("assign (idempotently) + print the repo's local stage port block via the atomic ORG#config.portCursor allocator (committed-file fallback)").option("--json", "machine-readable output").action(async (repo, o) => {
|
|
9961
|
-
const path2 = (0,
|
|
11075
|
+
const path2 = (0, import_node_path8.join)(process.cwd(), "infra", "port-ranges.json");
|
|
9962
11076
|
const allocate = async (seed) => {
|
|
9963
|
-
const { stdout } = await execFileP4("node", [(0,
|
|
11077
|
+
const { stdout } = await execFileP4("node", [(0, import_node_path8.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
|
|
9964
11078
|
const parsed = JSON.parse(stdout);
|
|
9965
11079
|
if (!Array.isArray(parsed.range) || parsed.range.length !== 2) throw new Error("port-ddb: no range in output");
|
|
9966
11080
|
return parsed.range;
|
|
@@ -10078,16 +11192,29 @@ program2.command("stage-live").description("explain that remote rc/live environm
|
|
|
10078
11192
|
});
|
|
10079
11193
|
var GH_TRAIN_TIMEOUT_MS = 3e4;
|
|
10080
11194
|
var GH_RUN_WATCH_TIMEOUT_MS = 20 * 6e4;
|
|
11195
|
+
var NODE_PREPARE_TIMEOUT_MS = 10 * 6e4;
|
|
10081
11196
|
function trainApplyDeps() {
|
|
10082
11197
|
return {
|
|
10083
11198
|
run: async (file, args) => {
|
|
10084
|
-
const timeout = file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
|
|
11199
|
+
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;
|
|
10085
11200
|
return (await execFileP4(file, args, { timeout })).stdout;
|
|
10086
11201
|
},
|
|
10087
11202
|
runSelf: async (args) => (await execFileP4(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
|
|
10088
11203
|
trainAuthority: async (repo) => {
|
|
10089
11204
|
const verdict = await fetchTrainAuthority(repo, registryClientDeps(await loadConfig()));
|
|
10090
11205
|
return verdict.ok ? { ok: true, role: verdict.authority.role, train: verdict.authority.train } : verdict;
|
|
11206
|
+
},
|
|
11207
|
+
// Slack release announcement (#883): Hub-only + best-effort inside announceRelease itself.
|
|
11208
|
+
announce: (args) => announceRelease({
|
|
11209
|
+
run: async (file, cmdArgs) => (await execFileP4(file, cmdArgs, { timeout: GH_TRAIN_TIMEOUT_MS })).stdout,
|
|
11210
|
+
readFile: (path2) => (0, import_promises2.readFile)(path2, "utf8")
|
|
11211
|
+
}, args),
|
|
11212
|
+
fetchEdgeDomains: async (slug) => {
|
|
11213
|
+
const proj = await fetchProjectBySlug(slug, registryClientDeps(await loadConfig()));
|
|
11214
|
+
const ed = proj?.edgeDomains;
|
|
11215
|
+
if (!ed) return null;
|
|
11216
|
+
const toArr = (v) => v ? [v] : void 0;
|
|
11217
|
+
return { rc: toArr(ed.rc), main: toArr(ed.main) };
|
|
10091
11218
|
}
|
|
10092
11219
|
};
|
|
10093
11220
|
}
|
|
@@ -10100,23 +11227,24 @@ function renderDeployLine(d) {
|
|
|
10100
11227
|
return parts.join("; ");
|
|
10101
11228
|
}
|
|
10102
11229
|
function renderTrainApply(commandName, r) {
|
|
10103
|
-
|
|
10104
|
-
|
|
11230
|
+
let base = `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
|
|
11231
|
+
if (r.resumeNote) base = `${base}; ${r.resumeNote}`;
|
|
11232
|
+
if (r.rcRetirement) base = `${base}; rc retirement: ${r.rcRetirement.toUpperCase()} (${r.rcRetirementNote ?? ""})`;
|
|
11233
|
+
return r.announceNote ? `${base}; announce: ${r.announceNote}` : base;
|
|
10105
11234
|
}
|
|
10106
11235
|
function renderTenantRedeploy(r) {
|
|
10107
11236
|
return `mmi-cli tenant redeploy: ${r.repo} ${r.stage} (ref=${r.ref}) [${r.deployModel}]; ${renderDeployLine(r)}`;
|
|
10108
11237
|
}
|
|
10109
|
-
for (const commandName of ["rcand", "release"
|
|
10110
|
-
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",
|
|
11238
|
+
for (const commandName of ["rcand", "release"]) {
|
|
11239
|
+
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").option("--announce-summary-file <path>", "release only: agent-curated summary lines for the Hub Slack announcement (#883)").action(async (o) => {
|
|
10111
11240
|
try {
|
|
10112
11241
|
await requireFreshTrainCli(commandName);
|
|
10113
11242
|
} catch (e) {
|
|
10114
11243
|
return fail(`${commandName}: ${e.message}`);
|
|
10115
11244
|
}
|
|
10116
11245
|
if (o.apply) {
|
|
10117
|
-
if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
|
|
10118
11246
|
try {
|
|
10119
|
-
const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch });
|
|
11247
|
+
const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile });
|
|
10120
11248
|
return printLine(o.json ? JSON.stringify(result, null, 2) : renderTrainApply(commandName, result));
|
|
10121
11249
|
} catch (e) {
|
|
10122
11250
|
return fail(`${commandName}: ${e.message}`);
|
|
@@ -10126,6 +11254,52 @@ for (const commandName of ["rcand", "release", "hotfix"]) {
|
|
|
10126
11254
|
console.log(o.json ? JSON.stringify({ command: commandName, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps));
|
|
10127
11255
|
});
|
|
10128
11256
|
}
|
|
11257
|
+
function renderHotfixStart(r) {
|
|
11258
|
+
return [`mmi-cli hotfix start: ${r.tag} (${r.branch}, from ${r.source})${r.reused ? " [reused]" : ""}`, ...r.notes.map((n) => ` - ${n}`)].join("\n");
|
|
11259
|
+
}
|
|
11260
|
+
function renderHotfixRelease(r) {
|
|
11261
|
+
return [
|
|
11262
|
+
`mmi-cli hotfix release: ${r.tag} at ${r.mergedSha.slice(0, 7)} on ${r.repo}`,
|
|
11263
|
+
` - checks: ${r.checks}`,
|
|
11264
|
+
` - ${r.tagNote}`,
|
|
11265
|
+
` - ${r.releaseNote}`,
|
|
11266
|
+
...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.url ? ` (${run.url})` : ""}`),
|
|
11267
|
+
` - ${r.verifyNote}`,
|
|
11268
|
+
...r.announceNote ? [` - announce: ${r.announceNote}`] : [],
|
|
11269
|
+
` - 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)`
|
|
11270
|
+
].join("\n");
|
|
11271
|
+
}
|
|
11272
|
+
function renderHotfixStatus(r) {
|
|
11273
|
+
return [
|
|
11274
|
+
`mmi-cli hotfix status: ${r.tag} on ${r.repo} \u2014 ${r.state}`,
|
|
11275
|
+
` - 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"}`,
|
|
11276
|
+
...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.url ? ` (${run.url})` : ""}`),
|
|
11277
|
+
` - npm @mutmutco/cli: ${r.npmVersion} \xB7 development manifests: ${r.devDistribution.version}${r.devDistribution.aligned ? " (aligned)" : " (behind)"}`,
|
|
11278
|
+
` - next: ${r.next}`
|
|
11279
|
+
].join("\n");
|
|
11280
|
+
}
|
|
11281
|
+
async function runHotfixSub(sub, body, json, render) {
|
|
11282
|
+
try {
|
|
11283
|
+
await requireFreshTrainCli("hotfix");
|
|
11284
|
+
const result = await body();
|
|
11285
|
+
printLine(json ? JSON.stringify(result, null, 2) : render(result));
|
|
11286
|
+
} catch (e) {
|
|
11287
|
+
fail(`hotfix ${sub}: ${e.message}`);
|
|
11288
|
+
}
|
|
11289
|
+
}
|
|
11290
|
+
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) => {
|
|
11291
|
+
try {
|
|
11292
|
+
await requireFreshTrainCli("hotfix");
|
|
11293
|
+
} catch (e) {
|
|
11294
|
+
return fail(`hotfix: ${e.message}`);
|
|
11295
|
+
}
|
|
11296
|
+
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]");
|
|
11297
|
+
const steps = trainPlan("hotfix");
|
|
11298
|
+
console.log(o.json ? JSON.stringify({ command: "hotfix", steps }, null, 2) : renderSteps("mmi-cli hotfix: dry-run plan", steps));
|
|
11299
|
+
});
|
|
11300
|
+
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));
|
|
11301
|
+
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").option("--announce-summary-file <path>", "agent-curated summary lines for the Hub Slack announcement (#883)").action(async (version, o) => runHotfixSub("release", () => runHotfixRelease(trainApplyDeps(), version, { announceSummaryFile: o.announceSummaryFile }), o.json, renderHotfixRelease));
|
|
11302
|
+
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));
|
|
10129
11303
|
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) => {
|
|
10130
11304
|
if (!o.repo) return fail("bootstrap: required option --repo <owner/repo> not specified");
|
|
10131
11305
|
if (o.apply) return fail("bootstrap: execution is not implemented yet; use the dry-run plan and the existing /bootstrap skill");
|
|
@@ -10144,7 +11318,7 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
|
|
|
10144
11318
|
const report = await verifyBootstrap(repo, o.class, {
|
|
10145
11319
|
client: defaultGitHubClient(),
|
|
10146
11320
|
projectMeta: meta,
|
|
10147
|
-
readLocalFile: (path2) => path2 === "projects.json" && apiProjects != null ? apiProjects : (0,
|
|
11321
|
+
readLocalFile: (path2) => path2 === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs7.existsSync)(path2) ? (0, import_node_fs7.readFileSync)(path2, "utf8") : null,
|
|
10148
11322
|
// requiredGcpApis is stored as an array by a JSON write, but `project set --var KEY=VALUE` stores a raw
|
|
10149
11323
|
// comma-string — accept either so the seeded value verifies regardless of how it was written.
|
|
10150
11324
|
requiredGcpApis: (() => {
|
|
@@ -10185,12 +11359,12 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
10185
11359
|
return fail(`bootstrap apply: ${e.message}`);
|
|
10186
11360
|
}
|
|
10187
11361
|
const manifestPath = "skills/bootstrap/seeds/manifest.json";
|
|
10188
|
-
if (!(0,
|
|
10189
|
-
const manifest = loadBootstrapSeeds((0,
|
|
11362
|
+
if (!(0, import_node_fs7.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; run from the MMI-Hub repo root`);
|
|
11363
|
+
const manifest = loadBootstrapSeeds((0, import_node_fs7.readFileSync)(manifestPath, "utf8"));
|
|
10190
11364
|
const baseBranch = o.class === "content" ? "main" : "development";
|
|
10191
11365
|
const slug = parsedRepo.slug;
|
|
10192
11366
|
const gh = async (args) => execFileP4("gh", args, { timeout: 2e4 });
|
|
10193
|
-
const readFile2 = (p) => (0,
|
|
11367
|
+
const readFile2 = (p) => (0, import_node_fs7.existsSync)(p) ? (0, import_node_fs7.readFileSync)(p, "utf8") : null;
|
|
10194
11368
|
const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
|
|
10195
11369
|
const vars = {};
|
|
10196
11370
|
for (const value of rawValues("--var")) {
|
|
@@ -10314,16 +11488,16 @@ access.command("audit").description("audit collaborator roles + train-branch pus
|
|
|
10314
11488
|
if (o.class !== "deployable" && o.class !== "content") return fail("access audit: --class must be deployable or content");
|
|
10315
11489
|
targets = [{ repo: o.repo, class: o.class }];
|
|
10316
11490
|
} else {
|
|
10317
|
-
const projectsJson = registryProjects ? JSON.stringify({ projects: registryProjects }) : (0,
|
|
11491
|
+
const projectsJson = registryProjects ? JSON.stringify({ projects: registryProjects }) : (0, import_node_fs7.existsSync)("projects.json") ? (0, import_node_fs7.readFileSync)("projects.json", "utf8") : null;
|
|
10318
11492
|
if (!projectsJson) return fail("access audit: no project registry \u2014 Hub API unreachable and projects.json not found; run from the MMI-Hub repo root or pass --repo <owner/repo>");
|
|
10319
|
-
const fanoutJson = (0,
|
|
11493
|
+
const fanoutJson = (0, import_node_fs7.existsSync)(".github/fanout-targets.json") ? (0, import_node_fs7.readFileSync)(".github/fanout-targets.json", "utf8") : null;
|
|
10320
11494
|
targets = loadAccessTargets(projectsJson, fanoutJson);
|
|
10321
11495
|
}
|
|
10322
11496
|
const derivedMatrix = registryProjects ? accessMatrixFromProjects(registryProjects) : {};
|
|
10323
|
-
const fileMatrix = (0,
|
|
11497
|
+
const fileMatrix = (0, import_node_fs7.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs7.readFileSync)("access-matrix.json", "utf8")) : {};
|
|
10324
11498
|
const matrix = mergeAccessMatrix(fileMatrix, derivedMatrix);
|
|
10325
11499
|
const derivedContracts = registryProjects ? dataAccessContractsFromProjects(registryProjects) : { consumers: {} };
|
|
10326
|
-
const fileContracts = (0,
|
|
11500
|
+
const fileContracts = (0, import_node_fs7.existsSync)("data-access-contracts.json") ? loadDataAccessContracts((0, import_node_fs7.readFileSync)("data-access-contracts.json", "utf8")) : { consumers: {} };
|
|
10327
11501
|
const dataAccess = mergeDataAccessContracts(fileContracts, derivedContracts);
|
|
10328
11502
|
const report = await auditOrgAccess(targets, deps, matrix, dataAccess);
|
|
10329
11503
|
console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
|
|
@@ -10332,18 +11506,28 @@ access.command("audit").description("audit collaborator roles + train-branch pus
|
|
|
10332
11506
|
var isWin = process.platform === "win32";
|
|
10333
11507
|
var installedPluginsPath = (surface = detectSurface(process.env)) => {
|
|
10334
11508
|
const homeDir = surface === "codex" ? ".codex" : ".claude";
|
|
10335
|
-
return (0,
|
|
11509
|
+
return (0, import_node_path8.join)((0, import_node_os3.homedir)(), homeDir, "plugins", "installed_plugins.json");
|
|
10336
11510
|
};
|
|
10337
11511
|
function readInstalledPlugins() {
|
|
10338
11512
|
try {
|
|
10339
|
-
return JSON.parse((0,
|
|
11513
|
+
return JSON.parse((0, import_node_fs7.readFileSync)(installedPluginsPath(), "utf8"));
|
|
10340
11514
|
} catch {
|
|
10341
11515
|
return null;
|
|
10342
11516
|
}
|
|
10343
11517
|
}
|
|
11518
|
+
function installedPluginSources() {
|
|
11519
|
+
return ["claude", "codex"].map((surface) => {
|
|
11520
|
+
const recordPath = (0, import_node_path8.join)((0, import_node_os3.homedir)(), `.${surface}`, "plugins", "installed_plugins.json");
|
|
11521
|
+
try {
|
|
11522
|
+
return { surface, installed: JSON.parse((0, import_node_fs7.readFileSync)(recordPath, "utf8")), recordPath };
|
|
11523
|
+
} catch {
|
|
11524
|
+
return { surface, installed: null, recordPath };
|
|
11525
|
+
}
|
|
11526
|
+
});
|
|
11527
|
+
}
|
|
10344
11528
|
function readClaudeSettings() {
|
|
10345
11529
|
try {
|
|
10346
|
-
return JSON.parse((0,
|
|
11530
|
+
return JSON.parse((0, import_node_fs7.readFileSync)((0, import_node_path8.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
|
|
10347
11531
|
} catch {
|
|
10348
11532
|
return null;
|
|
10349
11533
|
}
|
|
@@ -10365,7 +11549,7 @@ function writeProjectInstallRecord(record) {
|
|
|
10365
11549
|
const list = file.plugins[MMI_PLUGIN_ID] ?? [];
|
|
10366
11550
|
list.push(record);
|
|
10367
11551
|
file.plugins[MMI_PLUGIN_ID] = list;
|
|
10368
|
-
(0,
|
|
11552
|
+
(0, import_node_fs7.writeFileSync)(installedPluginsPath(), `${JSON.stringify(file, null, 2)}
|
|
10369
11553
|
`, "utf8");
|
|
10370
11554
|
return true;
|
|
10371
11555
|
} catch {
|
|
@@ -10378,25 +11562,57 @@ function backupAndWriteInstalledPlugins(records, pluginId) {
|
|
|
10378
11562
|
if (!file) return false;
|
|
10379
11563
|
if (!file.plugins) file.plugins = {};
|
|
10380
11564
|
const path2 = installedPluginsPath();
|
|
10381
|
-
(0,
|
|
11565
|
+
(0, import_node_fs7.copyFileSync)(path2, `${path2}.bak`);
|
|
10382
11566
|
file.plugins[pluginId] = records;
|
|
10383
|
-
(0,
|
|
11567
|
+
(0, import_node_fs7.writeFileSync)(path2, `${JSON.stringify(file, null, 2)}
|
|
10384
11568
|
`, "utf8");
|
|
10385
11569
|
return true;
|
|
10386
11570
|
} catch {
|
|
10387
11571
|
return false;
|
|
10388
11572
|
}
|
|
10389
11573
|
}
|
|
11574
|
+
function cursorPluginCacheRoot() {
|
|
11575
|
+
return (0, import_node_path8.join)((0, import_node_os3.homedir)(), ".cursor", "plugins", "cache", "mmi", "mmi");
|
|
11576
|
+
}
|
|
11577
|
+
function cursorPluginCachePinSnapshots() {
|
|
11578
|
+
const root = cursorPluginCacheRoot();
|
|
11579
|
+
try {
|
|
11580
|
+
return (0, import_node_fs7.readdirSync)(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => {
|
|
11581
|
+
const path2 = (0, import_node_path8.join)(root, entry.name);
|
|
11582
|
+
const pluginJson = (0, import_node_path8.join)(path2, ".cursor-plugin", "plugin.json");
|
|
11583
|
+
const hooksJson = (0, import_node_path8.join)(path2, "hooks", "hooks.json");
|
|
11584
|
+
let isEmpty = true;
|
|
11585
|
+
try {
|
|
11586
|
+
isEmpty = (0, import_node_fs7.readdirSync)(path2).length === 0;
|
|
11587
|
+
} catch {
|
|
11588
|
+
isEmpty = true;
|
|
11589
|
+
}
|
|
11590
|
+
return {
|
|
11591
|
+
name: entry.name,
|
|
11592
|
+
path: path2,
|
|
11593
|
+
hasPluginJson: (0, import_node_fs7.existsSync)(pluginJson),
|
|
11594
|
+
hasHooksJson: (0, import_node_fs7.existsSync)(hooksJson),
|
|
11595
|
+
isEmpty
|
|
11596
|
+
};
|
|
11597
|
+
});
|
|
11598
|
+
} catch {
|
|
11599
|
+
return [];
|
|
11600
|
+
}
|
|
11601
|
+
}
|
|
11602
|
+
function hubCheckoutForCursorSeed() {
|
|
11603
|
+
const manifest = (0, import_node_path8.join)(process.cwd(), "plugins", "mmi", ".cursor-plugin", "plugin.json");
|
|
11604
|
+
return (0, import_node_fs7.existsSync)(manifest) ? process.cwd() : void 0;
|
|
11605
|
+
}
|
|
10390
11606
|
function mmiPluginCacheRootSnapshots() {
|
|
10391
11607
|
const roots = [
|
|
10392
|
-
{ surface: "claude", root: (0,
|
|
10393
|
-
{ surface: "codex", root: (0,
|
|
11608
|
+
{ surface: "claude", root: (0, import_node_path8.join)((0, import_node_os3.homedir)(), ".claude", "plugins", "cache", "mmi", "mmi") },
|
|
11609
|
+
{ surface: "codex", root: (0, import_node_path8.join)((0, import_node_os3.homedir)(), ".codex", "plugins", "cache", "mmi", "mmi") }
|
|
10394
11610
|
];
|
|
10395
11611
|
return roots.flatMap(({ surface, root }) => {
|
|
10396
11612
|
try {
|
|
10397
|
-
const entries = (0,
|
|
11613
|
+
const entries = (0, import_node_fs7.readdirSync)(root, { withFileTypes: true }).map((entry) => ({
|
|
10398
11614
|
name: entry.name,
|
|
10399
|
-
path: (0,
|
|
11615
|
+
path: (0, import_node_path8.join)(root, entry.name),
|
|
10400
11616
|
isDirectory: entry.isDirectory()
|
|
10401
11617
|
}));
|
|
10402
11618
|
return [{ surface, root, entries }];
|
|
@@ -10406,10 +11622,10 @@ function mmiPluginCacheRootSnapshots() {
|
|
|
10406
11622
|
});
|
|
10407
11623
|
}
|
|
10408
11624
|
function uniqueQuarantineTarget(path2) {
|
|
10409
|
-
if (!(0,
|
|
11625
|
+
if (!(0, import_node_fs7.existsSync)(path2)) return path2;
|
|
10410
11626
|
for (let i = 1; i < 100; i += 1) {
|
|
10411
11627
|
const candidate = `${path2}-${i}`;
|
|
10412
|
-
if (!(0,
|
|
11628
|
+
if (!(0, import_node_fs7.existsSync)(candidate)) return candidate;
|
|
10413
11629
|
}
|
|
10414
11630
|
return `${path2}-${Date.now()}`;
|
|
10415
11631
|
}
|
|
@@ -10417,27 +11633,27 @@ function quarantinePluginCacheDirs(plan2) {
|
|
|
10417
11633
|
let moved = 0;
|
|
10418
11634
|
for (const move of plan2) {
|
|
10419
11635
|
try {
|
|
10420
|
-
if (!(0,
|
|
11636
|
+
if (!(0, import_node_fs7.existsSync)(move.from)) continue;
|
|
10421
11637
|
const target = uniqueQuarantineTarget(move.to);
|
|
10422
|
-
(0,
|
|
10423
|
-
(0,
|
|
11638
|
+
(0, import_node_fs7.mkdirSync)((0, import_node_path8.dirname)(target), { recursive: true });
|
|
11639
|
+
(0, import_node_fs7.renameSync)(move.from, target);
|
|
10424
11640
|
moved += 1;
|
|
10425
11641
|
} catch {
|
|
10426
11642
|
}
|
|
10427
11643
|
}
|
|
10428
11644
|
return moved;
|
|
10429
11645
|
}
|
|
10430
|
-
var gitignorePath = () => (0,
|
|
11646
|
+
var gitignorePath = () => (0, import_node_path8.join)(process.cwd(), ".gitignore");
|
|
10431
11647
|
function readGitignore() {
|
|
10432
11648
|
try {
|
|
10433
|
-
return (0,
|
|
11649
|
+
return (0, import_node_fs7.readFileSync)(gitignorePath(), "utf8");
|
|
10434
11650
|
} catch {
|
|
10435
11651
|
return null;
|
|
10436
11652
|
}
|
|
10437
11653
|
}
|
|
10438
11654
|
function writeGitignore(content) {
|
|
10439
11655
|
try {
|
|
10440
|
-
(0,
|
|
11656
|
+
(0, import_node_fs7.writeFileSync)(gitignorePath(), content, "utf8");
|
|
10441
11657
|
return true;
|
|
10442
11658
|
} catch {
|
|
10443
11659
|
return false;
|
|
@@ -10449,6 +11665,9 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10449
11665
|
else io.log(MMI_AGENTIC_ONBOARDING_GUIDE.url);
|
|
10450
11666
|
return;
|
|
10451
11667
|
}
|
|
11668
|
+
const repairLocal = !opts.json || Boolean(opts.apply);
|
|
11669
|
+
const repairFull = !opts.json && !opts.banner || Boolean(opts.apply);
|
|
11670
|
+
const repoWritesAllowed = !opts.noRepoWrites;
|
|
10452
11671
|
const checks = [];
|
|
10453
11672
|
const REWRITE_KEY = "url.https://github.com/.insteadOf";
|
|
10454
11673
|
const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
|
|
@@ -10473,23 +11692,30 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10473
11692
|
let onPath = pathProbe;
|
|
10474
11693
|
if (!onPath) {
|
|
10475
11694
|
const root = process.env.CLAUDE_PLUGIN_ROOT;
|
|
10476
|
-
if (root && (0,
|
|
11695
|
+
if (root && (0, import_node_fs7.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
|
|
10477
11696
|
}
|
|
10478
11697
|
checks.push({ ok: onPath, label: "mmi-cli on PATH", fix: "auto-provisioned at session start \u2014 reopen the session, or install the MMI plugin" });
|
|
10479
11698
|
const surface = detectSurface(process.env);
|
|
10480
11699
|
const reloadHint = reloadAction(surface);
|
|
10481
11700
|
let versionReport = buildVersionLagReport({
|
|
10482
|
-
currentVersion:
|
|
11701
|
+
currentVersion: resolveClientVersion(),
|
|
10483
11702
|
repoVersion: readRepoVersion(),
|
|
10484
11703
|
releasedVersion
|
|
10485
11704
|
});
|
|
10486
|
-
if (
|
|
11705
|
+
if (repairFull) versionReport = await applyVersionAutoUpdate(versionReport, (m) => io.err(m));
|
|
10487
11706
|
if (!versionReport.ok) versionReport = { ...versionReport, fix: pluginRecoveryFix(surface) };
|
|
10488
11707
|
checks.push(versionReport);
|
|
10489
11708
|
checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "Hub API URL configured", fix: "set MMI_HUB_URL or use a current MMI CLI/plugin build" });
|
|
11709
|
+
checks.push(
|
|
11710
|
+
buildHubCompatCheck({
|
|
11711
|
+
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
11712
|
+
versionInfo: await fetchHubVersionInfo(cfg.sagaApiUrl),
|
|
11713
|
+
installedVersion: resolveClientVersion()
|
|
11714
|
+
})
|
|
11715
|
+
);
|
|
10490
11716
|
checks.push(buildAwsCrossAccountCheck({ callerArn }));
|
|
10491
11717
|
let cloneOk = cloneProbe;
|
|
10492
|
-
if (!cloneOk &&
|
|
11718
|
+
if (!cloneOk && repairFull) {
|
|
10493
11719
|
try {
|
|
10494
11720
|
await execFileP4("git", ["config", "--global", "--add", REWRITE_KEY, "git@github.com:"]);
|
|
10495
11721
|
cloneOk = true;
|
|
@@ -10507,7 +11733,7 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10507
11733
|
mirrorFrom: existingMirrorRecord(installed),
|
|
10508
11734
|
surface
|
|
10509
11735
|
});
|
|
10510
|
-
if (!pluginCheck.ok && pluginCheck.recordToInsert &&
|
|
11736
|
+
if (!pluginCheck.ok && pluginCheck.recordToInsert && repairLocal) {
|
|
10511
11737
|
if (writeProjectInstallRecord(pluginCheck.recordToInsert)) {
|
|
10512
11738
|
pluginCheck = { ...pluginCheck, ok: true };
|
|
10513
11739
|
io.err(` \u21BB repaired: registered mmi@mmi project install record \u2014 ${reloadHint} to load MMI commands`);
|
|
@@ -10515,20 +11741,25 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10515
11741
|
}
|
|
10516
11742
|
checks.push(pluginCheck);
|
|
10517
11743
|
let gitignoreCheck = buildGitignoreManagedBlockCheck({ isOrgRepo: Boolean(cfg.sagaApiUrl), content: readGitignore() });
|
|
10518
|
-
|
|
11744
|
+
const gitignoreDecision = decideGitignoreRepair(gitignoreCheck, { repoWritesAllowed, repairFull });
|
|
11745
|
+
gitignoreCheck = gitignoreDecision.check;
|
|
11746
|
+
if (gitignoreDecision.action === "suppress") {
|
|
11747
|
+
io.err(" \u23F8 skipped (--no-repo-writes): org-managed .gitignore block repair would dirty the working tree");
|
|
11748
|
+
io.err(` apply it after the release train: ${gitignoreCheck.fix}`);
|
|
11749
|
+
} else if (gitignoreDecision.action === "write") {
|
|
10519
11750
|
if (writeGitignore(gitignoreCheck.contentToWrite)) {
|
|
10520
|
-
gitignoreCheck = { ...gitignoreCheck, ok: true };
|
|
10521
11751
|
const drift = gitignoreCheck.seeded ? "inserted the org-managed block" : [
|
|
10522
11752
|
gitignoreCheck.added?.length ? `added ${gitignoreCheck.added.join(", ")}` : "",
|
|
10523
11753
|
gitignoreCheck.removed?.length ? `removed ${gitignoreCheck.removed.join(", ")}` : ""
|
|
10524
11754
|
].filter(Boolean).join("; ") || "normalized the block";
|
|
11755
|
+
gitignoreCheck = { ...gitignoreCheck, ok: true };
|
|
10525
11756
|
io.err(` \u21BB repaired: org-managed .gitignore block \u2014 ${drift}`);
|
|
10526
11757
|
io.err(" this is an org-managed update (not unrelated churn) \u2014 stage & commit .gitignore so it stops recurring");
|
|
10527
11758
|
}
|
|
10528
11759
|
}
|
|
10529
11760
|
checks.push(gitignoreCheck);
|
|
10530
11761
|
let driftCheck = buildPluginConfigDriftCheck({ isOrgRepo: Boolean(cfg.sagaApiUrl), installed, surface });
|
|
10531
|
-
if (!driftCheck.ok && driftCheck.recordsToWrite &&
|
|
11762
|
+
if (!driftCheck.ok && driftCheck.recordsToWrite && repairLocal) {
|
|
10532
11763
|
if (backupAndWriteInstalledPlugins(driftCheck.recordsToWrite, driftCheck.pluginId)) {
|
|
10533
11764
|
driftCheck = { ...driftCheck, ok: true };
|
|
10534
11765
|
io.err(` \u21BB repaired: collapsed mmi@mmi to one user-scope entry (backup at installed_plugins.json.bak) \u2014 ${reloadHint} to load MMI commands`);
|
|
@@ -10537,15 +11768,16 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10537
11768
|
checks.push(driftCheck);
|
|
10538
11769
|
let installedVersionCheck = buildInstalledPluginVersionCheck({
|
|
10539
11770
|
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
10540
|
-
|
|
11771
|
+
sources: installedPluginSources(),
|
|
10541
11772
|
releasedVersion,
|
|
10542
11773
|
surface
|
|
10543
11774
|
});
|
|
10544
|
-
if (!installedVersionCheck.ok &&
|
|
10545
|
-
|
|
11775
|
+
if (!installedVersionCheck.ok && repairFull) {
|
|
11776
|
+
const claudeStale = installedVersionCheck.staleSurfaces?.some((s) => s.surface === "claude") ?? false;
|
|
11777
|
+
if (claudeStale && await applyClaudePluginHeal(surface, (m) => io.err(m))) {
|
|
10546
11778
|
const healed = buildInstalledPluginVersionCheck({
|
|
10547
11779
|
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
10548
|
-
|
|
11780
|
+
sources: installedPluginSources(),
|
|
10549
11781
|
releasedVersion,
|
|
10550
11782
|
surface
|
|
10551
11783
|
});
|
|
@@ -10559,43 +11791,72 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
10559
11791
|
let cacheCleanupCheck = buildMmiPluginCacheCleanupCheck({
|
|
10560
11792
|
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
10561
11793
|
roots: mmiPluginCacheRootSnapshots(),
|
|
10562
|
-
activeVersion:
|
|
11794
|
+
activeVersion: resolveClientVersion(),
|
|
10563
11795
|
releasedVersion,
|
|
10564
11796
|
installedVersions: installedPluginVersions(installed)
|
|
10565
11797
|
});
|
|
10566
|
-
if (!cacheCleanupCheck.ok && cacheCleanupCheck.quarantinePlan &&
|
|
11798
|
+
if (!cacheCleanupCheck.ok && cacheCleanupCheck.quarantinePlan && repairLocal) {
|
|
10567
11799
|
const moved = quarantinePluginCacheDirs(cacheCleanupCheck.quarantinePlan);
|
|
10568
11800
|
if (moved > 0) {
|
|
10569
11801
|
const surfaces = [...new Set(cacheCleanupCheck.leftovers?.map((entry) => entry.surface) ?? [])].join("/");
|
|
10570
11802
|
const names = cacheCleanupCheck.leftovers?.map((entry) => entry.name).join(", ");
|
|
10571
11803
|
io.err(` \u21BB quarantined ${moved} stale MMI plugin cache dir(s) for ${surfaces || "agent surfaces"}: ${names} \u2014 ${reloadHint} to load MMI commands`);
|
|
10572
11804
|
}
|
|
10573
|
-
cacheCleanupCheck =
|
|
10574
|
-
|
|
10575
|
-
|
|
10576
|
-
|
|
10577
|
-
|
|
10578
|
-
|
|
11805
|
+
cacheCleanupCheck = {
|
|
11806
|
+
...buildMmiPluginCacheCleanupCheck({
|
|
11807
|
+
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
11808
|
+
roots: mmiPluginCacheRootSnapshots(),
|
|
11809
|
+
activeVersion: resolveClientVersion(),
|
|
11810
|
+
releasedVersion
|
|
11811
|
+
}),
|
|
11812
|
+
...moved > 0 ? { cleanedCount: moved } : {}
|
|
11813
|
+
};
|
|
10579
11814
|
}
|
|
10580
11815
|
checks.push(cacheCleanupCheck);
|
|
11816
|
+
const cursorCacheRoot = cursorPluginCacheRoot();
|
|
11817
|
+
checks.push(
|
|
11818
|
+
buildCursorPluginInstallCheck({
|
|
11819
|
+
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
11820
|
+
surface,
|
|
11821
|
+
cacheRoot: cursorCacheRoot,
|
|
11822
|
+
cacheRootExists: (0, import_node_fs7.existsSync)(cursorCacheRoot),
|
|
11823
|
+
pins: cursorPluginCachePinSnapshots() ?? [],
|
|
11824
|
+
hubCheckout: hubCheckoutForCursorSeed()
|
|
11825
|
+
})
|
|
11826
|
+
);
|
|
10581
11827
|
const gaps = checks.filter((c) => !c.ok);
|
|
10582
|
-
const resources = doctorResourcesForGaps(gaps);
|
|
10583
|
-
if (opts.json) {
|
|
10584
|
-
io.log(JSON.stringify({ ok: gaps.length === 0, checks, ...resources.length ? { resources } : {} }, null, 2));
|
|
10585
|
-
return;
|
|
10586
|
-
}
|
|
10587
11828
|
if (opts.banner) {
|
|
10588
11829
|
if (gaps.length) io.log(`\u26A0 MMI setup needed \u2014 ${gaps.map((g) => g.fix).join(" \xB7 ")} \xB7 guide: ${MMI_AGENTIC_ONBOARDING_GUIDE.url}`);
|
|
10589
11830
|
return;
|
|
10590
11831
|
}
|
|
11832
|
+
const cacheRoots = mmiPluginCacheRootSnapshots();
|
|
11833
|
+
const cacheVersionsFor = (s) => cacheRoots.filter((r) => r.surface === s).flatMap((r) => r.entries.filter((e) => e.isDirectory).map((e) => e.name));
|
|
11834
|
+
const sourceVersions = (s) => installedPluginVersions(installedPluginSources().find((src) => src.surface === s)?.installed ?? null);
|
|
11835
|
+
const updateReport = buildPluginUpdateReport({
|
|
11836
|
+
cliVersion: resolveClientVersion(),
|
|
11837
|
+
claudePluginVersions: sourceVersions("claude"),
|
|
11838
|
+
codexPluginVersions: sourceVersions("codex"),
|
|
11839
|
+
codexCacheVersions: cacheVersionsFor("codex"),
|
|
11840
|
+
releasedVersion
|
|
11841
|
+
});
|
|
11842
|
+
const resources = doctorResourcesForGaps(gaps);
|
|
11843
|
+
if (opts.json) {
|
|
11844
|
+
io.log(JSON.stringify(buildDoctorJsonPayload({ checks, updateReport, resources }), null, 2));
|
|
11845
|
+
return;
|
|
11846
|
+
}
|
|
10591
11847
|
for (const c of checks) io.log(c.ok ? `\u2713 ${c.label}` : `\u2717 ${c.label}
|
|
10592
11848
|
\u2192 ${c.fix}`);
|
|
10593
11849
|
for (const r of resources) io.log(`Resource: ${r.label} \u2014 ${r.url}`);
|
|
11850
|
+
io.log("");
|
|
11851
|
+
for (const line of renderPluginUpdateReport(updateReport)) io.log(line);
|
|
10594
11852
|
io.log(gaps.length ? `
|
|
10595
11853
|
${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
|
|
10596
11854
|
}
|
|
10597
|
-
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
|
|
10598
|
-
|
|
11855
|
+
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), print fixes, and report per-surface versions + update recipes").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)").option("--no-repo-writes", "env/plugin repairs only \u2014 never mutate the repo working tree; report pending managed .gitignore repairs with the follow-up command (for release-train prep)").action((opts) => (
|
|
11856
|
+
// Commander maps `--no-repo-writes` to `repoWrites: false`; translate to the explicit `noRepoWrites` flag.
|
|
11857
|
+
runDoctor({ ...opts, noRepoWrites: opts.repoWrites === false })
|
|
11858
|
+
));
|
|
11859
|
+
program2.command("session-start").description("run the SessionStart verbs (rules sync, saga session+show, saga health, whoami, doctor) in one process; docs sync runs detached").action(async () => {
|
|
10599
11860
|
try {
|
|
10600
11861
|
const hook = parseHookInput(await readStdin());
|
|
10601
11862
|
if (hook.session_id) persistSession(hook.session_id);
|
|
@@ -10607,6 +11868,16 @@ program2.command("session-start").description("run the SessionStart verbs (rules
|
|
|
10607
11868
|
rulesSync: (io) => runRulesSync({ quiet: true }, io),
|
|
10608
11869
|
sagaShow: (io) => runSagaShow({ quiet: true }, io),
|
|
10609
11870
|
sagaHealth: (io) => runSagaHealth({ banner: true, quiet: true }, io),
|
|
11871
|
+
// whoami (#879): surface the resolved human so agents act --for them without asking. Silent
|
|
11872
|
+
// when unknown — a missing gh login must not noise or fail the banner.
|
|
11873
|
+
whoami: async (io) => {
|
|
11874
|
+
const report = await resolveWhoami({
|
|
11875
|
+
hubSession: async () => hubAuthSession({ baseUrl: (await loadConfig()).sagaApiUrl ?? defaultHubUrl(), githubToken }),
|
|
11876
|
+
ghLogin: githubLogin
|
|
11877
|
+
});
|
|
11878
|
+
const line = whoamiLine(report);
|
|
11879
|
+
if (line) io.log(line);
|
|
11880
|
+
},
|
|
10610
11881
|
doctor: (io) => runDoctor({ banner: true }, io)
|
|
10611
11882
|
});
|
|
10612
11883
|
await runSessionStart(parallel, sequential, consoleIo);
|
|
@@ -10614,7 +11885,7 @@ program2.command("session-start").description("run the SessionStart verbs (rules
|
|
|
10614
11885
|
});
|
|
10615
11886
|
function fail(msg) {
|
|
10616
11887
|
console.error(`mmi-cli ${msg}`);
|
|
10617
|
-
|
|
11888
|
+
hardExit(1);
|
|
10618
11889
|
}
|
|
10619
11890
|
process.on("unhandledRejection", (reason) => fail(reason instanceof Error ? reason.message : String(reason)));
|
|
10620
11891
|
process.on("uncaughtException", (err) => fail(err instanceof Error ? err.message : String(err)));
|