@mutmutco/cli 2.5.1 → 2.7.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/README.md +1 -1
- package/dist/index.cjs +612 -59
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ mmi-cli doctor --json
|
|
|
36
36
|
KB curation without echoing the plan body.
|
|
37
37
|
`mmi-cli plan` remains a compatibility alias.
|
|
38
38
|
- `mmi-cli secrets where|list|get|set|edit|rm|use|grant|revoke` manages two-tier project/org secrets without logging values; `where` prints the vault layout + well-known keys, and values move over TLS in the request body — never an argument.
|
|
39
|
-
- `mmi-cli project list|get|resolve|set` reads
|
|
39
|
+
- `mmi-cli project list|get|resolve|doctor|heal|readiness|set` reads and repairs Hub-owned v2 readiness state. `doctor --v2 --json` diagnoses central deploy/secrets readiness, `heal --v2 --apply` fixes only registry-owned defaults, and `readiness --update-issue` updates the repo's v2 readiness issue; `set` is master-only.
|
|
40
40
|
- `mmi-cli registry org` reads org-level constants from the registry (`ORG#config`).
|
|
41
41
|
- `mmi-cli oauth plan|verify` prints a repo's canonical Google OAuth URI set (read from the registry) and verifies the client is port-agnostic.
|
|
42
42
|
- `mmi-cli issue create` creates typed, prioritized GitHub issues (priority sets the board field, not a label) and queues related-issue discovery.
|
package/dist/index.cjs
CHANGED
|
@@ -3309,6 +3309,9 @@ function buildHealth(i) {
|
|
|
3309
3309
|
if (!i.sagaApiUrl) problems.push("sagaApiUrl not configured in .mmi/config.json");
|
|
3310
3310
|
if (!i.identity) problems.push("no GitHub identity (gh auth token / GH_TOKEN)");
|
|
3311
3311
|
if (!i.reachable) problems.push("saga backend unreachable");
|
|
3312
|
+
if (i.reachable && i.livenessStatus === 403 && i.livenessMessage === "Forbidden") {
|
|
3313
|
+
problems.push("saga API route-level 403 from GitHubAuthorizer cache/policy");
|
|
3314
|
+
}
|
|
3312
3315
|
if (i.reachable && i.authorized === false) problems.push("saga backend rejected authenticated state access");
|
|
3313
3316
|
if (!i.key.sessionId || i.key.sessionId === "-") problems.push("unsafe session id");
|
|
3314
3317
|
const safeToWrite = problems.length === 0;
|
|
@@ -3317,6 +3320,8 @@ function buildHealth(i) {
|
|
|
3317
3320
|
safeToWrite,
|
|
3318
3321
|
identity: i.identity,
|
|
3319
3322
|
reachable: i.reachable,
|
|
3323
|
+
livenessStatus: i.livenessStatus,
|
|
3324
|
+
livenessMessage: i.livenessMessage,
|
|
3320
3325
|
authorized: i.authorized,
|
|
3321
3326
|
sagaApiUrl: i.sagaApiUrl,
|
|
3322
3327
|
key: i.key,
|
|
@@ -3336,6 +3341,7 @@ function resumeCue() {
|
|
|
3336
3341
|
|
|
3337
3342
|
// src/saga-note.ts
|
|
3338
3343
|
var AGENT_SURFACE_TOKENS = ["claude", "codex", "cursor", "gemini"];
|
|
3344
|
+
var ROUTE_LEVEL_403 = "saga API route-level 403 from GitHubAuthorizer cache/policy";
|
|
3339
3345
|
function agentSurface() {
|
|
3340
3346
|
const surface = process.env.MMI_AGENT_SURFACE || "claude";
|
|
3341
3347
|
if (AGENT_SURFACE_TOKENS.includes(surface)) return surface;
|
|
@@ -3367,6 +3373,10 @@ function buildNoteCapture(summary, o, id, evidence) {
|
|
|
3367
3373
|
anchorForce: o.anchorForce || void 0
|
|
3368
3374
|
};
|
|
3369
3375
|
}
|
|
3376
|
+
function formatCaptureFailure(status, message) {
|
|
3377
|
+
if (status === 403 && message === "Forbidden") return `saga: ${ROUTE_LEVEL_403} (HTTP 403)`;
|
|
3378
|
+
return `saga: HTTP ${status}`;
|
|
3379
|
+
}
|
|
3370
3380
|
|
|
3371
3381
|
// src/version-lag.ts
|
|
3372
3382
|
var VERSION_LABEL = "installed plugin/adapter cache freshness";
|
|
@@ -3598,6 +3608,36 @@ query($owner: String!, $number: Int!, $after: String) {
|
|
|
3598
3608
|
}
|
|
3599
3609
|
}
|
|
3600
3610
|
}`;
|
|
3611
|
+
var ISSUE_PROJECT_ITEM_QUERY = `
|
|
3612
|
+
query($repoOwner: String!, $repoName: String!, $number: Int!) {
|
|
3613
|
+
repository(owner: $repoOwner, name: $repoName) {
|
|
3614
|
+
issue(number: $number) {
|
|
3615
|
+
id
|
|
3616
|
+
number
|
|
3617
|
+
title
|
|
3618
|
+
url
|
|
3619
|
+
state
|
|
3620
|
+
repository { nameWithOwner }
|
|
3621
|
+
labels(first: 10) { nodes { name } }
|
|
3622
|
+
assignees(first: 10) { nodes { login } }
|
|
3623
|
+
projectItems(first: 20) {
|
|
3624
|
+
nodes {
|
|
3625
|
+
id
|
|
3626
|
+
project { id title }
|
|
3627
|
+
fieldValues(first: 8) {
|
|
3628
|
+
nodes {
|
|
3629
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
3630
|
+
name
|
|
3631
|
+
optionId
|
|
3632
|
+
field { ... on ProjectV2SingleSelectField { name } }
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
}`;
|
|
3601
3641
|
function resolveBoardConfig(cfg) {
|
|
3602
3642
|
const missing = [];
|
|
3603
3643
|
if (!cfg.projectOwner) missing.push("projectOwner");
|
|
@@ -3871,6 +3911,14 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
3871
3911
|
const cfg = resolveBoardConfig(options.config);
|
|
3872
3912
|
const gh = deps.gh ?? defaultGh;
|
|
3873
3913
|
const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
|
|
3914
|
+
const selector = parseIssueSelector(options.selector, collected.repo);
|
|
3915
|
+
try {
|
|
3916
|
+
findBoardItem(collected.items, selector);
|
|
3917
|
+
} catch (e) {
|
|
3918
|
+
const fallback = await fetchIssueProjectItem(gh, cfg, selector);
|
|
3919
|
+
if (!fallback) throw e;
|
|
3920
|
+
collected.items.push(fallback);
|
|
3921
|
+
}
|
|
3874
3922
|
const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
|
|
3875
3923
|
collected.warnings.push(...writable.warnings);
|
|
3876
3924
|
collected.partial = collected.partial || writable.partial;
|
|
@@ -3882,7 +3930,6 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
3882
3930
|
warnings: collected.warnings,
|
|
3883
3931
|
partial: collected.partial
|
|
3884
3932
|
};
|
|
3885
|
-
const selector = parseIssueSelector(options.selector, collected.repo);
|
|
3886
3933
|
const flatItem = findBoardItem(collected.items, selector);
|
|
3887
3934
|
if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !writable.repos.has(flatItem.repository.toLowerCase())) {
|
|
3888
3935
|
throw new Error(`${flatItem.ref} is not claimable: viewer does not have write access to ${flatItem.repository}`);
|
|
@@ -4039,6 +4086,42 @@ async function fetchProjectPage(gh, cfg, after) {
|
|
|
4039
4086
|
if (!parsed.data) throw new Error("gh GraphQL response did not include data");
|
|
4040
4087
|
return parsed.data;
|
|
4041
4088
|
}
|
|
4089
|
+
async function fetchIssueProjectItem(gh, cfg, selector) {
|
|
4090
|
+
const [repoOwner, repoName] = selector.repo.split("/");
|
|
4091
|
+
if (!repoOwner || !repoName) return void 0;
|
|
4092
|
+
const { stdout } = await gh([
|
|
4093
|
+
"api",
|
|
4094
|
+
"graphql",
|
|
4095
|
+
"-f",
|
|
4096
|
+
`query=${ISSUE_PROJECT_ITEM_QUERY}`,
|
|
4097
|
+
"-f",
|
|
4098
|
+
`repoOwner=${repoOwner}`,
|
|
4099
|
+
"-f",
|
|
4100
|
+
`repoName=${repoName}`,
|
|
4101
|
+
"-F",
|
|
4102
|
+
`number=${selector.number}`
|
|
4103
|
+
]);
|
|
4104
|
+
const parsed = JSON.parse(stdout);
|
|
4105
|
+
const issue2 = parsed.data?.repository?.issue;
|
|
4106
|
+
if (!issue2) return void 0;
|
|
4107
|
+
const projectItem = (issue2.projectItems?.nodes ?? []).find((item) => item.project?.id === cfg.projectId);
|
|
4108
|
+
if (!projectItem) return void 0;
|
|
4109
|
+
return nodeToItem({
|
|
4110
|
+
id: projectItem.id,
|
|
4111
|
+
fieldValues: projectItem.fieldValues,
|
|
4112
|
+
content: {
|
|
4113
|
+
__typename: "Issue",
|
|
4114
|
+
id: issue2.id,
|
|
4115
|
+
number: issue2.number,
|
|
4116
|
+
title: issue2.title,
|
|
4117
|
+
url: issue2.url,
|
|
4118
|
+
state: issue2.state,
|
|
4119
|
+
repository: issue2.repository,
|
|
4120
|
+
labels: issue2.labels,
|
|
4121
|
+
assignees: issue2.assignees
|
|
4122
|
+
}
|
|
4123
|
+
});
|
|
4124
|
+
}
|
|
4042
4125
|
function nodesToItems(nodes, warnings) {
|
|
4043
4126
|
const items = [];
|
|
4044
4127
|
for (const node of nodes) {
|
|
@@ -4168,6 +4251,13 @@ function ghError(e) {
|
|
|
4168
4251
|
}
|
|
4169
4252
|
|
|
4170
4253
|
// src/gc.ts
|
|
4254
|
+
function buildRemoteBranchCleanupReport(branch, input) {
|
|
4255
|
+
if (!input.attempted) return { name: branch, status: "not-attempted", reason: input.reason };
|
|
4256
|
+
if (input.existsAfter === true) return { name: branch, status: "failed", reason: "still-present-after-delete" };
|
|
4257
|
+
if (input.existedBefore === false && input.existsAfter === false) return { name: branch, status: "already-gone" };
|
|
4258
|
+
if (input.existsAfter === false) return { name: branch, status: "deleted" };
|
|
4259
|
+
return { name: branch, status: "not-attempted", reason: input.reason ?? "remote-check-unavailable" };
|
|
4260
|
+
}
|
|
4171
4261
|
var DEFAULT_PROTECTED = /* @__PURE__ */ new Set(["development", "main", "master", "rc"]);
|
|
4172
4262
|
function groupedPrs(prs) {
|
|
4173
4263
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -4275,6 +4365,63 @@ function branchMissingFromList(branch, stdout) {
|
|
|
4275
4365
|
const names = stdout.split(/\r?\n/).map((line) => line.replace(/^\*\s*/, "").trim()).filter(Boolean);
|
|
4276
4366
|
return !names.includes(branch);
|
|
4277
4367
|
}
|
|
4368
|
+
function shellQuote(value) {
|
|
4369
|
+
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
|
|
4370
|
+
}
|
|
4371
|
+
function safeWorktreeRemoveCommand(safeCwd, targetPath) {
|
|
4372
|
+
const prefix = safeCwd ? `git -C ${shellQuote(safeCwd)}` : "git";
|
|
4373
|
+
return `${prefix} worktree remove --force ${shellQuote(targetPath)}`;
|
|
4374
|
+
}
|
|
4375
|
+
function errorMessage(error) {
|
|
4376
|
+
return error instanceof Error ? error.message : String(error);
|
|
4377
|
+
}
|
|
4378
|
+
async function cleanupPrMergeLocalBranch(branch, options) {
|
|
4379
|
+
const report = {
|
|
4380
|
+
branch,
|
|
4381
|
+
localBranch: { name: branch, status: "not-attempted", reason: branch ? "pending" : "missing-branch" }
|
|
4382
|
+
};
|
|
4383
|
+
if (!branch) return report;
|
|
4384
|
+
let afterWorktrees = [];
|
|
4385
|
+
try {
|
|
4386
|
+
afterWorktrees = parseWorktreePorcelain(await options.execGit(["worktree", "list", "--porcelain"]));
|
|
4387
|
+
} catch (e) {
|
|
4388
|
+
report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-list-failed", error: errorMessage(e) };
|
|
4389
|
+
return report;
|
|
4390
|
+
}
|
|
4391
|
+
const beforeWorktrees = options.beforeWorktrees ?? [];
|
|
4392
|
+
const wtPath = selectPrMergeCleanupWorktree(branch, beforeWorktrees, afterWorktrees, options.startingPath);
|
|
4393
|
+
const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...beforeWorktrees], wtPath);
|
|
4394
|
+
const git = (args) => safeCwd ? options.execGit(["-C", safeCwd, ...args]) : options.execGit(args);
|
|
4395
|
+
if (wtPath) {
|
|
4396
|
+
try {
|
|
4397
|
+
await git(["worktree", "remove", "--force", wtPath]);
|
|
4398
|
+
report.worktree = { path: wtPath, status: "removed" };
|
|
4399
|
+
} catch (e) {
|
|
4400
|
+
report.worktree = {
|
|
4401
|
+
path: wtPath,
|
|
4402
|
+
status: "failed",
|
|
4403
|
+
error: errorMessage(e),
|
|
4404
|
+
safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath)
|
|
4405
|
+
};
|
|
4406
|
+
report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-removal-failed" };
|
|
4407
|
+
return report;
|
|
4408
|
+
}
|
|
4409
|
+
}
|
|
4410
|
+
const current = (await git(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "") || "").trim();
|
|
4411
|
+
if (branch === current) {
|
|
4412
|
+
report.localBranch = { name: branch, status: "not-attempted", reason: "current-branch" };
|
|
4413
|
+
return report;
|
|
4414
|
+
}
|
|
4415
|
+
try {
|
|
4416
|
+
await git(["branch", "-D", branch]);
|
|
4417
|
+
report.localBranch = { name: branch, status: "deleted" };
|
|
4418
|
+
} catch (e) {
|
|
4419
|
+
const remaining = await git(["branch", "--list", branch]).catch(() => "");
|
|
4420
|
+
report.localBranch = branchMissingFromList(branch, remaining) ? { name: branch, status: "already-gone" } : { name: branch, status: "failed", error: errorMessage(e) };
|
|
4421
|
+
}
|
|
4422
|
+
if (wtPath) await git(["worktree", "prune"]).catch(() => "");
|
|
4423
|
+
return report;
|
|
4424
|
+
}
|
|
4278
4425
|
function formatGcPlan(plan2, apply) {
|
|
4279
4426
|
const lines = [`mmi-cli gc: ${apply ? "apply" : "dry-run"}`];
|
|
4280
4427
|
if (!plan2.branches.length && !plan2.trackingRefs.length) lines.push("nothing to clean");
|
|
@@ -4325,8 +4472,9 @@ function trainPlan(command) {
|
|
|
4325
4472
|
{ label: "verify current branch is development", gated: true },
|
|
4326
4473
|
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
4327
4474
|
{ label: "preflight required rc secret names", command: "mmi-cli secrets preflight --stage rc --repo <owner/repo>", gated: true },
|
|
4475
|
+
{ label: "for Hub distribution changes, verify the release bump is already landed before touching rc", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
|
|
4328
4476
|
{ label: "merge development to rc", gated: true },
|
|
4329
|
-
{ label: "
|
|
4477
|
+
{ label: "trigger the deploy path for this repo model", command: "tenant-container: gh workflow run tenant-deploy.yml ...; hub-serverless: no manual dispatch, deploy.yml auto-fires on rc push", gated: true }
|
|
4330
4478
|
];
|
|
4331
4479
|
}
|
|
4332
4480
|
if (command === "release") {
|
|
@@ -4336,8 +4484,9 @@ function trainPlan(command) {
|
|
|
4336
4484
|
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
4337
4485
|
{ label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
|
|
4338
4486
|
{ label: "merge rc to main", gated: true },
|
|
4487
|
+
{ 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 },
|
|
4339
4488
|
{ label: "tag release and publish GitHub Release", gated: true },
|
|
4340
|
-
{ label: "
|
|
4489
|
+
{ label: "trigger the deploy path for this repo model", command: "tenant-container: gh workflow run tenant-deploy.yml ...; hub-serverless: no manual dispatch, deploy.yml + publish.yml auto-fire on the release", gated: true },
|
|
4341
4490
|
{ label: "roll development forward", gated: true }
|
|
4342
4491
|
];
|
|
4343
4492
|
}
|
|
@@ -4505,8 +4654,13 @@ var PLUGIN_DRIFT_LABEL = "plugin config drift (mmi@mmi duplicate rows / stale gi
|
|
|
4505
4654
|
function recordFreshness(r) {
|
|
4506
4655
|
return r.lastUpdated ?? r.installedAt ?? "";
|
|
4507
4656
|
}
|
|
4508
|
-
function
|
|
4509
|
-
return records.reduce((best, r) =>
|
|
4657
|
+
function bestRecord(records) {
|
|
4658
|
+
return records.reduce((best, r) => {
|
|
4659
|
+
const byVersion = compareVersions(r.version ?? "0", best.version ?? "0");
|
|
4660
|
+
if (byVersion > 0) return r;
|
|
4661
|
+
if (byVersion < 0) return best;
|
|
4662
|
+
return recordFreshness(r) > recordFreshness(best) ? r : best;
|
|
4663
|
+
});
|
|
4510
4664
|
}
|
|
4511
4665
|
function pluginConfigDriftFix(pluginId) {
|
|
4512
4666
|
return `\`${pluginId}\` registered as N duplicate project rows / a stale gitCommitSha in ~/.claude/plugins/installed_plugins.json \u2014 run \`mmi-cli doctor\` to collapse them to one \`scope: user\` entry at the highest version (a .bak backup is written first), or in \`/plugin\` uninstall the extra rows and reinstall once at user scope`;
|
|
@@ -4523,19 +4677,21 @@ function buildPluginConfigDriftCheck(input) {
|
|
|
4523
4677
|
const records = input.installed?.plugins?.[pluginId];
|
|
4524
4678
|
if (!Array.isArray(records) || records.length === 0) return base;
|
|
4525
4679
|
const projectRows = records.filter((r) => r.scope === "project");
|
|
4526
|
-
const
|
|
4527
|
-
const
|
|
4528
|
-
const staleShaRows =
|
|
4680
|
+
const best = bestRecord(records);
|
|
4681
|
+
const bestSha = best.gitCommitSha;
|
|
4682
|
+
const staleShaRows = bestSha ? records.filter((r) => r.gitCommitSha !== void 0 && r.gitCommitSha !== bestSha).length : 0;
|
|
4683
|
+
const duplicateRows = records.length > 1 ? records.length : 0;
|
|
4529
4684
|
const duplicateProjectRows = projectRows.length > 1 ? projectRows.length : 0;
|
|
4530
|
-
if (
|
|
4531
|
-
return { ...base, duplicateProjectRows: 0, staleShaRows: 0 };
|
|
4685
|
+
if (duplicateRows === 0 && staleShaRows === 0) {
|
|
4686
|
+
return { ...base, duplicateRows: 0, duplicateProjectRows: 0, staleShaRows: 0 };
|
|
4532
4687
|
}
|
|
4533
|
-
const { projectPath: _drop, ...rest } =
|
|
4688
|
+
const { projectPath: _drop, ...rest } = best;
|
|
4534
4689
|
const collapsed = { ...rest, scope: "user" };
|
|
4535
4690
|
return {
|
|
4536
4691
|
...base,
|
|
4537
4692
|
ok: false,
|
|
4538
4693
|
recordsToWrite: [collapsed],
|
|
4694
|
+
duplicateRows,
|
|
4539
4695
|
duplicateProjectRows,
|
|
4540
4696
|
staleShaRows
|
|
4541
4697
|
};
|
|
@@ -4549,6 +4705,58 @@ function buildGitignoreManagedBlockCheck(input) {
|
|
|
4549
4705
|
if (!changed) return base;
|
|
4550
4706
|
return { ...base, ok: false, contentToWrite: content };
|
|
4551
4707
|
}
|
|
4708
|
+
function detectSurface(env) {
|
|
4709
|
+
const has = (k) => Boolean(env[k]?.trim());
|
|
4710
|
+
if (env.MMI_AGENT_SURFACE === "codex" || has("CODEX_HOME") || (env.CLAUDE_PLUGIN_ROOT ?? "").includes(".codex")) {
|
|
4711
|
+
return "codex";
|
|
4712
|
+
}
|
|
4713
|
+
const isClaude = has("CLAUDECODE") || has("CLAUDE_CODE_ENTRYPOINT") || has("CLAUDE_PLUGIN_ROOT") || env.MMI_AGENT_SURFACE === "claude";
|
|
4714
|
+
const isVscode = env.TERM_PROGRAM === "vscode" || has("VSCODE_PID") || has("VSCODE_GIT_ASKPASS_NODE");
|
|
4715
|
+
if (isClaude && isVscode) return "claude-vscode";
|
|
4716
|
+
if (isClaude) return "claude-cli";
|
|
4717
|
+
return "shell";
|
|
4718
|
+
}
|
|
4719
|
+
function pluginRecoveryFix(surface) {
|
|
4720
|
+
const claude = "claude plugin marketplace update mmi && claude plugin update mmi@mmi && claude plugin enable mmi@mmi";
|
|
4721
|
+
switch (surface) {
|
|
4722
|
+
case "claude-vscode":
|
|
4723
|
+
return `${claude} # then reopen the VS Code workspace to reload MMI commands`;
|
|
4724
|
+
case "claude-cli":
|
|
4725
|
+
return `${claude} # then restart Claude Code, or run /reload-plugins`;
|
|
4726
|
+
case "codex":
|
|
4727
|
+
return "codex plugin marketplace upgrade mmi && codex plugin add mmi@mmi # then restart Codex";
|
|
4728
|
+
case "shell":
|
|
4729
|
+
default:
|
|
4730
|
+
return "npm install -g @mutmutco/cli@latest";
|
|
4731
|
+
}
|
|
4732
|
+
}
|
|
4733
|
+
var INSTALLED_PLUGIN_VERSION_LABEL = "installed MMI plugin version (vs latest release)";
|
|
4734
|
+
function isSemverVersion(v) {
|
|
4735
|
+
return typeof v === "string" && /^v?\d+\.\d+\.\d+/.test(v.trim());
|
|
4736
|
+
}
|
|
4737
|
+
function buildInstalledPluginVersionCheck(input) {
|
|
4738
|
+
const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
|
|
4739
|
+
const base = {
|
|
4740
|
+
ok: true,
|
|
4741
|
+
label: INSTALLED_PLUGIN_VERSION_LABEL,
|
|
4742
|
+
fix: pluginRecoveryFix(input.surface),
|
|
4743
|
+
pluginId
|
|
4744
|
+
};
|
|
4745
|
+
if (!input.isOrgRepo) return base;
|
|
4746
|
+
const records = input.installed?.plugins?.[pluginId];
|
|
4747
|
+
if (!Array.isArray(records) || records.length === 0) return base;
|
|
4748
|
+
const installedVersion = bestRecord(records).version;
|
|
4749
|
+
if (!isSemverVersion(installedVersion) || !isSemverVersion(input.releasedVersion)) return base;
|
|
4750
|
+
if (compareVersions(installedVersion, input.releasedVersion) >= 0) {
|
|
4751
|
+
return { ...base, installedVersion, releasedVersion: input.releasedVersion };
|
|
4752
|
+
}
|
|
4753
|
+
return {
|
|
4754
|
+
...base,
|
|
4755
|
+
ok: false,
|
|
4756
|
+
installedVersion,
|
|
4757
|
+
releasedVersion: input.releasedVersion
|
|
4758
|
+
};
|
|
4759
|
+
}
|
|
4552
4760
|
|
|
4553
4761
|
// src/stage-runner.ts
|
|
4554
4762
|
var import_node_child_process3 = require("node:child_process");
|
|
@@ -5558,6 +5766,24 @@ async function fetchProjectBySlug(slug, deps) {
|
|
|
5558
5766
|
return null;
|
|
5559
5767
|
}
|
|
5560
5768
|
}
|
|
5769
|
+
async function fetchDeployStatusBySlug(slug, deps) {
|
|
5770
|
+
if (!deps.baseUrl || !slug) return null;
|
|
5771
|
+
const token = await deps.token();
|
|
5772
|
+
if (!token) return null;
|
|
5773
|
+
const doFetch = deps.fetch ?? fetch;
|
|
5774
|
+
try {
|
|
5775
|
+
const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}/deploy-status`, {
|
|
5776
|
+
method: "GET",
|
|
5777
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
5778
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
5779
|
+
});
|
|
5780
|
+
if (!res.ok) return null;
|
|
5781
|
+
const body = await res.json();
|
|
5782
|
+
return body?.stages ?? null;
|
|
5783
|
+
} catch {
|
|
5784
|
+
return null;
|
|
5785
|
+
}
|
|
5786
|
+
}
|
|
5561
5787
|
async function fetchOrgConfig(deps) {
|
|
5562
5788
|
if (!deps.baseUrl) return null;
|
|
5563
5789
|
const token = await deps.token();
|
|
@@ -5604,6 +5830,164 @@ async function upsertProject(slug, patch, deps) {
|
|
|
5604
5830
|
return postJson(`/projects/${encodeURIComponent(slug)}`, patch, deps);
|
|
5605
5831
|
}
|
|
5606
5832
|
|
|
5833
|
+
// src/project-readiness.ts
|
|
5834
|
+
var STAGES = ["dev", "rc", "main"];
|
|
5835
|
+
var DEFAULT_RUNTIME_SECRET_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
|
|
5836
|
+
function slugOfRepo(repoOrSlug) {
|
|
5837
|
+
return (repoOrSlug.includes("/") ? repoOrSlug.split("/").pop() : repoOrSlug).toLowerCase();
|
|
5838
|
+
}
|
|
5839
|
+
function repoFrom(repoOrSlug, slug) {
|
|
5840
|
+
return repoOrSlug.includes("/") ? repoOrSlug : `mutmutco/${slug}`;
|
|
5841
|
+
}
|
|
5842
|
+
function defaultSubdomain(slug) {
|
|
5843
|
+
return slug.replace(/^[a-z]+-/, "");
|
|
5844
|
+
}
|
|
5845
|
+
function defaultDeployModel(meta, repo) {
|
|
5846
|
+
if (meta?.deployModel) return meta.deployModel;
|
|
5847
|
+
if (meta?.class === "content") return "content";
|
|
5848
|
+
if (repo.toLowerCase().endsWith("/mmi-hub")) return "hub-serverless";
|
|
5849
|
+
return "tenant-container";
|
|
5850
|
+
}
|
|
5851
|
+
function stageRequiredSecrets(stage2, meta) {
|
|
5852
|
+
const contract = meta.requiredRuntimeSecrets;
|
|
5853
|
+
const extra = Array.isArray(contract) ? contract : Array.isArray(contract?.[stage2]) ? contract[stage2] ?? [] : [];
|
|
5854
|
+
return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES, ...extra])];
|
|
5855
|
+
}
|
|
5856
|
+
function stageKey(stage2, key) {
|
|
5857
|
+
return key.includes("/") ? key : `${stage2}/${key}`;
|
|
5858
|
+
}
|
|
5859
|
+
function appGapsFor(meta, model, slug) {
|
|
5860
|
+
if (model === "content") return ["Content repo: keep app-owned work to docs/content changes; release train does not apply."];
|
|
5861
|
+
const gaps = [
|
|
5862
|
+
"Ensure Docker/compose runs from the central release directory and consumes the Hub-generated .env.",
|
|
5863
|
+
"Make app config fail clearly for missing required env in prod/rc instead of relying on hidden defaults.",
|
|
5864
|
+
"Keep app-owned README.md and architecture.md aligned with v2 central deploy/secrets reality."
|
|
5865
|
+
];
|
|
5866
|
+
if (slug === "mmi-katip") {
|
|
5867
|
+
gaps.push("Katip-specific app plan: declare Google Workspace env requirements, remove prod-hidden impersonation defaults, and make non-critical Google Workspace failures degrade instead of crash-looping.");
|
|
5868
|
+
}
|
|
5869
|
+
if (!meta) gaps.unshift("No app-owned repo changes can be planned precisely until Hub registry META exists.");
|
|
5870
|
+
return gaps;
|
|
5871
|
+
}
|
|
5872
|
+
function contractByStage(contract) {
|
|
5873
|
+
if (Array.isArray(contract)) return { dev: contract, rc: contract, main: contract };
|
|
5874
|
+
return contract ?? {};
|
|
5875
|
+
}
|
|
5876
|
+
function ensureStageNames(names, required) {
|
|
5877
|
+
return [.../* @__PURE__ */ new Set([...names ?? [], ...required])];
|
|
5878
|
+
}
|
|
5879
|
+
function buildV2HealPatch(repoOrSlug, meta) {
|
|
5880
|
+
const slug = slugOfRepo(repoOrSlug);
|
|
5881
|
+
const repo = repoFrom(repoOrSlug, slug);
|
|
5882
|
+
const sub = defaultSubdomain(slug);
|
|
5883
|
+
const patch = {};
|
|
5884
|
+
const model = defaultDeployModel(meta, repo);
|
|
5885
|
+
if (!meta?.class) patch.class = "deployable";
|
|
5886
|
+
if (!meta?.deployModel) patch.deployModel = model;
|
|
5887
|
+
if (!meta?.vaultPath) patch.vaultPath = `/mmi-future/${slug}`;
|
|
5888
|
+
if (!meta?.kbPointer) patch.kbPointer = `kb/projects/${slug}.md`;
|
|
5889
|
+
if (!meta?.oauth) patch.oauth = { subdomains: [sub], callbackPath: "/auth/callback" };
|
|
5890
|
+
if (!meta?.edgeDomains && model === "tenant-container") {
|
|
5891
|
+
patch.edgeDomains = {
|
|
5892
|
+
dev: `dev.${sub}.mutatismutandis.co`,
|
|
5893
|
+
rc: `rc.${sub}.mutatismutandis.co`,
|
|
5894
|
+
main: `${sub}.mutatismutandis.co`
|
|
5895
|
+
};
|
|
5896
|
+
}
|
|
5897
|
+
if (slug === "mmi-katip") {
|
|
5898
|
+
const required = ["GOOGLE_IMPERSONATE_USER", "GOOGLE_ADMIN_USER"];
|
|
5899
|
+
const current = contractByStage(meta?.requiredRuntimeSecrets);
|
|
5900
|
+
const next = {
|
|
5901
|
+
dev: ensureStageNames(current.dev, required),
|
|
5902
|
+
rc: ensureStageNames(current.rc, required),
|
|
5903
|
+
main: ensureStageNames(current.main, required)
|
|
5904
|
+
};
|
|
5905
|
+
if (JSON.stringify(current) !== JSON.stringify(next)) {
|
|
5906
|
+
patch.requiredRuntimeSecrets = next;
|
|
5907
|
+
}
|
|
5908
|
+
}
|
|
5909
|
+
return { slug, patch, appOwnedGaps: appGapsFor(meta, model, slug) };
|
|
5910
|
+
}
|
|
5911
|
+
async function buildV2Doctor(repoOrSlug, deps) {
|
|
5912
|
+
const slug = slugOfRepo(repoOrSlug);
|
|
5913
|
+
const repo = repoFrom(repoOrSlug, slug);
|
|
5914
|
+
const meta = await deps.getProject(slug);
|
|
5915
|
+
const model = defaultDeployModel(meta, repo);
|
|
5916
|
+
const autoHeal = buildV2HealPatch(repo, meta);
|
|
5917
|
+
if (!meta) {
|
|
5918
|
+
const emptySecrets = {
|
|
5919
|
+
dev: { required: [], present: [], missing: [] },
|
|
5920
|
+
rc: { required: [], present: [], missing: [] },
|
|
5921
|
+
main: { required: [], present: [], missing: [] }
|
|
5922
|
+
};
|
|
5923
|
+
const emptyCoords = {
|
|
5924
|
+
dev: { ok: false },
|
|
5925
|
+
rc: { ok: false },
|
|
5926
|
+
main: { ok: false }
|
|
5927
|
+
};
|
|
5928
|
+
return {
|
|
5929
|
+
ok: false,
|
|
5930
|
+
repo,
|
|
5931
|
+
slug,
|
|
5932
|
+
hubOwned: { meta: { ok: false, missing: [`PROJECT#${slug}/META`] }, deployCoords: emptyCoords, secrets: emptySecrets },
|
|
5933
|
+
autoHealAvailable: Object.keys(autoHeal.patch),
|
|
5934
|
+
appOwnedGaps: autoHeal.appOwnedGaps
|
|
5935
|
+
};
|
|
5936
|
+
}
|
|
5937
|
+
const presentSecrets = new Set(await deps.listSecrets(repo));
|
|
5938
|
+
const deployCoords = Object.fromEntries(await Promise.all(STAGES.map(async (stage2) => [stage2, { ok: await deps.hasDeployCoords(slug, stage2) }])));
|
|
5939
|
+
const secrets2 = Object.fromEntries(STAGES.map((stage2) => {
|
|
5940
|
+
const required = stageRequiredSecrets(stage2, meta).map((key) => stageKey(stage2, key));
|
|
5941
|
+
const present = required.filter((key) => presentSecrets.has(key));
|
|
5942
|
+
const missing = required.filter((key) => !presentSecrets.has(key));
|
|
5943
|
+
return [stage2, { required, present, missing }];
|
|
5944
|
+
}));
|
|
5945
|
+
const metaMissing = ["class", "deployModel", "vaultPath", "kbPointer", "oauth"].filter((key) => meta[key] === void 0);
|
|
5946
|
+
const ok = metaMissing.length === 0 && Object.values(deployCoords).every((v) => v.ok) && Object.values(secrets2).every((v) => v.missing.length === 0);
|
|
5947
|
+
return {
|
|
5948
|
+
ok,
|
|
5949
|
+
repo,
|
|
5950
|
+
slug,
|
|
5951
|
+
class: meta.class,
|
|
5952
|
+
deployModel: model,
|
|
5953
|
+
hubOwned: { meta: { ok: metaMissing.length === 0, missing: metaMissing }, deployCoords, secrets: secrets2 },
|
|
5954
|
+
autoHealAvailable: Object.keys(autoHeal.patch),
|
|
5955
|
+
appOwnedGaps: autoHeal.appOwnedGaps
|
|
5956
|
+
};
|
|
5957
|
+
}
|
|
5958
|
+
function renderReadinessIssueBody(existingBody, report, opts = {}) {
|
|
5959
|
+
const start = "<!-- mmi-v2-readiness:start -->";
|
|
5960
|
+
const end = "<!-- mmi-v2-readiness:end -->";
|
|
5961
|
+
const stageLines = STAGES.map((stage2) => {
|
|
5962
|
+
const coords = report.hubOwned.deployCoords[stage2].ok ? "coords ok" : "coords missing/unverified";
|
|
5963
|
+
const missing = report.hubOwned.secrets[stage2].missing;
|
|
5964
|
+
return `- ${stage2}: ${coords}; ${missing.length ? `missing ${missing.join(", ")}` : "required secret names present"}`;
|
|
5965
|
+
});
|
|
5966
|
+
const section = [
|
|
5967
|
+
start,
|
|
5968
|
+
"## Hub v2 readiness",
|
|
5969
|
+
"",
|
|
5970
|
+
`Repo: ${report.repo}`,
|
|
5971
|
+
`Deploy model: ${report.deployModel ?? "(unresolved)"}`,
|
|
5972
|
+
`Overall: ${report.ok ? "ready" : "not ready"}`,
|
|
5973
|
+
"",
|
|
5974
|
+
"### Hub-owned diagnosis",
|
|
5975
|
+
`- META: ${report.hubOwned.meta.ok ? "ok" : `missing ${report.hubOwned.meta.missing.join(", ")}`}`,
|
|
5976
|
+
...stageLines,
|
|
5977
|
+
"",
|
|
5978
|
+
"### Auto-heal applied / available",
|
|
5979
|
+
...opts.healed?.length ? opts.healed.map((x) => `- ${x}`) : report.autoHealAvailable.map((x) => `- ${x}`),
|
|
5980
|
+
"",
|
|
5981
|
+
"### App-owned implementation plan",
|
|
5982
|
+
...report.appOwnedGaps.map((x) => `- ${x}`),
|
|
5983
|
+
end
|
|
5984
|
+
].join("\n");
|
|
5985
|
+
const re = new RegExp(`${start}[\\s\\S]*?${end}`);
|
|
5986
|
+
return re.test(existingBody) ? existingBody.replace(re, section) : `${existingBody.trim()}
|
|
5987
|
+
|
|
5988
|
+
${section}`.trim();
|
|
5989
|
+
}
|
|
5990
|
+
|
|
5607
5991
|
// src/kb.ts
|
|
5608
5992
|
var DEFAULT_KB = { owner: "mutmutco", repo: "MM-KB", ref: "main" };
|
|
5609
5993
|
function resolveKbSource(rawBase) {
|
|
@@ -5750,7 +6134,8 @@ async function planPull(deps, slug, opts = {}) {
|
|
|
5750
6134
|
deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
|
|
5751
6135
|
}
|
|
5752
6136
|
async function planList(deps, opts = {}) {
|
|
5753
|
-
const
|
|
6137
|
+
const project2 = opts.project ?? (opts.quiet ? await deps.project() : void 0);
|
|
6138
|
+
const qs = project2 ? `?${new URLSearchParams({ project: project2 }).toString()}` : "";
|
|
5754
6139
|
let res;
|
|
5755
6140
|
try {
|
|
5756
6141
|
res = await deps.fetch(`${deps.apiUrl}/plan/list${qs}`, {
|
|
@@ -5926,15 +6311,15 @@ async function secretsList(deps, opts) {
|
|
|
5926
6311
|
const { secrets: secrets2 } = await res.json();
|
|
5927
6312
|
deps.log(formatSecretList(secrets2 ?? []));
|
|
5928
6313
|
}
|
|
5929
|
-
var
|
|
6314
|
+
var DEFAULT_RUNTIME_SECRET_NAMES2 = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
|
|
5930
6315
|
function stringList(v) {
|
|
5931
6316
|
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && isValidSecretKey(x)) : [];
|
|
5932
6317
|
}
|
|
5933
6318
|
function requiredRuntimeSecretNames(stage2, contract) {
|
|
5934
6319
|
const extra = Array.isArray(contract) ? stringList(contract) : stringList(contract?.[stage2]);
|
|
5935
|
-
return [.../* @__PURE__ */ new Set([...
|
|
6320
|
+
return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES2, ...extra])];
|
|
5936
6321
|
}
|
|
5937
|
-
function
|
|
6322
|
+
function stageKey2(stage2, key) {
|
|
5938
6323
|
return key.includes("/") ? key : `${stage2}/${key}`;
|
|
5939
6324
|
}
|
|
5940
6325
|
async function secretsPreflight(deps, opts) {
|
|
@@ -5957,7 +6342,7 @@ async function secretsPreflight(deps, opts) {
|
|
|
5957
6342
|
}
|
|
5958
6343
|
const { secrets: secrets2 } = await res.json();
|
|
5959
6344
|
const present = new Set((secrets2 ?? []).map((s) => s.key));
|
|
5960
|
-
const required = opts.required.map((key) =>
|
|
6345
|
+
const required = opts.required.map((key) => stageKey2(opts.stage, key));
|
|
5961
6346
|
const missing = required.filter((key) => !present.has(key));
|
|
5962
6347
|
if (missing.length) {
|
|
5963
6348
|
deps.log(`missing ${missing.join(", ")}`);
|
|
@@ -6081,7 +6466,7 @@ var LOOPBACK = ["http://localhost", "http://127.0.0.1"];
|
|
|
6081
6466
|
var SSM_ENVS = ["dev", "rc", "main"];
|
|
6082
6467
|
var SSM_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
|
|
6083
6468
|
var uniq = (xs) => [...new Set(xs)];
|
|
6084
|
-
function
|
|
6469
|
+
function defaultSubdomain2(slug) {
|
|
6085
6470
|
const i = slug.indexOf("-");
|
|
6086
6471
|
return i === -1 ? slug : slug.slice(i + 1);
|
|
6087
6472
|
}
|
|
@@ -6110,7 +6495,7 @@ function oauthSsmKeys() {
|
|
|
6110
6495
|
}
|
|
6111
6496
|
function parseOauthConfig(mmiConfig, slug) {
|
|
6112
6497
|
const raw = mmiConfig?.oauth ?? {};
|
|
6113
|
-
const subdomains = Array.isArray(raw.subdomains) && raw.subdomains.length > 0 ? raw.subdomains.map(String) : [
|
|
6498
|
+
const subdomains = Array.isArray(raw.subdomains) && raw.subdomains.length > 0 ? raw.subdomains.map(String) : [defaultSubdomain2(slug)];
|
|
6114
6499
|
const domains = Array.isArray(raw.domains) && raw.domains.length > 0 ? raw.domains.map(String) : [...DEFAULT_DOMAINS];
|
|
6115
6500
|
const callbackPath = typeof raw.callbackPath === "string" && raw.callbackPath ? raw.callbackPath : DEFAULT_CALLBACK_PATH;
|
|
6116
6501
|
if (!callbackPath.startsWith("/")) {
|
|
@@ -6270,7 +6655,15 @@ async function postCapture(capture, quiet = false) {
|
|
|
6270
6655
|
// duplicate the note. Backend capture-latency root cause tracked in #255.
|
|
6271
6656
|
signal: AbortSignal.timeout(2e4)
|
|
6272
6657
|
});
|
|
6273
|
-
|
|
6658
|
+
let message = "";
|
|
6659
|
+
if (!res.ok) {
|
|
6660
|
+
try {
|
|
6661
|
+
const body = await res.clone().json();
|
|
6662
|
+
message = typeof body.message === "string" ? body.message : "";
|
|
6663
|
+
} catch {
|
|
6664
|
+
}
|
|
6665
|
+
}
|
|
6666
|
+
if (!quiet) console.log(res.ok ? "noted" : formatCaptureFailure(res.status, message));
|
|
6274
6667
|
} catch (e) {
|
|
6275
6668
|
if (!quiet) console.error(`mmi-cli saga: ${e.message}`);
|
|
6276
6669
|
}
|
|
@@ -6332,34 +6725,6 @@ async function applyGcPlan(plan2, remote) {
|
|
|
6332
6725
|
await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
|
|
6333
6726
|
}
|
|
6334
6727
|
}
|
|
6335
|
-
async function cleanupLocalBranch(branch, before = {}) {
|
|
6336
|
-
const result = { branchDeleted: false };
|
|
6337
|
-
if (!branch) return result;
|
|
6338
|
-
const { stdout } = await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
|
|
6339
|
-
const afterWorktrees = parseWorktreePorcelain(stdout);
|
|
6340
|
-
const wtPath = selectPrMergeCleanupWorktree(branch, before.worktrees ?? [], afterWorktrees, before.startingPath);
|
|
6341
|
-
const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...before.worktrees ?? []], wtPath);
|
|
6342
|
-
const git = (args) => safeCwd ? execFileP3("git", ["-C", safeCwd, ...args], { timeout: GIT_TIMEOUT_MS }) : execFileP3("git", args, { timeout: GIT_TIMEOUT_MS });
|
|
6343
|
-
if (wtPath) {
|
|
6344
|
-
await git(["worktree", "remove", "--force", wtPath]).catch(() => {
|
|
6345
|
-
});
|
|
6346
|
-
result.worktreeRemoved = wtPath;
|
|
6347
|
-
}
|
|
6348
|
-
const current = safeCwd ? ((await git(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => ({ stdout: "" }))).stdout || "").trim() : await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "";
|
|
6349
|
-
if (branch !== current) {
|
|
6350
|
-
await git(["branch", "-D", branch]).then(() => {
|
|
6351
|
-
result.branchDeleted = true;
|
|
6352
|
-
}).catch(() => {
|
|
6353
|
-
});
|
|
6354
|
-
if (!result.branchDeleted) {
|
|
6355
|
-
const remaining = await git(["branch", "--list", branch]).catch(() => ({ stdout: "" }));
|
|
6356
|
-
result.branchDeleted = branchMissingFromList(branch, remaining.stdout || "");
|
|
6357
|
-
}
|
|
6358
|
-
}
|
|
6359
|
-
if (wtPath) await git(["worktree", "prune"]).catch(() => {
|
|
6360
|
-
});
|
|
6361
|
-
return result;
|
|
6362
|
-
}
|
|
6363
6728
|
function resolveVersion() {
|
|
6364
6729
|
try {
|
|
6365
6730
|
const manifest = (0, import_node_path4.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
|
|
@@ -6405,14 +6770,43 @@ async function applyVersionAutoUpdate(report, log) {
|
|
|
6405
6770
|
}
|
|
6406
6771
|
}
|
|
6407
6772
|
try {
|
|
6408
|
-
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
6409
6773
|
log(` \u21BB updating mmi-cli ${report.currentVersion} \u2192 ${target}\u2026`);
|
|
6410
|
-
await
|
|
6774
|
+
await runHostBin("npm", ["install", "-g", "@mutmutco/cli@latest"], { timeout: NPM_UPDATE_TIMEOUT_MS });
|
|
6411
6775
|
return { ...report, ok: true };
|
|
6412
6776
|
} catch {
|
|
6413
6777
|
return report;
|
|
6414
6778
|
}
|
|
6415
6779
|
}
|
|
6780
|
+
async function requireFreshTrainCli(commandName) {
|
|
6781
|
+
const report = buildVersionLagReport({
|
|
6782
|
+
currentVersion: resolveVersion(),
|
|
6783
|
+
repoVersion: readRepoVersion(),
|
|
6784
|
+
releasedVersion: await fetchReleasedVersion()
|
|
6785
|
+
});
|
|
6786
|
+
if (report.ok) return;
|
|
6787
|
+
const target = report.staleAgainst === "released" ? `released ${report.releasedVersion}` : `repo ${report.repoVersion}`;
|
|
6788
|
+
throw new Error(`running mmi-cli ${report.currentVersion} is stale against ${target}; run doctor/update first so ${commandName} uses the current train path`);
|
|
6789
|
+
}
|
|
6790
|
+
var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
|
|
6791
|
+
function runHostBin(bin, args, opts) {
|
|
6792
|
+
return isWin ? execFileP3("cmd.exe", ["/c", bin, ...args], opts) : execFileP3(bin, args, opts);
|
|
6793
|
+
}
|
|
6794
|
+
async function runClaudePlugin(args) {
|
|
6795
|
+
try {
|
|
6796
|
+
await runHostBin("claude", args, { timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
|
|
6797
|
+
return true;
|
|
6798
|
+
} catch {
|
|
6799
|
+
return false;
|
|
6800
|
+
}
|
|
6801
|
+
}
|
|
6802
|
+
async function applyClaudePluginHeal(surface, log) {
|
|
6803
|
+
if (surface !== "claude-cli" && surface !== "claude-vscode") return false;
|
|
6804
|
+
log(" \u21BB updating the MMI plugin via `claude plugin` (marketplace \u2192 update \u2192 enable)\u2026");
|
|
6805
|
+
if (!await runClaudePlugin(["plugin", "marketplace", "update", "mmi"])) return false;
|
|
6806
|
+
if (!await runClaudePlugin(["plugin", "update", "mmi@mmi"])) return false;
|
|
6807
|
+
await runClaudePlugin(["plugin", "enable", "mmi@mmi"]);
|
|
6808
|
+
return true;
|
|
6809
|
+
}
|
|
6416
6810
|
var program2 = new Command();
|
|
6417
6811
|
program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveVersion());
|
|
6418
6812
|
var rules = program2.command("rules").description("org rules delivery");
|
|
@@ -6554,9 +6948,15 @@ saga.command("key").option("--json", "machine-readable output").description("pri
|
|
|
6554
6948
|
async function probeBackend(url) {
|
|
6555
6949
|
try {
|
|
6556
6950
|
const res = await fetch(`${url}/saga/head`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
|
|
6557
|
-
|
|
6951
|
+
let message = "";
|
|
6952
|
+
try {
|
|
6953
|
+
const body = await res.clone().json();
|
|
6954
|
+
message = typeof body.message === "string" ? body.message : "";
|
|
6955
|
+
} catch {
|
|
6956
|
+
}
|
|
6957
|
+
return { reachable: res.ok || res.status === 403, status: res.status, message };
|
|
6558
6958
|
} catch {
|
|
6559
|
-
return false;
|
|
6959
|
+
return { reachable: false };
|
|
6560
6960
|
}
|
|
6561
6961
|
}
|
|
6562
6962
|
async function probeSagaAccess(url, key) {
|
|
@@ -6574,9 +6974,18 @@ saga.command("health").option("--json", "machine-readable output").option("--ban
|
|
|
6574
6974
|
const key = await sagaKey(cfg, session);
|
|
6575
6975
|
const source = session.source;
|
|
6576
6976
|
const identity = await githubLogin();
|
|
6577
|
-
const
|
|
6578
|
-
const authorized = cfg.sagaApiUrl && reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
|
|
6579
|
-
const report = buildHealth({
|
|
6977
|
+
const liveness = cfg.sagaApiUrl ? await probeBackend(cfg.sagaApiUrl) : { reachable: false };
|
|
6978
|
+
const authorized = cfg.sagaApiUrl && liveness.reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
|
|
6979
|
+
const report = buildHealth({
|
|
6980
|
+
key,
|
|
6981
|
+
source,
|
|
6982
|
+
identity,
|
|
6983
|
+
reachable: liveness.reachable,
|
|
6984
|
+
livenessStatus: liveness.status,
|
|
6985
|
+
livenessMessage: liveness.message,
|
|
6986
|
+
authorized,
|
|
6987
|
+
sagaApiUrl: cfg.sagaApiUrl
|
|
6988
|
+
});
|
|
6580
6989
|
if (o.json) return console.log(JSON.stringify(report));
|
|
6581
6990
|
if (o.banner) {
|
|
6582
6991
|
const banner = healthBanner(report);
|
|
@@ -6851,6 +7260,47 @@ function reportWrite(label, res) {
|
|
|
6851
7260
|
const detail = res.body?.error ?? "";
|
|
6852
7261
|
fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
|
|
6853
7262
|
}
|
|
7263
|
+
async function v2ReadinessDeps(cfg) {
|
|
7264
|
+
const reg = registryClientDeps(cfg);
|
|
7265
|
+
return {
|
|
7266
|
+
getProject: (slug) => fetchProjectBySlug(slug, reg),
|
|
7267
|
+
hasDeployCoords: async (slug, stage2) => {
|
|
7268
|
+
const status = await fetchDeployStatusBySlug(slug, reg);
|
|
7269
|
+
return Boolean(status?.[stage2]);
|
|
7270
|
+
},
|
|
7271
|
+
listSecrets: async (targetRepo2) => {
|
|
7272
|
+
const apiUrl = cfg.sagaApiUrl;
|
|
7273
|
+
if (!apiUrl) return [];
|
|
7274
|
+
const qs = new URLSearchParams({ repo: targetRepo2 }).toString();
|
|
7275
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/secrets/list?${qs}`, {
|
|
7276
|
+
method: "GET",
|
|
7277
|
+
headers: await sagaHeaders(),
|
|
7278
|
+
signal: AbortSignal.timeout(8e3)
|
|
7279
|
+
});
|
|
7280
|
+
if (!res.ok) return [];
|
|
7281
|
+
const body = await res.json();
|
|
7282
|
+
return (body.secrets ?? []).map((s) => s.key).filter(Boolean);
|
|
7283
|
+
}
|
|
7284
|
+
};
|
|
7285
|
+
}
|
|
7286
|
+
async function updateV2ReadinessIssue(repo, report, healed) {
|
|
7287
|
+
const title = "v2 readiness: central deploy + secrets alignment";
|
|
7288
|
+
const freshBody = renderReadinessIssueBody("", report, { healed });
|
|
7289
|
+
const list = await execFileP3("gh", ["issue", "list", "--repo", repo, "--state", "open", "--search", "v2 readiness in:title", "--json", "number,title", "--limit", "20"], { timeout: 2e4 });
|
|
7290
|
+
const issues = JSON.parse(list.stdout || "[]");
|
|
7291
|
+
const existing = issues.find((i) => i.title.toLowerCase().includes("v2 readiness"));
|
|
7292
|
+
if (!existing) {
|
|
7293
|
+
const created = await execFileP3("gh", ["issue", "create", "--repo", repo, "--title", title, "--body", freshBody, "--label", "feature"], { timeout: 2e4 });
|
|
7294
|
+
const url = created.stdout.trim();
|
|
7295
|
+
const number = Number(url.match(/\/issues\/(\d+)$/)?.[1] ?? 0);
|
|
7296
|
+
return { number, url, action: "created" };
|
|
7297
|
+
}
|
|
7298
|
+
const view = await execFileP3("gh", ["issue", "view", String(existing.number), "--repo", repo, "--json", "body,url", "--jq", "{body:.body,url:.url}"], { timeout: 2e4 });
|
|
7299
|
+
const current = JSON.parse(view.stdout || "{}");
|
|
7300
|
+
const nextBody = renderReadinessIssueBody(current.body ?? "", report, { healed });
|
|
7301
|
+
await execFileP3("gh", ["issue", "edit", String(existing.number), "--repo", repo, "--body", nextBody], { timeout: 2e4 });
|
|
7302
|
+
return { number: existing.number, url: current.url ?? `https://github.com/${repo}/issues/${existing.number}`, action: "updated" };
|
|
7303
|
+
}
|
|
6854
7304
|
var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); set is master-only");
|
|
6855
7305
|
project.command("list").description("list all projects (identity + board, never deploy coords)").option("--json", "machine-readable output").action(async (o) => {
|
|
6856
7306
|
const cfg = await loadConfig();
|
|
@@ -6879,6 +7329,38 @@ project.command("resolve <owner/repo>").description("deploy coords for a stage \
|
|
|
6879
7329
|
}
|
|
6880
7330
|
fail(msg);
|
|
6881
7331
|
});
|
|
7332
|
+
project.command("doctor <owner/repo>").description("diagnose Hub v2 readiness for a repo without reading product repo files").option("--v2", "run the v2 central deploy/secrets readiness contract").option("--json", "machine-readable output").action(async (repo, o) => {
|
|
7333
|
+
if (!o.v2) return fail("project doctor: pass --v2");
|
|
7334
|
+
const cfg = await loadConfig();
|
|
7335
|
+
const report = await buildV2Doctor(repo, await v2ReadinessDeps(cfg));
|
|
7336
|
+
console.log(JSON.stringify(report));
|
|
7337
|
+
if (!report.ok) process.exitCode = 1;
|
|
7338
|
+
});
|
|
7339
|
+
project.command("heal <owner/repo>").description("repair Hub-owned v2 readiness state only; never product repo files").option("--v2", "run the v2 central deploy/secrets readiness contract").option("--apply", "apply the Hub-owned registry patch").option("--json", "machine-readable output").action(async (repo, o) => {
|
|
7340
|
+
if (!o.v2) return fail("project heal: pass --v2");
|
|
7341
|
+
const cfg = await loadConfig();
|
|
7342
|
+
const slug = slugOf(repo);
|
|
7343
|
+
const meta = await fetchProjectBySlug(slug, registryClientDeps(cfg));
|
|
7344
|
+
const plan2 = buildV2HealPatch(repo, meta);
|
|
7345
|
+
if (!o.apply) {
|
|
7346
|
+
console.log(JSON.stringify({ ok: true, slug, dryRun: true, patch: plan2.patch, appOwnedGaps: plan2.appOwnedGaps }));
|
|
7347
|
+
return;
|
|
7348
|
+
}
|
|
7349
|
+
if (!Object.keys(plan2.patch).length) {
|
|
7350
|
+
console.log(JSON.stringify({ ok: true, slug, applied: [], appOwnedGaps: plan2.appOwnedGaps }));
|
|
7351
|
+
return;
|
|
7352
|
+
}
|
|
7353
|
+
const res = await upsertProject(slug, plan2.patch, registryClientDeps(cfg));
|
|
7354
|
+
if (!res.ok) return reportWrite("project heal", res);
|
|
7355
|
+
console.log(JSON.stringify({ ok: true, slug, applied: Object.keys(plan2.patch), appOwnedGaps: plan2.appOwnedGaps, result: res.body }));
|
|
7356
|
+
});
|
|
7357
|
+
project.command("readiness <owner/repo>").description("update the repo v2 readiness issue with Hub diagnosis and app-owned tasks").option("--update-issue", "create/update the bounded v2 readiness issue section").option("--json", "machine-readable output").action(async (repo, o) => {
|
|
7358
|
+
if (!o.updateIssue) return fail("project readiness: pass --update-issue");
|
|
7359
|
+
const cfg = await loadConfig();
|
|
7360
|
+
const report = await buildV2Doctor(repo, await v2ReadinessDeps(cfg));
|
|
7361
|
+
const issue2 = await updateV2ReadinessIssue(repo, report, []);
|
|
7362
|
+
console.log(JSON.stringify({ ok: true, repo, issue: issue2, ready: report.ok }));
|
|
7363
|
+
});
|
|
6882
7364
|
project.command("set <owner/repo>").description("MASTER-ONLY: upsert a project META (idempotent merge; no clobber of unspecified fields)").option("--class <class>", "deployable | content").option("--deploy-model <model>", "hub-serverless | serverless | tenant-container | content (release-train deploy path, #514)").option("--var <KEY=VALUE...>", "META field to set (repeatable): name, division, projectId, branch, wikiRepo, vaultPath, kbPointer").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
|
|
6883
7365
|
const cfg = await loadConfig();
|
|
6884
7366
|
const slug = slugOf(repoOrSlug);
|
|
@@ -7038,6 +7520,15 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
|
|
|
7038
7520
|
const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
|
|
7039
7521
|
console.log(JSON.stringify(created));
|
|
7040
7522
|
});
|
|
7523
|
+
async function remoteBranchExists(branch) {
|
|
7524
|
+
if (!branch) return void 0;
|
|
7525
|
+
try {
|
|
7526
|
+
const { stdout } = await execFileP3("git", ["ls-remote", "--heads", "origin", branch], { timeout: GIT_TIMEOUT_MS });
|
|
7527
|
+
return stdout.trim().length > 0;
|
|
7528
|
+
} catch {
|
|
7529
|
+
return void 0;
|
|
7530
|
+
}
|
|
7531
|
+
}
|
|
7041
7532
|
pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree \u2014 no leftover local branch").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (number, o) => {
|
|
7042
7533
|
const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
|
|
7043
7534
|
const repoArgs = o.repo ? ["--repo", o.repo] : [];
|
|
@@ -7046,11 +7537,42 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
|
|
|
7046
7537
|
const beforeWorktrees = parseWorktreePorcelain(
|
|
7047
7538
|
(await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
|
|
7048
7539
|
);
|
|
7540
|
+
const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
|
|
7541
|
+
let remoteDeleteAttempted = false;
|
|
7542
|
+
let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
|
|
7049
7543
|
await execFileP3("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
|
|
7050
|
-
|
|
7544
|
+
const message = String(e.message || "");
|
|
7545
|
+
if (/already been merged/i.test(message)) {
|
|
7546
|
+
remoteNotAttemptedReason = "pr-already-merged";
|
|
7547
|
+
return;
|
|
7548
|
+
}
|
|
7549
|
+
if (!/used by worktree|cannot delete branch/i.test(message)) throw e;
|
|
7550
|
+
});
|
|
7551
|
+
if (!remoteNotAttemptedReason) remoteDeleteAttempted = true;
|
|
7552
|
+
const remoteAfter = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
|
|
7553
|
+
const remoteBranch = buildRemoteBranchCleanupReport(headRef, {
|
|
7554
|
+
attempted: remoteDeleteAttempted,
|
|
7555
|
+
existedBefore: remoteBefore,
|
|
7556
|
+
existsAfter: remoteAfter,
|
|
7557
|
+
reason: remoteNotAttemptedReason
|
|
7051
7558
|
});
|
|
7052
|
-
const
|
|
7053
|
-
|
|
7559
|
+
const localCleanup = repoArgs.length ? {
|
|
7560
|
+
branch: headRef,
|
|
7561
|
+
localBranch: { name: headRef, status: "not-attempted", reason: "repo-option" },
|
|
7562
|
+
worktree: void 0
|
|
7563
|
+
} : await cleanupPrMergeLocalBranch(headRef, {
|
|
7564
|
+
beforeWorktrees,
|
|
7565
|
+
startingPath,
|
|
7566
|
+
execGit: async (args) => (await execFileP3("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
|
|
7567
|
+
});
|
|
7568
|
+
console.log(JSON.stringify({
|
|
7569
|
+
merged: number,
|
|
7570
|
+
branch: headRef,
|
|
7571
|
+
method: method.slice(2),
|
|
7572
|
+
remoteBranch,
|
|
7573
|
+
localBranch: localCleanup.localBranch,
|
|
7574
|
+
worktree: localCleanup.worktree
|
|
7575
|
+
}));
|
|
7054
7576
|
});
|
|
7055
7577
|
async function runBoardRead(o) {
|
|
7056
7578
|
try {
|
|
@@ -7248,6 +7770,11 @@ program2.command("stage-live").description("explain that remote rc/live environm
|
|
|
7248
7770
|
});
|
|
7249
7771
|
for (const commandName of ["rcand", "release", "hotfix"]) {
|
|
7250
7772
|
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--apply", commandName === "hotfix" ? "reserved; hotfix uses the /hotfix skill PR path" : "execute the guarded master-only train after explicit approval").action(async (o) => {
|
|
7773
|
+
try {
|
|
7774
|
+
await requireFreshTrainCli(commandName);
|
|
7775
|
+
} catch (e) {
|
|
7776
|
+
return fail(`${commandName}: ${e.message}`);
|
|
7777
|
+
}
|
|
7251
7778
|
if (o.apply) {
|
|
7252
7779
|
if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
|
|
7253
7780
|
try {
|
|
@@ -7489,7 +8016,7 @@ function writeGitignore(content) {
|
|
|
7489
8016
|
return false;
|
|
7490
8017
|
}
|
|
7491
8018
|
}
|
|
7492
|
-
program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone, plugin install record, .gitignore managed block, plugin config drift) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output").action(async (opts) => {
|
|
8019
|
+
program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone, plugin install record, .gitignore managed block, plugin config drift, installed plugin version) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output").action(async (opts) => {
|
|
7493
8020
|
if (opts.guide) {
|
|
7494
8021
|
if (opts.json) console.log(JSON.stringify({ resources: [MMI_AGENTIC_ONBOARDING_GUIDE] }, null, 2));
|
|
7495
8022
|
else console.log(MMI_AGENTIC_ONBOARDING_GUIDE.url);
|
|
@@ -7517,12 +8044,15 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
|
|
|
7517
8044
|
if (root && (0, import_node_fs4.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
|
|
7518
8045
|
}
|
|
7519
8046
|
checks.push({ ok: onPath, label: "mmi-cli on PATH", fix: "auto-provisioned at session start \u2014 reopen the session, or install the MMI plugin" });
|
|
8047
|
+
const surface = detectSurface(process.env);
|
|
8048
|
+
const releasedVersion = await fetchReleasedVersion();
|
|
7520
8049
|
let versionReport = buildVersionLagReport({
|
|
7521
8050
|
currentVersion: resolveVersion(),
|
|
7522
8051
|
repoVersion: readRepoVersion(),
|
|
7523
|
-
releasedVersion
|
|
8052
|
+
releasedVersion
|
|
7524
8053
|
});
|
|
7525
8054
|
if (!opts.json) versionReport = await applyVersionAutoUpdate(versionReport, (m) => console.error(m));
|
|
8055
|
+
if (!versionReport.ok) versionReport = { ...versionReport, fix: pluginRecoveryFix(surface) };
|
|
7526
8056
|
checks.push(versionReport);
|
|
7527
8057
|
const cfg = await loadConfig();
|
|
7528
8058
|
checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "repo config (.mmi/config.json)", fix: "ask a master-admin to run /bootstrap on this repo" });
|
|
@@ -7574,7 +8104,30 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
|
|
|
7574
8104
|
if (!opts.banner) console.error(" \u21BB repaired: collapsed mmi@mmi to one user-scope entry (backup at installed_plugins.json.bak) \u2014 run /reload-plugins");
|
|
7575
8105
|
}
|
|
7576
8106
|
}
|
|
8107
|
+
if (!driftCheck.ok) driftCheck = { ...driftCheck, fix: pluginRecoveryFix(surface) };
|
|
7577
8108
|
checks.push(driftCheck);
|
|
8109
|
+
let installedVersionCheck = buildInstalledPluginVersionCheck({
|
|
8110
|
+
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
8111
|
+
installed,
|
|
8112
|
+
releasedVersion,
|
|
8113
|
+
surface
|
|
8114
|
+
});
|
|
8115
|
+
if (!installedVersionCheck.ok && !opts.json) {
|
|
8116
|
+
if (await applyClaudePluginHeal(surface, (m) => console.error(m))) {
|
|
8117
|
+
const healed = buildInstalledPluginVersionCheck({
|
|
8118
|
+
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
8119
|
+
installed: readInstalledPlugins(),
|
|
8120
|
+
releasedVersion,
|
|
8121
|
+
surface
|
|
8122
|
+
});
|
|
8123
|
+
installedVersionCheck = healed;
|
|
8124
|
+
if (healed.ok && !opts.banner) {
|
|
8125
|
+
const reload = surface === "claude-vscode" ? "reopen the VS Code workspace" : "restart Claude Code (or run /reload-plugins)";
|
|
8126
|
+
console.error(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via claude plugin \u2014 ${reload} to load the new commands`);
|
|
8127
|
+
}
|
|
8128
|
+
}
|
|
8129
|
+
}
|
|
8130
|
+
checks.push(installedVersionCheck);
|
|
7578
8131
|
const gaps = checks.filter((c) => !c.ok);
|
|
7579
8132
|
const resources = doctorResourcesForGaps(gaps);
|
|
7580
8133
|
if (opts.json) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mutmutco/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "MMI Future CLI — delivers the org rules (whole-file), plus saga and KB access. The cross-IDE engine the plugin's SessionStart hook drives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|