@mutmutco/cli 0.11.0 → 2.0.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 +23 -7
- package/dist/index.cjs +1024 -128
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -3064,6 +3064,10 @@ function rulesSourceAuthHeaders(sourceUrl, token) {
|
|
|
3064
3064
|
}
|
|
3065
3065
|
return void 0;
|
|
3066
3066
|
}
|
|
3067
|
+
function resolveRulesBase(orgRulesSource, defaultBase) {
|
|
3068
|
+
const isUrl = typeof orgRulesSource === "string" && /^https?:\/\//i.test(orgRulesSource);
|
|
3069
|
+
return (isUrl ? orgRulesSource : defaultBase).replace(/\/$/, "");
|
|
3070
|
+
}
|
|
3067
3071
|
|
|
3068
3072
|
// src/docs-sync.ts
|
|
3069
3073
|
var SYNCED_DOCS = ["README.md", "architecture.md"];
|
|
@@ -3100,6 +3104,7 @@ function parseHookInput(stdin) {
|
|
|
3100
3104
|
var import_node_child_process4 = require("node:child_process");
|
|
3101
3105
|
var import_node_util3 = require("node:util");
|
|
3102
3106
|
var import_node_path4 = require("node:path");
|
|
3107
|
+
var import_node_os = require("node:os");
|
|
3103
3108
|
|
|
3104
3109
|
// src/saga-head-maintainer.ts
|
|
3105
3110
|
var import_node_child_process = require("node:child_process");
|
|
@@ -3235,7 +3240,6 @@ function buildIssueArgs({ type, title, body, priority, repo, labels }) {
|
|
|
3235
3240
|
const args = ["issue", "create"];
|
|
3236
3241
|
if (repo) args.push("--repo", repo);
|
|
3237
3242
|
args.push("--title", title, "--body", body, "--label", type);
|
|
3238
|
-
args.push("--label", `priority:${priority}`);
|
|
3239
3243
|
for (const label of labels ?? []) args.push("--label", label);
|
|
3240
3244
|
return args;
|
|
3241
3245
|
}
|
|
@@ -3288,6 +3292,12 @@ function resolveSession(d) {
|
|
|
3288
3292
|
return { id, source: "generated" };
|
|
3289
3293
|
}
|
|
3290
3294
|
|
|
3295
|
+
// src/hub-url.ts
|
|
3296
|
+
var DEFAULT_HUB_URL = "https://tqxxwzftic.execute-api.eu-central-1.amazonaws.com";
|
|
3297
|
+
function defaultHubUrl() {
|
|
3298
|
+
return process.env.MMI_HUB_URL || DEFAULT_HUB_URL;
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3291
3301
|
// src/saga-health.ts
|
|
3292
3302
|
function buildHealth(i) {
|
|
3293
3303
|
const problems = [];
|
|
@@ -3313,8 +3323,17 @@ function healthBanner(report) {
|
|
|
3313
3323
|
const suffix = report.problems.length > 2 ? ` (+${report.problems.length - 2} more)` : "";
|
|
3314
3324
|
return `saga health: CHECK - ${summary}${suffix}`;
|
|
3315
3325
|
}
|
|
3326
|
+
function resumeCue() {
|
|
3327
|
+
return '> STATUS/RESUME CUE \u2014 For any status, resume, or "where do I stand" report: read THIS saga HEAD first (`mmi-cli saga show`), then reconcile its NEXT / LAST 5 / DECISIONS against the live board + git/gh before reporting. Do not rebuild the picture from board/issues/memory while skipping the HEAD.';
|
|
3328
|
+
}
|
|
3316
3329
|
|
|
3317
3330
|
// src/saga-note.ts
|
|
3331
|
+
var AGENT_SURFACE_TOKENS = ["claude", "codex", "cursor", "gemini"];
|
|
3332
|
+
function agentSurface() {
|
|
3333
|
+
const surface = process.env.MMI_AGENT_SURFACE || "claude";
|
|
3334
|
+
if (AGENT_SURFACE_TOKENS.includes(surface)) return surface;
|
|
3335
|
+
throw new Error(`MMI_AGENT_SURFACE must be one of: ${AGENT_SURFACE_TOKENS.join(", ")}`);
|
|
3336
|
+
}
|
|
3318
3337
|
function buildNoteCapture(summary, o, id, evidence) {
|
|
3319
3338
|
const queueOp = o.queueAdd ? { op: "add", text: o.queueAdd } : o.queueDone != null ? { op: "done", index: Number(o.queueDone) } : void 0;
|
|
3320
3339
|
const state = o.diagnostic ? "diagnostic" : o.verified ? "verified" : "asserted";
|
|
@@ -3335,7 +3354,7 @@ function buildNoteCapture(summary, o, id, evidence) {
|
|
|
3335
3354
|
state,
|
|
3336
3355
|
source,
|
|
3337
3356
|
evidence: Object.keys(ev).length ? ev : void 0,
|
|
3338
|
-
surface:
|
|
3357
|
+
surface: agentSurface(),
|
|
3339
3358
|
supersedes: o.supersedes,
|
|
3340
3359
|
anchor,
|
|
3341
3360
|
anchorForce: o.anchorForce || void 0
|
|
@@ -3392,6 +3411,16 @@ function buildVersionLagReport(input) {
|
|
|
3392
3411
|
releasedVersion: input.releasedVersion
|
|
3393
3412
|
};
|
|
3394
3413
|
}
|
|
3414
|
+
function pluginManifestVersionArgs() {
|
|
3415
|
+
return ["api", "repos/mutmutco/MMI-Hub/contents/.claude-plugin/plugin.json?ref=main", "-H", "Accept: application/vnd.github.raw"];
|
|
3416
|
+
}
|
|
3417
|
+
function parseManifestVersion(stdout) {
|
|
3418
|
+
try {
|
|
3419
|
+
return JSON.parse(stdout).version || void 0;
|
|
3420
|
+
} catch {
|
|
3421
|
+
return void 0;
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3395
3424
|
function versionAutoUpdateAction(report, hasPluginRoot) {
|
|
3396
3425
|
if (report.ok || report.staleAgainst !== "released") return "none";
|
|
3397
3426
|
return hasPluginRoot ? "plugin-pull" : "npm";
|
|
@@ -3603,7 +3632,7 @@ function parseIssueSelector(selector, defaultRepo) {
|
|
|
3603
3632
|
if (local) return { repo: defaultRepo, number: Number(local[1]) };
|
|
3604
3633
|
throw new Error(`expected an issue selector like 123, #123, owner/repo#123, or a GitHub issue URL`);
|
|
3605
3634
|
}
|
|
3606
|
-
function partitionBoardItems(items, viewer, currentRepo) {
|
|
3635
|
+
function partitionBoardItems(items, viewer, currentRepo, writableRepos) {
|
|
3607
3636
|
const empty = () => ({ userOwned: [], claimable: [], taken: [] });
|
|
3608
3637
|
const groups = { primary: empty(), secondary: empty() };
|
|
3609
3638
|
const viewerKey = viewer.toLowerCase();
|
|
@@ -3615,7 +3644,7 @@ function partitionBoardItems(items, viewer, currentRepo) {
|
|
|
3615
3644
|
const assignedToViewer = assignees.includes(viewerKey);
|
|
3616
3645
|
if (assignedToViewer) {
|
|
3617
3646
|
groups[scope].userOwned.push(item);
|
|
3618
|
-
} else if (item.status === "Todo" && item.assignees.length === 0) {
|
|
3647
|
+
} else if (item.status === "Todo" && item.assignees.length === 0 && (!writableRepos || writableRepos.has(item.repository.toLowerCase()))) {
|
|
3619
3648
|
groups[scope].claimable.push(item);
|
|
3620
3649
|
} else if (item.assignees.length > 0) {
|
|
3621
3650
|
groups[scope].taken.push(item);
|
|
@@ -3695,12 +3724,12 @@ async function collectBoardItems(cfg, options, deps) {
|
|
|
3695
3724
|
try {
|
|
3696
3725
|
const page = await fetchProjectPage(gh, cfg, after);
|
|
3697
3726
|
viewer ||= page.viewer.login;
|
|
3698
|
-
const
|
|
3699
|
-
if (!
|
|
3700
|
-
projectId =
|
|
3701
|
-
projectTitle ||=
|
|
3702
|
-
nodes.push(...
|
|
3703
|
-
after =
|
|
3727
|
+
const project2 = page.organization?.projectV2;
|
|
3728
|
+
if (!project2) throw new Error(`project ${cfg.projectOwner}#${cfg.projectNumber} not found`);
|
|
3729
|
+
projectId = project2.id;
|
|
3730
|
+
projectTitle ||= project2.title;
|
|
3731
|
+
nodes.push(...project2.items.nodes ?? []);
|
|
3732
|
+
after = project2.items.pageInfo.hasNextPage ? project2.items.pageInfo.endCursor ?? void 0 : void 0;
|
|
3704
3733
|
} catch (e) {
|
|
3705
3734
|
const message = `partial board read: ${e.message}`;
|
|
3706
3735
|
if (!nodes.length || !options.allowPartial) throw new Error(message);
|
|
@@ -3711,11 +3740,46 @@ async function collectBoardItems(cfg, options, deps) {
|
|
|
3711
3740
|
} while (after);
|
|
3712
3741
|
return { items: nodesToItems(nodes, warnings), viewer, repo: currentRepo, projectId, projectTitle, warnings, partial };
|
|
3713
3742
|
}
|
|
3743
|
+
async function repoCanPush(repo, gh) {
|
|
3744
|
+
try {
|
|
3745
|
+
const { stdout } = await gh(["api", `repos/${repo}`, "--jq", ".permissions.push"]);
|
|
3746
|
+
const value = stdout.trim();
|
|
3747
|
+
if (value === "true") return true;
|
|
3748
|
+
if (value === "false") return false;
|
|
3749
|
+
const parsed = JSON.parse(value);
|
|
3750
|
+
return typeof parsed.permissions?.push === "boolean" ? parsed.permissions.push : void 0;
|
|
3751
|
+
} catch {
|
|
3752
|
+
return void 0;
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
async function resolveWritableReposForClaimables(items, gh, allowPartial) {
|
|
3756
|
+
const candidateRepos = [...new Set(items.filter((item) => item.status === "Todo" && item.assignees.length === 0).map((item) => item.repository))];
|
|
3757
|
+
const repos = /* @__PURE__ */ new Set();
|
|
3758
|
+
const warnings = [];
|
|
3759
|
+
let partial = false;
|
|
3760
|
+
for (const repo of candidateRepos) {
|
|
3761
|
+
const canPush = await repoCanPush(repo, gh);
|
|
3762
|
+
if (canPush === true) {
|
|
3763
|
+
repos.add(repo.toLowerCase());
|
|
3764
|
+
continue;
|
|
3765
|
+
}
|
|
3766
|
+
if (canPush === void 0) {
|
|
3767
|
+
const warning = `partial claimable access read: ${repo}: could not verify viewer write access`;
|
|
3768
|
+
if (!allowPartial) throw new Error(warning);
|
|
3769
|
+
warnings.push(warning);
|
|
3770
|
+
partial = true;
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
return { repos, warnings, partial };
|
|
3774
|
+
}
|
|
3714
3775
|
async function readBoard(options, deps = {}) {
|
|
3715
3776
|
const cfg = resolveBoardConfig(options.config);
|
|
3716
3777
|
const gh = deps.gh ?? defaultGh;
|
|
3717
3778
|
const collected = await collectBoardItems(cfg, options, deps);
|
|
3718
|
-
const
|
|
3779
|
+
const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
|
|
3780
|
+
collected.warnings.push(...writable.warnings);
|
|
3781
|
+
collected.partial = collected.partial || writable.partial;
|
|
3782
|
+
const groups = partitionBoardItems(collected.items, collected.viewer, collected.repo, writable.repos);
|
|
3719
3783
|
const report = {
|
|
3720
3784
|
project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: collected.projectId, title: collected.projectTitle || String(cfg.projectNumber) },
|
|
3721
3785
|
viewer: collected.viewer,
|
|
@@ -3799,8 +3863,23 @@ async function showBoardItem(options, deps = {}) {
|
|
|
3799
3863
|
async function claimBoardIssue(options, deps = {}) {
|
|
3800
3864
|
const cfg = resolveBoardConfig(options.config);
|
|
3801
3865
|
const gh = deps.gh ?? defaultGh;
|
|
3802
|
-
const
|
|
3803
|
-
const
|
|
3866
|
+
const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
|
|
3867
|
+
const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
|
|
3868
|
+
collected.warnings.push(...writable.warnings);
|
|
3869
|
+
collected.partial = collected.partial || writable.partial;
|
|
3870
|
+
const report = {
|
|
3871
|
+
project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: collected.projectId, title: collected.projectTitle || String(cfg.projectNumber) },
|
|
3872
|
+
viewer: collected.viewer,
|
|
3873
|
+
repo: collected.repo,
|
|
3874
|
+
...partitionBoardItems(collected.items, collected.viewer, collected.repo, writable.repos),
|
|
3875
|
+
warnings: collected.warnings,
|
|
3876
|
+
partial: collected.partial
|
|
3877
|
+
};
|
|
3878
|
+
const selector = parseIssueSelector(options.selector, collected.repo);
|
|
3879
|
+
const flatItem = findBoardItem(collected.items, selector);
|
|
3880
|
+
if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !writable.repos.has(flatItem.repository.toLowerCase())) {
|
|
3881
|
+
throw new Error(`${flatItem.ref} is not claimable: viewer does not have write access to ${flatItem.repository}`);
|
|
3882
|
+
}
|
|
3804
3883
|
const item = findClaimableItem(report, selector);
|
|
3805
3884
|
if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
|
|
3806
3885
|
const assignee = options.assignee ?? "@me";
|
|
@@ -4206,6 +4285,12 @@ function stagePlan(stage2 = {}) {
|
|
|
4206
4285
|
{ label: "check health", command: stage2.healthUrl ? `curl --fail ${stage2.healthUrl}` : "(no stage.healthUrl configured)" }
|
|
4207
4286
|
];
|
|
4208
4287
|
}
|
|
4288
|
+
function stageLivePlan() {
|
|
4289
|
+
return [
|
|
4290
|
+
{ label: "stage-live is not an org command; /stage is local only", command: "mmi-cli stage run --apply" },
|
|
4291
|
+
{ label: "remote rc/live environments move through the gated promotion train", command: "mmi-cli rc && mmi-cli release && mmi-cli hotfix", gated: true }
|
|
4292
|
+
];
|
|
4293
|
+
}
|
|
4209
4294
|
function trainPlan(command) {
|
|
4210
4295
|
if (command === "rc") {
|
|
4211
4296
|
return [
|
|
@@ -4244,8 +4329,83 @@ function bootstrapPlan(repo, repoClass) {
|
|
|
4244
4329
|
];
|
|
4245
4330
|
}
|
|
4246
4331
|
|
|
4332
|
+
// src/bootstrap-seeds.ts
|
|
4333
|
+
var PLACEHOLDER_RE = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
4334
|
+
function loadBootstrapSeeds(manifestJson) {
|
|
4335
|
+
let parsed;
|
|
4336
|
+
try {
|
|
4337
|
+
parsed = JSON.parse(manifestJson);
|
|
4338
|
+
} catch {
|
|
4339
|
+
throw new Error("bootstrap seed manifest is not valid JSON");
|
|
4340
|
+
}
|
|
4341
|
+
const obj = parsed ?? {};
|
|
4342
|
+
const seeds = obj.seeds ?? [];
|
|
4343
|
+
for (const s of seeds) {
|
|
4344
|
+
if (!s || !s.target || !s.source || !Array.isArray(s.classes)) {
|
|
4345
|
+
throw new Error(`invalid seed entry (needs target, source, classes): ${JSON.stringify(s)}`);
|
|
4346
|
+
}
|
|
4347
|
+
if (s.ownership !== "org" && s.ownership !== "repo") {
|
|
4348
|
+
throw new Error(`invalid seed ownership '${s.ownership}' for ${s.target} (must be 'org' or 'repo')`);
|
|
4349
|
+
}
|
|
4350
|
+
}
|
|
4351
|
+
return {
|
|
4352
|
+
seeds,
|
|
4353
|
+
labels: obj.labels ?? [],
|
|
4354
|
+
placeholders: obj.placeholders ?? []
|
|
4355
|
+
};
|
|
4356
|
+
}
|
|
4357
|
+
function renderSeed(template, vars) {
|
|
4358
|
+
return template.replace(PLACEHOLDER_RE, (match, key) => key in vars ? vars[key] : match);
|
|
4359
|
+
}
|
|
4360
|
+
function missingPlaceholders(rendered) {
|
|
4361
|
+
const out = /* @__PURE__ */ new Set();
|
|
4362
|
+
for (const m of rendered.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
|
|
4363
|
+
return [...out];
|
|
4364
|
+
}
|
|
4365
|
+
var GITIGNORE_MANAGED_BEGIN = "# >>> mmi-managed >>>";
|
|
4366
|
+
var GITIGNORE_MANAGED_END = "# <<< mmi-managed <<<";
|
|
4367
|
+
var MANAGED_GITIGNORE_LINES = [
|
|
4368
|
+
'# Org-wide cleanliness (AGENTS.md "Repo cleanliness") \u2014 enforced by `mmi-cli doctor`.',
|
|
4369
|
+
"# Do not edit inside these markers; this block is regenerated on the next doctor run.",
|
|
4370
|
+
".playwright-mcp/",
|
|
4371
|
+
".claude/worktrees/",
|
|
4372
|
+
"/*.png"
|
|
4373
|
+
];
|
|
4374
|
+
function renderManagedGitignoreBlock() {
|
|
4375
|
+
return [GITIGNORE_MANAGED_BEGIN, ...MANAGED_GITIGNORE_LINES, GITIGNORE_MANAGED_END].join("\n");
|
|
4376
|
+
}
|
|
4377
|
+
function upsertManagedGitignoreBlock(current) {
|
|
4378
|
+
const block = renderManagedGitignoreBlock();
|
|
4379
|
+
const src = (current ?? "").replace(/\r\n/g, "\n");
|
|
4380
|
+
if (src.trim() === "") {
|
|
4381
|
+
const next2 = `${block}
|
|
4382
|
+
`;
|
|
4383
|
+
return { content: next2, changed: src !== next2 };
|
|
4384
|
+
}
|
|
4385
|
+
const managed = /* @__PURE__ */ new Set([GITIGNORE_MANAGED_BEGIN, GITIGNORE_MANAGED_END, ...MANAGED_GITIGNORE_LINES]);
|
|
4386
|
+
const lines = src.split("\n");
|
|
4387
|
+
const beginAt = lines.findIndex((l) => l === GITIGNORE_MANAGED_BEGIN);
|
|
4388
|
+
const endAt = beginAt === -1 ? -1 : lines.findIndex((l, i) => i > beginAt && l === GITIGNORE_MANAGED_END);
|
|
4389
|
+
let next;
|
|
4390
|
+
if (beginAt !== -1 && endAt !== -1) {
|
|
4391
|
+
const before = lines.slice(0, beginAt).filter((l) => !managed.has(l.trim()));
|
|
4392
|
+
const after = lines.slice(endAt + 1).filter((l) => !managed.has(l.trim()));
|
|
4393
|
+
next = `${[...before, ...block.split("\n"), ...after].join("\n").replace(/\n+$/, "")}
|
|
4394
|
+
`;
|
|
4395
|
+
} else {
|
|
4396
|
+
const kept = lines.filter((l) => !managed.has(l.trim())).join("\n").replace(/\n+$/, "");
|
|
4397
|
+
next = kept === "" ? `${block}
|
|
4398
|
+
` : `${kept}
|
|
4399
|
+
|
|
4400
|
+
${block}
|
|
4401
|
+
`;
|
|
4402
|
+
}
|
|
4403
|
+
return { content: next, changed: src !== next };
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4247
4406
|
// src/doctor.ts
|
|
4248
4407
|
var GH_PROJECT_LOGIN_FIX = 'run: gh auth login --hostname github.com --git-protocol https --web --scopes "project"';
|
|
4408
|
+
var AWS_CROSS_ACCOUNT_FIX = "use a non-root IAM user/session profile for master-agent AWS checks; set AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (plus AWS_SESSION_TOKEN for temporary credentials), then verify `aws sts get-caller-identity` does not end in :root";
|
|
4249
4409
|
function buildGithubAuthCheck(input) {
|
|
4250
4410
|
const ok = Boolean(input.login?.trim());
|
|
4251
4411
|
return {
|
|
@@ -4254,6 +4414,62 @@ function buildGithubAuthCheck(input) {
|
|
|
4254
4414
|
fix: input.ghInstalled ? GH_PROJECT_LOGIN_FIX : `install GitHub CLI (https://cli.github.com), then: ${GH_PROJECT_LOGIN_FIX.replace(/^run: /, "")}`
|
|
4255
4415
|
};
|
|
4256
4416
|
}
|
|
4417
|
+
function buildAwsCrossAccountCheck(input) {
|
|
4418
|
+
const callerArn = input.callerArn?.trim();
|
|
4419
|
+
return {
|
|
4420
|
+
ok: !callerArn || !callerArn.endsWith(":root"),
|
|
4421
|
+
label: "AWS cross-account identity (master-agent audits)",
|
|
4422
|
+
fix: AWS_CROSS_ACCOUNT_FIX
|
|
4423
|
+
};
|
|
4424
|
+
}
|
|
4425
|
+
var MMI_PLUGIN_ID = "mmi@mmi";
|
|
4426
|
+
var PLUGIN_LABEL = "plugin install record (mmi@mmi for this project)";
|
|
4427
|
+
function pluginInstallManualFix(projectPath) {
|
|
4428
|
+
return `run \`/plugin install ${MMI_PLUGIN_ID}\` then \`/reload-plugins\` (VS Code extension: reopen the workspace) to register the project install record for ${projectPath}`;
|
|
4429
|
+
}
|
|
4430
|
+
function isMmiPluginEnabled(settings) {
|
|
4431
|
+
return Boolean(settings?.enabledPlugins?.[MMI_PLUGIN_ID]);
|
|
4432
|
+
}
|
|
4433
|
+
function hasProjectInstallRecord(file, pluginId, projectPath) {
|
|
4434
|
+
const records = file?.plugins?.[pluginId];
|
|
4435
|
+
if (!Array.isArray(records)) return false;
|
|
4436
|
+
return records.some((r) => r.scope === "project" && r.projectPath === projectPath);
|
|
4437
|
+
}
|
|
4438
|
+
function buildPluginInstallRecordCheck(input) {
|
|
4439
|
+
const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
|
|
4440
|
+
const base = {
|
|
4441
|
+
ok: true,
|
|
4442
|
+
label: PLUGIN_LABEL,
|
|
4443
|
+
fix: pluginInstallManualFix(input.projectPath),
|
|
4444
|
+
pluginId
|
|
4445
|
+
};
|
|
4446
|
+
if (!input.isOrgRepo || !isMmiPluginEnabled(input.settings)) return base;
|
|
4447
|
+
if (hasProjectInstallRecord(input.installed, pluginId, input.projectPath)) return base;
|
|
4448
|
+
const now = input.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4449
|
+
const recordToInsert = input.mirrorFrom ? {
|
|
4450
|
+
...input.mirrorFrom,
|
|
4451
|
+
scope: "project",
|
|
4452
|
+
projectPath: input.projectPath,
|
|
4453
|
+
...input.mirrorFrom.installedAt !== void 0 ? { installedAt: now } : {},
|
|
4454
|
+
...input.mirrorFrom.lastUpdated !== void 0 ? { lastUpdated: now } : {}
|
|
4455
|
+
} : { scope: "project", projectPath: input.projectPath, installedAt: now, lastUpdated: now };
|
|
4456
|
+
return {
|
|
4457
|
+
ok: false,
|
|
4458
|
+
label: PLUGIN_LABEL,
|
|
4459
|
+
fix: pluginInstallManualFix(input.projectPath),
|
|
4460
|
+
pluginId,
|
|
4461
|
+
recordToInsert
|
|
4462
|
+
};
|
|
4463
|
+
}
|
|
4464
|
+
var GITIGNORE_BLOCK_LABEL = "org .gitignore managed block (.playwright-mcp/, .claude/worktrees/, scratch *.png)";
|
|
4465
|
+
var GITIGNORE_BLOCK_FIX = "run `mmi-cli doctor` to auto-insert the `# >>> mmi-managed >>>` block (or copy it from MMI-Hub's .gitignore)";
|
|
4466
|
+
function buildGitignoreManagedBlockCheck(input) {
|
|
4467
|
+
const base = { ok: true, label: GITIGNORE_BLOCK_LABEL, fix: GITIGNORE_BLOCK_FIX };
|
|
4468
|
+
if (!input.isOrgRepo) return base;
|
|
4469
|
+
const { content, changed } = upsertManagedGitignoreBlock(input.content);
|
|
4470
|
+
if (!changed) return base;
|
|
4471
|
+
return { ...base, ok: false, contentToWrite: content };
|
|
4472
|
+
}
|
|
4257
4473
|
|
|
4258
4474
|
// src/stage-runner.ts
|
|
4259
4475
|
var import_node_child_process3 = require("node:child_process");
|
|
@@ -4417,8 +4633,8 @@ var import_node_fs3 = require("node:fs");
|
|
|
4417
4633
|
var BLOCK = 100;
|
|
4418
4634
|
var SPAN = 10;
|
|
4419
4635
|
var FIRST = 3e3;
|
|
4420
|
-
function nextPortBlock(
|
|
4421
|
-
const bases = Object.values(
|
|
4636
|
+
function nextPortBlock(registry2) {
|
|
4637
|
+
const bases = Object.values(registry2).map(([start]) => start);
|
|
4422
4638
|
const base = bases.length ? Math.max(...bases) + BLOCK : FIRST;
|
|
4423
4639
|
return [base, base + SPAN];
|
|
4424
4640
|
}
|
|
@@ -4434,20 +4650,42 @@ function loadPortRegistry(path) {
|
|
|
4434
4650
|
return out;
|
|
4435
4651
|
}
|
|
4436
4652
|
function ensurePortRange(repo, path) {
|
|
4437
|
-
const
|
|
4438
|
-
const existing =
|
|
4653
|
+
const registry2 = loadPortRegistry(path);
|
|
4654
|
+
const existing = registry2[repo];
|
|
4439
4655
|
if (existing) return existing;
|
|
4440
|
-
const range = nextPortBlock(
|
|
4656
|
+
const range = nextPortBlock(registry2);
|
|
4441
4657
|
const raw = (0, import_node_fs3.existsSync)(path) ? JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8")) : {};
|
|
4442
4658
|
raw[repo] = range;
|
|
4443
4659
|
(0, import_node_fs3.writeFileSync)(path, JSON.stringify(raw, null, 2) + "\n", "utf8");
|
|
4444
4660
|
return range;
|
|
4445
4661
|
}
|
|
4662
|
+
function portCursorSeed(registry2) {
|
|
4663
|
+
return nextPortBlock(registry2)[0];
|
|
4664
|
+
}
|
|
4665
|
+
function existingPortRange(repo, registry2) {
|
|
4666
|
+
return registry2[repo] ?? null;
|
|
4667
|
+
}
|
|
4668
|
+
async function ensurePortRangeAtomic(repo, path, allocate, opts = {}) {
|
|
4669
|
+
const registry2 = loadPortRegistry(path);
|
|
4670
|
+
const existing = existingPortRange(repo, registry2);
|
|
4671
|
+
if (existing) return { range: existing, source: "existing" };
|
|
4672
|
+
const seed = portCursorSeed(registry2);
|
|
4673
|
+
try {
|
|
4674
|
+
const range = await allocate(seed);
|
|
4675
|
+
return { range, source: "ddb" };
|
|
4676
|
+
} catch (e) {
|
|
4677
|
+
if (!opts.quiet) console.warn(`port-registry: DDB allocator unreachable, falling back to committed file (${e.message})`);
|
|
4678
|
+
return { range: ensurePortRange(repo, path), source: "file" };
|
|
4679
|
+
}
|
|
4680
|
+
}
|
|
4446
4681
|
|
|
4447
4682
|
// src/access.ts
|
|
4448
4683
|
var OWNER = "mutmutco";
|
|
4449
4684
|
var LOCKED_APP = "mmi-github-app";
|
|
4450
4685
|
var OVERGRANT_ROLES = /* @__PURE__ */ new Set(["admin", "maintain"]);
|
|
4686
|
+
var REQUIRED_DATA_ACCESS = {
|
|
4687
|
+
"mutmutco/MM-Chat": [{ name: "kb-projection-reader", dbRole: "kb_reader", vaultParamNeedle: "KB_READ_DB_URL" }]
|
|
4688
|
+
};
|
|
4451
4689
|
function lockedBranches(repoClass) {
|
|
4452
4690
|
return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
|
|
4453
4691
|
}
|
|
@@ -4549,21 +4787,56 @@ async function auditTrainBranch(repo, branch, owners, deps, projectAdmins = /* @
|
|
|
4549
4787
|
}
|
|
4550
4788
|
return findings;
|
|
4551
4789
|
}
|
|
4552
|
-
|
|
4790
|
+
function auditDataAccessContracts(repo, contracts = { consumers: {} }) {
|
|
4791
|
+
const required = REQUIRED_DATA_ACCESS[repo] ?? [];
|
|
4792
|
+
const configured = (contracts.consumers ?? {})[repo] ?? [];
|
|
4793
|
+
const findings = [];
|
|
4794
|
+
for (const req of required) {
|
|
4795
|
+
const matched = configured.some((grant) => grant.name === req.name && grant.dbRole === req.dbRole && (grant.vaultParams ?? []).some((param) => param.includes(req.vaultParamNeedle)));
|
|
4796
|
+
if (!matched) {
|
|
4797
|
+
findings.push({
|
|
4798
|
+
repo,
|
|
4799
|
+
kind: "data-access-missing",
|
|
4800
|
+
severity: "high",
|
|
4801
|
+
detail: `${repo} must have auditable data-access contract '${req.name}' with DB role '${req.dbRole}' and vault parameter name containing '${req.vaultParamNeedle}'`,
|
|
4802
|
+
remediation: `add or fix data-access-contracts.json for ${repo}; record parameter names only, never DSNs or secret values`
|
|
4803
|
+
});
|
|
4804
|
+
}
|
|
4805
|
+
}
|
|
4806
|
+
return findings;
|
|
4807
|
+
}
|
|
4808
|
+
async function auditRepoAccess(repo, repoClass, owners, deps, projectAdmins = /* @__PURE__ */ new Set(), dataAccess) {
|
|
4553
4809
|
const findings = [];
|
|
4554
4810
|
findings.push(...await auditRepoCollaborators(repo, owners, deps));
|
|
4811
|
+
if (dataAccess) findings.push(...auditDataAccessContracts(repo, dataAccess));
|
|
4555
4812
|
for (const branch of lockedBranches(repoClass)) {
|
|
4556
4813
|
findings.push(...await auditTrainBranch(repo, branch, owners, deps, projectAdmins));
|
|
4557
4814
|
}
|
|
4558
4815
|
return { repo, class: repoClass, ok: !findings.some((f) => f.severity === "high"), findings };
|
|
4559
4816
|
}
|
|
4560
|
-
async function
|
|
4817
|
+
async function auditOrgBasePermission(deps) {
|
|
4818
|
+
const org = await ghJson(deps, ["api", `orgs/${OWNER}`], {});
|
|
4819
|
+
const perm = org.default_repository_permission;
|
|
4820
|
+
if (perm && perm !== "read" && perm !== "none") {
|
|
4821
|
+
return [{
|
|
4822
|
+
repo: OWNER,
|
|
4823
|
+
kind: "org-base-permission",
|
|
4824
|
+
severity: "high",
|
|
4825
|
+
detail: `org default_repository_permission is '${perm}' \u2014 every member gets '${perm}' on every repo; D25 requires 'read'`,
|
|
4826
|
+
remediation: `gh api -X PATCH orgs/${OWNER} -f default_repository_permission=read`
|
|
4827
|
+
}];
|
|
4828
|
+
}
|
|
4829
|
+
return [];
|
|
4830
|
+
}
|
|
4831
|
+
async function auditOrgAccess(targets, deps, matrix = {}, dataAccess) {
|
|
4561
4832
|
const owners = new Set(await resolveOwners(deps));
|
|
4833
|
+
const orgFindings = await auditOrgBasePermission(deps);
|
|
4562
4834
|
const repos = [];
|
|
4563
4835
|
for (const target of targets) {
|
|
4564
|
-
repos.push(await auditRepoAccess(target.repo, target.class, owners, deps, new Set(matrix[target.repo] ?? [])));
|
|
4836
|
+
repos.push(await auditRepoAccess(target.repo, target.class, owners, deps, new Set(matrix[target.repo] ?? []), dataAccess));
|
|
4565
4837
|
}
|
|
4566
|
-
|
|
4838
|
+
const ok = orgFindings.every((f) => f.severity !== "high") && repos.every((r) => r.ok);
|
|
4839
|
+
return { ok, owners: [...owners], orgFindings, repos };
|
|
4567
4840
|
}
|
|
4568
4841
|
function loadAccessTargets(projectsJson, fanoutJson) {
|
|
4569
4842
|
const projects = safeJson(projectsJson, {}).projects ?? [];
|
|
@@ -4571,8 +4844,8 @@ function loadAccessTargets(projectsJson, fanoutJson) {
|
|
|
4571
4844
|
const contentNames = new Set(fanout.filter((r) => r.class === "content").map((r) => r.repo));
|
|
4572
4845
|
const seen = /* @__PURE__ */ new Set();
|
|
4573
4846
|
const targets = [];
|
|
4574
|
-
for (const
|
|
4575
|
-
for (const repo of
|
|
4847
|
+
for (const project2 of projects) {
|
|
4848
|
+
for (const repo of project2.repos ?? []) {
|
|
4576
4849
|
if (seen.has(repo)) continue;
|
|
4577
4850
|
seen.add(repo);
|
|
4578
4851
|
targets.push({ repo, class: contentNames.has(repo.split("/")[1]) ? "content" : "deployable" });
|
|
@@ -4584,8 +4857,37 @@ function loadAccessMatrix(matrixJson) {
|
|
|
4584
4857
|
if (!matrixJson) return {};
|
|
4585
4858
|
return safeJson(matrixJson, {}).projectAdmins ?? {};
|
|
4586
4859
|
}
|
|
4860
|
+
function loadDataAccessContracts(dataAccessJson) {
|
|
4861
|
+
if (!dataAccessJson) return { consumers: {} };
|
|
4862
|
+
const parsed = safeJson(dataAccessJson, { consumers: {} });
|
|
4863
|
+
return { consumers: parsed.consumers ?? {} };
|
|
4864
|
+
}
|
|
4865
|
+
function canonAccessRepo(repo) {
|
|
4866
|
+
const name = repo.includes("/") ? repo.split("/").pop() : repo;
|
|
4867
|
+
return `mutmutco/${name.toLowerCase()}`;
|
|
4868
|
+
}
|
|
4869
|
+
function accessMatrixFromProjects(projects) {
|
|
4870
|
+
const matrix = {};
|
|
4871
|
+
for (const p of projects) {
|
|
4872
|
+
if (!Array.isArray(p.projectAdmins) || p.projectAdmins.length === 0) continue;
|
|
4873
|
+
for (const repo of p.repos ?? []) matrix[canonAccessRepo(repo)] = p.projectAdmins;
|
|
4874
|
+
}
|
|
4875
|
+
return matrix;
|
|
4876
|
+
}
|
|
4877
|
+
function dataAccessContractsFromProjects(projects) {
|
|
4878
|
+
const consumers = {};
|
|
4879
|
+
for (const p of projects) {
|
|
4880
|
+
if (!Array.isArray(p.consumers) || p.consumers.length === 0) continue;
|
|
4881
|
+
for (const repo of p.repos ?? []) consumers[canonAccessRepo(repo)] = p.consumers;
|
|
4882
|
+
}
|
|
4883
|
+
return { consumers };
|
|
4884
|
+
}
|
|
4587
4885
|
function renderAccessReport(report) {
|
|
4588
4886
|
const lines = [`mmi-cli access audit: ${report.ok ? "OK" : "CHECK"} (owners: ${report.owners.map((o) => "@" + o).join(", ") || "none"})`];
|
|
4887
|
+
for (const finding of report.orgFindings ?? []) {
|
|
4888
|
+
lines.push(` [${finding.severity}] ${finding.kind}: ${finding.detail}`);
|
|
4889
|
+
if (finding.remediation) lines.push(` ${finding.remediation}`);
|
|
4890
|
+
}
|
|
4589
4891
|
for (const repo of report.repos) {
|
|
4590
4892
|
lines.push(`${repo.ok ? "OK" : "FLAG"} ${repo.repo} (${repo.class})`);
|
|
4591
4893
|
for (const finding of repo.findings) {
|
|
@@ -4604,19 +4906,19 @@ var requiredIssueTemplates = [
|
|
|
4604
4906
|
".github/ISSUE_TEMPLATE/task.yml",
|
|
4605
4907
|
".github/ISSUE_TEMPLATE/config.yml"
|
|
4606
4908
|
];
|
|
4607
|
-
var requiredWorkflows = [
|
|
4909
|
+
var requiredWorkflows = [];
|
|
4608
4910
|
var requiredLabels = ["bug", "feature", "task", "priority:urgent", "priority:high", "priority:medium", "priority:low"];
|
|
4609
4911
|
var requiredPriorityOptions = ["Urgent", "High", "Medium", "Low"];
|
|
4610
4912
|
var strayDefaultLabels = ["documentation", "duplicate", "enhancement", "good first issue", "help wanted", "invalid", "question", "wontfix"];
|
|
4611
4913
|
var requiredStatusOptions = ["Todo", "In Progress", "In Review", "Done"];
|
|
4612
4914
|
var requiredProjectWorkflows = [
|
|
4613
4915
|
"Auto-add sub-issues to project",
|
|
4614
|
-
"Auto-
|
|
4916
|
+
"Auto-archive items",
|
|
4615
4917
|
"Item added to project",
|
|
4616
|
-
"Item closed"
|
|
4617
|
-
"Pull request linked to issue",
|
|
4618
|
-
"Pull request merged"
|
|
4918
|
+
"Item closed"
|
|
4619
4919
|
];
|
|
4920
|
+
var requiredOrgRulesetTypes = ["pull_request", "non_fast_forward", "deletion"];
|
|
4921
|
+
var requiredHubStatusChecks = ["cli", "infra", "docs"];
|
|
4620
4922
|
var requiredActionsVariables = ["MMI_APP_ID"];
|
|
4621
4923
|
var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
|
|
4622
4924
|
function expectedBranches(repoClass) {
|
|
@@ -4672,6 +4974,27 @@ function hasPushAllowlist(p) {
|
|
|
4672
4974
|
function optionDetail(missing) {
|
|
4673
4975
|
return missing.length === 0 ? void 0 : `missing: ${missing.join(", ")}`;
|
|
4674
4976
|
}
|
|
4977
|
+
function presentDetail(present) {
|
|
4978
|
+
return present.length === 0 ? void 0 : `present: ${present.join(", ")}`;
|
|
4979
|
+
}
|
|
4980
|
+
function missingRuleTypes(ruleset, required) {
|
|
4981
|
+
const types = new Set((ruleset.rules || []).map((rule) => rule.type).filter(Boolean));
|
|
4982
|
+
return required.filter((type) => !types.has(type));
|
|
4983
|
+
}
|
|
4984
|
+
function rulesetStatusChecks(rulesets) {
|
|
4985
|
+
return new Set(rulesets.flatMap((ruleset) => (ruleset.rules || []).filter((rule) => rule.type === "required_status_checks").flatMap((rule) => rule.parameters?.required_status_checks || []).map((check) => check.context).filter((context) => Boolean(context))));
|
|
4986
|
+
}
|
|
4987
|
+
async function rulesetDetails(deps, repo, list) {
|
|
4988
|
+
const details = [];
|
|
4989
|
+
for (const ruleset of list) {
|
|
4990
|
+
if (ruleset.id == null || ruleset.rules != null) {
|
|
4991
|
+
details.push(ruleset);
|
|
4992
|
+
continue;
|
|
4993
|
+
}
|
|
4994
|
+
details.push(await ghJson2(deps, ["api", `repos/${repo}/rulesets/${ruleset.id}`], ruleset));
|
|
4995
|
+
}
|
|
4996
|
+
return details;
|
|
4997
|
+
}
|
|
4675
4998
|
function localRegistryCheck(deps, path, predicate) {
|
|
4676
4999
|
const text = deps.readLocalFile?.(path);
|
|
4677
5000
|
if (text == null) return null;
|
|
@@ -4724,7 +5047,7 @@ async function verifyBootstrap(repo, repoClass, deps) {
|
|
|
4724
5047
|
checks.push({ ok: labelNames.has(label), label: `label exists: ${label}` });
|
|
4725
5048
|
}
|
|
4726
5049
|
const strays = strayDefaultLabels.filter((l) => labelNames.has(l));
|
|
4727
|
-
checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail:
|
|
5050
|
+
checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: presentDetail(strays) });
|
|
4728
5051
|
const actions = await ghJson2(deps, ["api", `repos/${repo}/actions/permissions`], {});
|
|
4729
5052
|
checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
|
|
4730
5053
|
const variables = await ghJson2(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
|
|
@@ -4743,12 +5066,12 @@ async function verifyBootstrap(repo, repoClass, deps) {
|
|
|
4743
5066
|
label: ".mmi project board config exists"
|
|
4744
5067
|
});
|
|
4745
5068
|
if (config?.projectOwner && config.projectNumber != null) {
|
|
4746
|
-
const
|
|
5069
|
+
const project2 = await ghJson2(
|
|
4747
5070
|
deps,
|
|
4748
5071
|
["project", "field-list", String(config.projectNumber), "--owner", config.projectOwner, "--format", "json"],
|
|
4749
5072
|
{}
|
|
4750
5073
|
);
|
|
4751
|
-
const fields =
|
|
5074
|
+
const fields = project2.fields || [];
|
|
4752
5075
|
const statusField = fields.find((field) => field.name === "Status");
|
|
4753
5076
|
const labelField = fields.find((field) => field.name === "Labels");
|
|
4754
5077
|
checks.push({
|
|
@@ -4822,19 +5145,31 @@ async function verifyBootstrap(repo, repoClass, deps) {
|
|
|
4822
5145
|
if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
|
|
4823
5146
|
const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
|
|
4824
5147
|
if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
|
|
4825
|
-
const
|
|
5148
|
+
const rulesetList = await ghJson2(
|
|
4826
5149
|
deps,
|
|
4827
5150
|
["api", `repos/${repo}/rulesets?includes_parents=true`],
|
|
4828
5151
|
[]
|
|
4829
5152
|
);
|
|
4830
|
-
const
|
|
5153
|
+
const rulesets = await rulesetDetails(deps, repo, rulesetList);
|
|
5154
|
+
const activeOrgRulesets = rulesets.filter(
|
|
4831
5155
|
(r) => r.source_type === "Organization" && r.target === "branch" && r.enforcement === "active"
|
|
4832
5156
|
);
|
|
5157
|
+
const orgRuleset = activeOrgRulesets.find((ruleset) => missingRuleTypes(ruleset, requiredOrgRulesetTypes).length === 0);
|
|
5158
|
+
const missingOrgRuleTypes = activeOrgRulesets.length === 0 ? requiredOrgRulesetTypes : missingRuleTypes(activeOrgRulesets[0], requiredOrgRulesetTypes);
|
|
4833
5159
|
checks.push({
|
|
4834
|
-
ok: orgRuleset,
|
|
5160
|
+
ok: Boolean(orgRuleset),
|
|
4835
5161
|
label: "covered by an active org ruleset",
|
|
4836
|
-
detail: orgRuleset ? void 0 : "no active Organization-sourced branch ruleset targets this repo"
|
|
5162
|
+
detail: orgRuleset ? void 0 : activeOrgRulesets.length === 0 ? "no active Organization-sourced branch ruleset targets this repo" : `missing rule types: ${missingOrgRuleTypes.join(", ")}`
|
|
4837
5163
|
});
|
|
5164
|
+
if (repo === "mutmutco/MMI-Hub") {
|
|
5165
|
+
const statusChecks = rulesetStatusChecks(rulesets.filter((r) => r.target === "branch" && r.enforcement === "active"));
|
|
5166
|
+
const missing = requiredHubStatusChecks.filter((check) => !statusChecks.has(check));
|
|
5167
|
+
checks.push({
|
|
5168
|
+
ok: missing.length === 0,
|
|
5169
|
+
label: "Hub required status checks configured",
|
|
5170
|
+
detail: optionDetail(missing)
|
|
5171
|
+
});
|
|
5172
|
+
}
|
|
4838
5173
|
const waived = applyWaivers(checks, config?.verifyWaivers ?? []);
|
|
4839
5174
|
return { ok: waived.every((c) => c.ok || c.waived), repo, class: repoClass, baseBranch, checks: waived };
|
|
4840
5175
|
}
|
|
@@ -4852,45 +5187,14 @@ function renderBootstrapVerifyReport(report) {
|
|
|
4852
5187
|
return lines.join("\n");
|
|
4853
5188
|
}
|
|
4854
5189
|
|
|
4855
|
-
// src/bootstrap-seeds.ts
|
|
4856
|
-
var PLACEHOLDER_RE = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
4857
|
-
function loadBootstrapSeeds(manifestJson) {
|
|
4858
|
-
let parsed;
|
|
4859
|
-
try {
|
|
4860
|
-
parsed = JSON.parse(manifestJson);
|
|
4861
|
-
} catch {
|
|
4862
|
-
throw new Error("bootstrap seed manifest is not valid JSON");
|
|
4863
|
-
}
|
|
4864
|
-
const obj = parsed ?? {};
|
|
4865
|
-
const seeds = obj.seeds ?? [];
|
|
4866
|
-
for (const s of seeds) {
|
|
4867
|
-
if (!s || !s.target || !s.source || !Array.isArray(s.classes)) {
|
|
4868
|
-
throw new Error(`invalid seed entry (needs target, source, classes): ${JSON.stringify(s)}`);
|
|
4869
|
-
}
|
|
4870
|
-
if (s.ownership !== "org" && s.ownership !== "repo") {
|
|
4871
|
-
throw new Error(`invalid seed ownership '${s.ownership}' for ${s.target} (must be 'org' or 'repo')`);
|
|
4872
|
-
}
|
|
4873
|
-
}
|
|
4874
|
-
return {
|
|
4875
|
-
seeds,
|
|
4876
|
-
labels: obj.labels ?? [],
|
|
4877
|
-
placeholders: obj.placeholders ?? []
|
|
4878
|
-
};
|
|
4879
|
-
}
|
|
4880
|
-
function renderSeed(template, vars) {
|
|
4881
|
-
return template.replace(PLACEHOLDER_RE, (match, key) => key in vars ? vars[key] : match);
|
|
4882
|
-
}
|
|
4883
|
-
function missingPlaceholders(rendered) {
|
|
4884
|
-
const out = /* @__PURE__ */ new Set();
|
|
4885
|
-
for (const m of rendered.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
|
|
4886
|
-
return [...out];
|
|
4887
|
-
}
|
|
4888
|
-
|
|
4889
5190
|
// src/bootstrap-apply.ts
|
|
4890
5191
|
function planSeedAction(seed, exists) {
|
|
4891
5192
|
if (seed.source === "fanout") {
|
|
4892
5193
|
return { target: seed.target, action: "skip", ownership: "fanout", reason: "delivered by the fanout pipeline" };
|
|
4893
5194
|
}
|
|
5195
|
+
if (seed.source === "managed-block") {
|
|
5196
|
+
return exists ? { target: seed.target, action: "update", ownership: "org", reason: "org-managed block merged in-place (repo-owned lines preserved)" } : { target: seed.target, action: "create", ownership: "org", reason: "org-managed block; .gitignore absent, created" };
|
|
5197
|
+
}
|
|
4894
5198
|
if (seed.ownership === "repo") {
|
|
4895
5199
|
return exists ? { target: seed.target, action: "skip", ownership: "repo", reason: "repo-owned, already present (never clobbered)" } : { target: seed.target, action: "create", ownership: "repo", reason: "repo-owned, missing" };
|
|
4896
5200
|
}
|
|
@@ -4913,6 +5217,49 @@ function resolveSeedContent(seed, vars, readFile2) {
|
|
|
4913
5217
|
}
|
|
4914
5218
|
return null;
|
|
4915
5219
|
}
|
|
5220
|
+
function buildRegisterPayload(repo, cls, vars) {
|
|
5221
|
+
const slug = (repo.split("/")[1] ?? repo).toLowerCase();
|
|
5222
|
+
const num = (v) => {
|
|
5223
|
+
if (v == null || v === "") return void 0;
|
|
5224
|
+
const n = Number(v);
|
|
5225
|
+
return Number.isFinite(n) ? n : void 0;
|
|
5226
|
+
};
|
|
5227
|
+
const statusOptions = vars.STATUS_TODO || vars.STATUS_IN_PROGRESS || vars.STATUS_IN_REVIEW || vars.STATUS_DONE ? {
|
|
5228
|
+
Todo: vars.STATUS_TODO,
|
|
5229
|
+
"In Progress": vars.STATUS_IN_PROGRESS,
|
|
5230
|
+
"In Review": vars.STATUS_IN_REVIEW,
|
|
5231
|
+
Done: vars.STATUS_DONE
|
|
5232
|
+
} : void 0;
|
|
5233
|
+
const priorityOptions = vars.PRIORITY_URGENT || vars.PRIORITY_HIGH || vars.PRIORITY_MEDIUM || vars.PRIORITY_LOW ? {
|
|
5234
|
+
Urgent: vars.PRIORITY_URGENT,
|
|
5235
|
+
High: vars.PRIORITY_HIGH,
|
|
5236
|
+
Medium: vars.PRIORITY_MEDIUM,
|
|
5237
|
+
Low: vars.PRIORITY_LOW
|
|
5238
|
+
} : void 0;
|
|
5239
|
+
const payload = {
|
|
5240
|
+
slug,
|
|
5241
|
+
// Identity. name/division default off the repo name when the skill didn't pass them.
|
|
5242
|
+
name: vars.NAME || repo.split("/")[1] || slug,
|
|
5243
|
+
division: vars.DIVISION || (repo.split("/")[1] ?? "").split("-")[0] || void 0,
|
|
5244
|
+
repos: [`mutmutco/${slug}`],
|
|
5245
|
+
wikiRepo: vars.WIKI_REPO || `mutmutco/${repo.split("/")[1] ?? slug}`,
|
|
5246
|
+
branch: vars.BRANCH || (cls === "content" ? "main" : "development"),
|
|
5247
|
+
class: cls,
|
|
5248
|
+
// Board coords (from GraphQL at bootstrap, passed as --var by the skill).
|
|
5249
|
+
projectOwner: vars.PROJECT_OWNER || void 0,
|
|
5250
|
+
projectNumber: num(vars.PROJECT_NUMBER),
|
|
5251
|
+
projectId: vars.PROJECT_ID || void 0,
|
|
5252
|
+
statusFieldId: vars.STATUS_FIELD_ID || void 0,
|
|
5253
|
+
statusOptions,
|
|
5254
|
+
priorityFieldId: vars.PRIORITY_FIELD_ID || void 0,
|
|
5255
|
+
priorityOptions,
|
|
5256
|
+
// Pointers. vaultPath is explicit + canonical; kbPointer is the per-project KB doc path.
|
|
5257
|
+
vaultPath: `/mmi-future/${slug}`,
|
|
5258
|
+
kbPointer: `kb/projects/${slug}.md`
|
|
5259
|
+
};
|
|
5260
|
+
for (const k of Object.keys(payload)) if (payload[k] === void 0) delete payload[k];
|
|
5261
|
+
return payload;
|
|
5262
|
+
}
|
|
4916
5263
|
function contentPutArgs(repo, path, content, branch, sha) {
|
|
4917
5264
|
const args = [
|
|
4918
5265
|
"api",
|
|
@@ -4930,6 +5277,93 @@ function contentPutArgs(repo, path, content, branch, sha) {
|
|
|
4930
5277
|
return args;
|
|
4931
5278
|
}
|
|
4932
5279
|
|
|
5280
|
+
// src/registry-client.ts
|
|
5281
|
+
var DEFAULT_TIMEOUT_MS = 8e3;
|
|
5282
|
+
async function fetchProjectsList(deps) {
|
|
5283
|
+
if (!deps.baseUrl) return null;
|
|
5284
|
+
const token = await deps.token();
|
|
5285
|
+
if (!token) return null;
|
|
5286
|
+
const doFetch = deps.fetch ?? fetch;
|
|
5287
|
+
try {
|
|
5288
|
+
const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/projects/list`, {
|
|
5289
|
+
method: "GET",
|
|
5290
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
5291
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
5292
|
+
});
|
|
5293
|
+
if (!res.ok) return null;
|
|
5294
|
+
const body = await res.json();
|
|
5295
|
+
return Array.isArray(body?.projects) ? body.projects : null;
|
|
5296
|
+
} catch {
|
|
5297
|
+
return null;
|
|
5298
|
+
}
|
|
5299
|
+
}
|
|
5300
|
+
async function fetchProjectsJson(deps) {
|
|
5301
|
+
const projects = await fetchProjectsList(deps);
|
|
5302
|
+
return projects ? JSON.stringify({ projects }) : null;
|
|
5303
|
+
}
|
|
5304
|
+
async function fetchProjectBySlug(slug, deps) {
|
|
5305
|
+
if (!deps.baseUrl || !slug) return null;
|
|
5306
|
+
const token = await deps.token();
|
|
5307
|
+
if (!token) return null;
|
|
5308
|
+
const doFetch = deps.fetch ?? fetch;
|
|
5309
|
+
try {
|
|
5310
|
+
const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}`, {
|
|
5311
|
+
method: "GET",
|
|
5312
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
5313
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
5314
|
+
});
|
|
5315
|
+
if (!res.ok) return null;
|
|
5316
|
+
return await res.json();
|
|
5317
|
+
} catch {
|
|
5318
|
+
return null;
|
|
5319
|
+
}
|
|
5320
|
+
}
|
|
5321
|
+
async function fetchOrgConfig(deps) {
|
|
5322
|
+
if (!deps.baseUrl) return null;
|
|
5323
|
+
const token = await deps.token();
|
|
5324
|
+
if (!token) return null;
|
|
5325
|
+
const doFetch = deps.fetch ?? fetch;
|
|
5326
|
+
try {
|
|
5327
|
+
const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/org/config`, {
|
|
5328
|
+
method: "GET",
|
|
5329
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
5330
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
5331
|
+
});
|
|
5332
|
+
if (!res.ok) return null;
|
|
5333
|
+
return await res.json();
|
|
5334
|
+
} catch {
|
|
5335
|
+
return null;
|
|
5336
|
+
}
|
|
5337
|
+
}
|
|
5338
|
+
async function postJson(pathSuffix, payload, deps, method = "POST") {
|
|
5339
|
+
if (!deps.baseUrl) return { ok: false, status: 0, body: null, error: "no Hub API URL (this repo is not bootstrapped)" };
|
|
5340
|
+
const token = await deps.token();
|
|
5341
|
+
if (!token) return { ok: false, status: 0, body: null, error: "no GitHub token (run `gh auth login`)" };
|
|
5342
|
+
const doFetch = deps.fetch ?? fetch;
|
|
5343
|
+
try {
|
|
5344
|
+
const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}${pathSuffix}`, {
|
|
5345
|
+
method,
|
|
5346
|
+
headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
5347
|
+
body: JSON.stringify(payload),
|
|
5348
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
5349
|
+
});
|
|
5350
|
+
let body = null;
|
|
5351
|
+
try {
|
|
5352
|
+
body = await res.json();
|
|
5353
|
+
} catch {
|
|
5354
|
+
}
|
|
5355
|
+
return { ok: res.ok, status: res.status, body };
|
|
5356
|
+
} catch (e) {
|
|
5357
|
+
return { ok: false, status: 0, body: null, error: e.message };
|
|
5358
|
+
}
|
|
5359
|
+
}
|
|
5360
|
+
async function registerProject(payload, deps) {
|
|
5361
|
+
return postJson("/projects/register", payload, deps);
|
|
5362
|
+
}
|
|
5363
|
+
async function upsertProject(slug, patch, deps) {
|
|
5364
|
+
return postJson(`/projects/${encodeURIComponent(slug)}`, patch, deps);
|
|
5365
|
+
}
|
|
5366
|
+
|
|
4933
5367
|
// src/kb.ts
|
|
4934
5368
|
var DEFAULT_KB = { owner: "mutmutco", repo: "MM-KB", ref: "main" };
|
|
4935
5369
|
function resolveKbSource(rawBase) {
|
|
@@ -4961,7 +5395,7 @@ var import_node_path3 = require("node:path");
|
|
|
4961
5395
|
var PLANS_DIR = "plans";
|
|
4962
5396
|
var META_FILE = (0, import_node_path3.join)(PLANS_DIR, ".plan-meta.json");
|
|
4963
5397
|
var planPath = (slug) => (0, import_node_path3.join)(PLANS_DIR, `${slug}.md`);
|
|
4964
|
-
var metaKey = (
|
|
5398
|
+
var metaKey = (project2, slug) => `${project2}/${slug}`;
|
|
4965
5399
|
function parseMeta(raw) {
|
|
4966
5400
|
if (!raw) return {};
|
|
4967
5401
|
try {
|
|
@@ -4983,23 +5417,46 @@ function hashContent(s) {
|
|
|
4983
5417
|
return (h >>> 0).toString(16);
|
|
4984
5418
|
}
|
|
4985
5419
|
function staleHint(slug) {
|
|
4986
|
-
return `remote "${slug}" is newer \u2014 run \`mmi-cli
|
|
5420
|
+
return `remote "${slug}" is newer \u2014 run \`mmi-cli northstar pull ${slug}\` first (your local is based on an older version), or re-push with \`--force\` to overwrite`;
|
|
4987
5421
|
}
|
|
4988
5422
|
function formatPlanList(plans) {
|
|
4989
5423
|
return plans.map((p) => `${p.slug} \xB7 ${p.updatedAt ?? "-"} \xB7 ${p.project}`).join("\n");
|
|
4990
5424
|
}
|
|
4991
5425
|
var TIMEOUT_MS = 8e3;
|
|
5426
|
+
var GRADUATION_KEYS = /* @__PURE__ */ new Set(["northstar-graduation", "privacy", "merged-pr"]);
|
|
5427
|
+
function splitFrontmatter(content) {
|
|
5428
|
+
const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(content);
|
|
5429
|
+
if (!match) return { entries: [], body: content };
|
|
5430
|
+
return { entries: match[1].split(/\r?\n/).filter((line) => line.trim()), body: content.slice(match[0].length) };
|
|
5431
|
+
}
|
|
5432
|
+
function markPlanGraduated(content, opts) {
|
|
5433
|
+
const { entries, body } = splitFrontmatter(normalizeEol(content));
|
|
5434
|
+
const preserved = entries.filter((line) => {
|
|
5435
|
+
const key = /^([A-Za-z0-9_-]+):/.exec(line)?.[1]?.toLowerCase();
|
|
5436
|
+
return !key || !GRADUATION_KEYS.has(key);
|
|
5437
|
+
});
|
|
5438
|
+
const next = [
|
|
5439
|
+
...preserved,
|
|
5440
|
+
"northstar-graduation: built-and-merged",
|
|
5441
|
+
"privacy: org",
|
|
5442
|
+
`merged-pr: ${opts.mergedPr}`
|
|
5443
|
+
];
|
|
5444
|
+
return `---
|
|
5445
|
+
${next.join("\n")}
|
|
5446
|
+
---
|
|
5447
|
+
${body.replace(/^\n+/, "")}`;
|
|
5448
|
+
}
|
|
4992
5449
|
async function planPush(deps, slug, opts = {}) {
|
|
4993
5450
|
const raw = deps.readLocal(slug);
|
|
4994
5451
|
if (raw == null) {
|
|
4995
5452
|
deps.err(`no local ${planPath(slug)} to push`);
|
|
4996
|
-
return;
|
|
5453
|
+
return false;
|
|
4997
5454
|
}
|
|
4998
5455
|
const content = normalizeEol(raw);
|
|
4999
|
-
const
|
|
5456
|
+
const project2 = opts.project ?? await deps.project();
|
|
5000
5457
|
const meta = parseMeta(deps.readMetaRaw());
|
|
5001
|
-
const entry = meta[metaKey(
|
|
5002
|
-
const body = { project, slug, content };
|
|
5458
|
+
const entry = meta[metaKey(project2, slug)];
|
|
5459
|
+
const body = { project: project2, slug, content };
|
|
5003
5460
|
if (opts.force) body.force = true;
|
|
5004
5461
|
else if (entry?.etag) body.baseEtag = entry.etag;
|
|
5005
5462
|
const res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
|
|
@@ -5010,25 +5467,28 @@ async function planPush(deps, slug, opts = {}) {
|
|
|
5010
5467
|
});
|
|
5011
5468
|
if (res.ok) {
|
|
5012
5469
|
const out = await res.json();
|
|
5013
|
-
meta[metaKey(
|
|
5470
|
+
meta[metaKey(project2, slug)] = { etag: out.etag, hash: hashContent(content), syncedAt: deps.now() };
|
|
5014
5471
|
deps.writeMetaRaw(serializeMeta(meta));
|
|
5015
5472
|
deps.log(`pushed ${slug}`);
|
|
5473
|
+
return true;
|
|
5016
5474
|
} else if (res.status === 409) {
|
|
5017
5475
|
deps.err(staleHint(slug));
|
|
5476
|
+
return false;
|
|
5018
5477
|
} else {
|
|
5019
5478
|
deps.err(`plan push failed: HTTP ${res.status}`);
|
|
5479
|
+
return false;
|
|
5020
5480
|
}
|
|
5021
5481
|
}
|
|
5022
5482
|
async function planPull(deps, slug, opts = {}) {
|
|
5023
|
-
const
|
|
5483
|
+
const project2 = opts.project ?? await deps.project();
|
|
5024
5484
|
const meta = parseMeta(deps.readMetaRaw());
|
|
5025
|
-
const entry = meta[metaKey(
|
|
5485
|
+
const entry = meta[metaKey(project2, slug)];
|
|
5026
5486
|
const local = deps.readLocal(slug);
|
|
5027
5487
|
if (local != null && entry && !opts.force && hashContent(normalizeEol(local)) !== entry.hash) {
|
|
5028
5488
|
deps.err(`local ${planPath(slug)} has unpushed edits \u2014 push it, or pull with --force to overwrite`);
|
|
5029
5489
|
return;
|
|
5030
5490
|
}
|
|
5031
|
-
const qs = new URLSearchParams({ project, slug }).toString();
|
|
5491
|
+
const qs = new URLSearchParams({ project: project2, slug }).toString();
|
|
5032
5492
|
const res = await deps.fetch(`${deps.apiUrl}/plan/get?${qs}`, {
|
|
5033
5493
|
method: "GET",
|
|
5034
5494
|
headers: await deps.headers(),
|
|
@@ -5045,7 +5505,7 @@ async function planPull(deps, slug, opts = {}) {
|
|
|
5045
5505
|
const doc = await res.json();
|
|
5046
5506
|
const content = normalizeEol(doc.content ?? "");
|
|
5047
5507
|
deps.writeLocal(slug, content);
|
|
5048
|
-
meta[metaKey(
|
|
5508
|
+
meta[metaKey(project2, slug)] = { etag: doc.etag, hash: hashContent(content), syncedAt: deps.now() };
|
|
5049
5509
|
deps.writeMetaRaw(serializeMeta(meta));
|
|
5050
5510
|
deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
|
|
5051
5511
|
}
|
|
@@ -5078,11 +5538,11 @@ async function planList(deps, opts = {}) {
|
|
|
5078
5538
|
deps.log(formatPlanList(plans));
|
|
5079
5539
|
}
|
|
5080
5540
|
async function planDelete(deps, slug, opts = {}) {
|
|
5081
|
-
const
|
|
5541
|
+
const project2 = opts.project ?? await deps.project();
|
|
5082
5542
|
const res = await deps.fetch(`${deps.apiUrl}/plan/delete`, {
|
|
5083
5543
|
method: "POST",
|
|
5084
5544
|
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5085
|
-
body: JSON.stringify({ project, slug }),
|
|
5545
|
+
body: JSON.stringify({ project: project2, slug }),
|
|
5086
5546
|
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
5087
5547
|
});
|
|
5088
5548
|
if (!res.ok) {
|
|
@@ -5091,10 +5551,29 @@ async function planDelete(deps, slug, opts = {}) {
|
|
|
5091
5551
|
}
|
|
5092
5552
|
deps.removeLocal(slug);
|
|
5093
5553
|
const meta = parseMeta(deps.readMetaRaw());
|
|
5094
|
-
delete meta[metaKey(
|
|
5554
|
+
delete meta[metaKey(project2, slug)];
|
|
5095
5555
|
deps.writeMetaRaw(serializeMeta(meta));
|
|
5096
5556
|
deps.log(`deleted ${slug}`);
|
|
5097
5557
|
}
|
|
5558
|
+
async function planGraduate(deps, slug, opts = {}) {
|
|
5559
|
+
if (!opts.orgVisible) {
|
|
5560
|
+
deps.err("refusing to mark an org-visible graduation without --org-visible");
|
|
5561
|
+
return;
|
|
5562
|
+
}
|
|
5563
|
+
if (!opts.mergedPr) {
|
|
5564
|
+
deps.err("missing --merged-pr <url|number>");
|
|
5565
|
+
return;
|
|
5566
|
+
}
|
|
5567
|
+
const raw = deps.readLocal(slug);
|
|
5568
|
+
if (raw == null) {
|
|
5569
|
+
deps.err(`no local ${planPath(slug)} to graduate`);
|
|
5570
|
+
return;
|
|
5571
|
+
}
|
|
5572
|
+
const content = markPlanGraduated(raw, { mergedPr: opts.mergedPr });
|
|
5573
|
+
deps.writeLocal(slug, content);
|
|
5574
|
+
const pushed = await planPush(deps, slug, { project: opts.project, force: opts.force });
|
|
5575
|
+
if (pushed) deps.log(`graduated ${slug}`);
|
|
5576
|
+
}
|
|
5098
5577
|
|
|
5099
5578
|
// src/secrets.ts
|
|
5100
5579
|
var OWNER2 = "mutmutco";
|
|
@@ -5120,11 +5599,47 @@ function formatSecretList(items) {
|
|
|
5120
5599
|
const width = Math.max(...items.map((i) => i.key.length));
|
|
5121
5600
|
return items.map((i) => `${i.canManage ? "*" : " "} ${i.key.padEnd(width)} ${i.tier}`).join("\n").concat("\n\n* = you can manage (write/rotate) this secret. Values are never shown \u2014 `secrets get <KEY>` prints one.");
|
|
5122
5601
|
}
|
|
5602
|
+
function vaultPointer(slug) {
|
|
5603
|
+
const root = `${SSM_ROOT}/${slug}`;
|
|
5604
|
+
return {
|
|
5605
|
+
slug,
|
|
5606
|
+
root,
|
|
5607
|
+
tiers: {
|
|
5608
|
+
project: `${root}/${PROJECT_TIER_SEGMENT}/* (project-admin self-serve)`,
|
|
5609
|
+
org: [`${root}/rc/*`, `${root}/main/*`].map((p) => `${p} (master-gated)`)
|
|
5610
|
+
},
|
|
5611
|
+
stages: ["dev", "rc", "main"],
|
|
5612
|
+
// Google OAuth is one client per repo; creds live at every stage under the standard key names
|
|
5613
|
+
// (local is port-agnostic and reuses the dev tier). See the oauth-everywhere convention.
|
|
5614
|
+
wellKnown: {
|
|
5615
|
+
googleOAuth: ["dev/GOOGLE_CLIENT_ID", "dev/GOOGLE_CLIENT_SECRET", "rc/GOOGLE_CLIENT_ID", "rc/GOOGLE_CLIENT_SECRET", "main/GOOGLE_CLIENT_ID", "main/GOOGLE_CLIENT_SECRET"]
|
|
5616
|
+
}
|
|
5617
|
+
};
|
|
5618
|
+
}
|
|
5619
|
+
function formatVaultPointer(p) {
|
|
5620
|
+
const lines = [
|
|
5621
|
+
`vault root: ${p.root}`,
|
|
5622
|
+
` project tier (self-serve): ${p.tiers.project}`,
|
|
5623
|
+
` org tier (master-gated): ${p.tiers.org.join(" \xB7 ")}`,
|
|
5624
|
+
`stages: ${p.stages.join(", ")} (local is port-agnostic, reuses dev)`,
|
|
5625
|
+
`well-known keys:`,
|
|
5626
|
+
...Object.entries(p.wellKnown).map(([k, keys]) => ` ${k}: ${keys.join(", ")}`),
|
|
5627
|
+
``,
|
|
5628
|
+
`enumerate actual keys: mmi-cli secrets list`,
|
|
5629
|
+
`read one: mmi-cli secrets get <stage>/<KEY> (e.g. main/GOOGLE_CLIENT_ID)`,
|
|
5630
|
+
`set a project-tier key: mmi-cli secrets set <KEY> (value via stdin; org tier needs a master grant)`
|
|
5631
|
+
];
|
|
5632
|
+
return lines.join("\n");
|
|
5633
|
+
}
|
|
5123
5634
|
var TIMEOUT_MS2 = 8e3;
|
|
5124
5635
|
var repoOf = (slug) => `${OWNER2}/${slug}`;
|
|
5125
5636
|
async function targetRepo(deps, opts) {
|
|
5126
5637
|
return opts.repo ?? repoOf(await deps.slug());
|
|
5127
5638
|
}
|
|
5639
|
+
async function secretsWhere(deps, opts) {
|
|
5640
|
+
const slug = opts.repo ? opts.repo.split("/").pop().toLowerCase() : await deps.slug();
|
|
5641
|
+
deps.log(formatVaultPointer(vaultPointer(slug)));
|
|
5642
|
+
}
|
|
5128
5643
|
async function readErr(res) {
|
|
5129
5644
|
try {
|
|
5130
5645
|
const j = await res.json();
|
|
@@ -5133,6 +5648,23 @@ async function readErr(res) {
|
|
|
5133
5648
|
return "";
|
|
5134
5649
|
}
|
|
5135
5650
|
}
|
|
5651
|
+
async function fetchSecretValue(deps, key, opts) {
|
|
5652
|
+
if (!isValidSecretKey(key)) return null;
|
|
5653
|
+
const repo = await targetRepo(deps, opts);
|
|
5654
|
+
try {
|
|
5655
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/get`, {
|
|
5656
|
+
method: "POST",
|
|
5657
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5658
|
+
body: JSON.stringify({ repo, key }),
|
|
5659
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5660
|
+
});
|
|
5661
|
+
if (!res.ok) return null;
|
|
5662
|
+
const { value } = await res.json();
|
|
5663
|
+
return value ?? null;
|
|
5664
|
+
} catch {
|
|
5665
|
+
return null;
|
|
5666
|
+
}
|
|
5667
|
+
}
|
|
5136
5668
|
async function secretsList(deps, opts) {
|
|
5137
5669
|
const repo = await targetRepo(deps, opts);
|
|
5138
5670
|
const qs = new URLSearchParams({ repo }).toString();
|
|
@@ -5261,6 +5793,69 @@ async function secretsUse(deps, key, _opts) {
|
|
|
5261
5793
|
);
|
|
5262
5794
|
}
|
|
5263
5795
|
|
|
5796
|
+
// src/oauth.ts
|
|
5797
|
+
var DEFAULT_DOMAINS = ["mutatismutandis.co", "mutmut.co"];
|
|
5798
|
+
var DEFAULT_CALLBACK_PATH = "/api/auth/callback";
|
|
5799
|
+
var ENV_PREFIXES = ["", "dev", "rc"];
|
|
5800
|
+
var LOOPBACK = ["http://localhost", "http://127.0.0.1"];
|
|
5801
|
+
var SSM_ENVS = ["dev", "rc", "main"];
|
|
5802
|
+
var SSM_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
|
|
5803
|
+
var uniq = (xs) => [...new Set(xs)];
|
|
5804
|
+
function defaultSubdomain(slug) {
|
|
5805
|
+
const i = slug.indexOf("-");
|
|
5806
|
+
return i === -1 ? slug : slug.slice(i + 1);
|
|
5807
|
+
}
|
|
5808
|
+
function expectedHosts(cfg) {
|
|
5809
|
+
const out = [];
|
|
5810
|
+
for (const sub of cfg.subdomains) {
|
|
5811
|
+
for (const domain of cfg.domains) {
|
|
5812
|
+
const base = sub ? `${sub}.${domain}` : domain;
|
|
5813
|
+
for (const env of ENV_PREFIXES) out.push(env ? `${env}.${base}` : base);
|
|
5814
|
+
}
|
|
5815
|
+
}
|
|
5816
|
+
return uniq(out);
|
|
5817
|
+
}
|
|
5818
|
+
function expectedJsOrigins(cfg) {
|
|
5819
|
+
return uniq([...expectedHosts(cfg).map((h) => `https://${h}`), ...LOOPBACK]);
|
|
5820
|
+
}
|
|
5821
|
+
function expectedRedirectUris(cfg) {
|
|
5822
|
+
const { callbackPath } = cfg;
|
|
5823
|
+
return uniq([
|
|
5824
|
+
...expectedHosts(cfg).map((h) => `https://${h}${callbackPath}`),
|
|
5825
|
+
...LOOPBACK.map((l) => `${l}${callbackPath}`)
|
|
5826
|
+
]);
|
|
5827
|
+
}
|
|
5828
|
+
function oauthSsmKeys() {
|
|
5829
|
+
return SSM_ENVS.flatMap((env) => SSM_NAMES.map((name) => `${env}/${name}`));
|
|
5830
|
+
}
|
|
5831
|
+
function parseOauthConfig(mmiConfig, slug) {
|
|
5832
|
+
const raw = mmiConfig?.oauth ?? {};
|
|
5833
|
+
const subdomains = Array.isArray(raw.subdomains) && raw.subdomains.length > 0 ? raw.subdomains.map(String) : [defaultSubdomain(slug)];
|
|
5834
|
+
const domains = Array.isArray(raw.domains) && raw.domains.length > 0 ? raw.domains.map(String) : [...DEFAULT_DOMAINS];
|
|
5835
|
+
const callbackPath = typeof raw.callbackPath === "string" && raw.callbackPath ? raw.callbackPath : DEFAULT_CALLBACK_PATH;
|
|
5836
|
+
if (!callbackPath.startsWith("/")) {
|
|
5837
|
+
throw new Error(`oauth.callbackPath must start with "/" (got ${JSON.stringify(callbackPath)})`);
|
|
5838
|
+
}
|
|
5839
|
+
return { subdomains, domains, callbackPath };
|
|
5840
|
+
}
|
|
5841
|
+
function probeRedirectUri(callbackPath, port = 9123) {
|
|
5842
|
+
return `http://localhost:${port}${callbackPath}`;
|
|
5843
|
+
}
|
|
5844
|
+
function buildAuthorizeProbeUrl(clientId, redirectUri) {
|
|
5845
|
+
const qs = new URLSearchParams({
|
|
5846
|
+
client_id: clientId,
|
|
5847
|
+
redirect_uri: redirectUri,
|
|
5848
|
+
response_type: "code",
|
|
5849
|
+
scope: "openid email",
|
|
5850
|
+
access_type: "offline",
|
|
5851
|
+
prompt: "consent"
|
|
5852
|
+
});
|
|
5853
|
+
return `https://accounts.google.com/o/oauth2/v2/auth?${qs.toString()}`;
|
|
5854
|
+
}
|
|
5855
|
+
function authorizeBodyHasMismatch(body) {
|
|
5856
|
+
return /redirect_uri_mismatch/i.test(body);
|
|
5857
|
+
}
|
|
5858
|
+
|
|
5264
5859
|
// src/index.ts
|
|
5265
5860
|
var rawExecFileP2 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
|
|
5266
5861
|
var execFileP3 = (file, args, options = {}) => (
|
|
@@ -5288,16 +5883,58 @@ async function githubLogin() {
|
|
|
5288
5883
|
return void 0;
|
|
5289
5884
|
}
|
|
5290
5885
|
}
|
|
5886
|
+
async function awsCallerArn() {
|
|
5887
|
+
try {
|
|
5888
|
+
const { stdout } = await execFileP3(
|
|
5889
|
+
"aws",
|
|
5890
|
+
["sts", "get-caller-identity", "--query", "Arn", "--output", "text"],
|
|
5891
|
+
{ timeout: GIT_TIMEOUT_MS }
|
|
5892
|
+
);
|
|
5893
|
+
return stdout.trim() || void 0;
|
|
5894
|
+
} catch {
|
|
5895
|
+
return void 0;
|
|
5896
|
+
}
|
|
5897
|
+
}
|
|
5291
5898
|
async function sagaHeaders(extra = {}) {
|
|
5292
5899
|
const t = await githubToken();
|
|
5293
5900
|
return t ? { ...extra, Authorization: `Bearer ${t}` } : extra;
|
|
5294
5901
|
}
|
|
5295
5902
|
async function loadConfig() {
|
|
5903
|
+
let file = {};
|
|
5296
5904
|
try {
|
|
5297
|
-
|
|
5905
|
+
file = JSON.parse(await (0, import_promises.readFile)(".mmi/config.json", "utf8"));
|
|
5298
5906
|
} catch {
|
|
5299
|
-
|
|
5300
|
-
}
|
|
5907
|
+
file = {};
|
|
5908
|
+
}
|
|
5909
|
+
if (!file.sagaApiUrl) file.sagaApiUrl = defaultHubUrl();
|
|
5910
|
+
return file;
|
|
5911
|
+
}
|
|
5912
|
+
var discoveredConfig = null;
|
|
5913
|
+
async function loadConfigOrDiscover() {
|
|
5914
|
+
if (discoveredConfig) return discoveredConfig;
|
|
5915
|
+
const floor = await loadConfig();
|
|
5916
|
+
if (floor.projectId && floor.statusFieldId && floor.statusOptions) {
|
|
5917
|
+
discoveredConfig = floor;
|
|
5918
|
+
return floor;
|
|
5919
|
+
}
|
|
5920
|
+
if (!floor.sagaApiUrl) return floor;
|
|
5921
|
+
const meta = await fetchProjectBySlug(await repoSlug(), { baseUrl: floor.sagaApiUrl, token: githubToken });
|
|
5922
|
+
if (!meta) return floor;
|
|
5923
|
+
discoveredConfig = {
|
|
5924
|
+
projectOwner: meta.projectOwner,
|
|
5925
|
+
projectNumber: meta.projectNumber,
|
|
5926
|
+
projectId: meta.projectId,
|
|
5927
|
+
statusFieldId: meta.statusFieldId,
|
|
5928
|
+
statusOptions: meta.statusOptions,
|
|
5929
|
+
priorityFieldId: meta.priorityFieldId,
|
|
5930
|
+
priorityOptions: meta.priorityOptions,
|
|
5931
|
+
...floor
|
|
5932
|
+
};
|
|
5933
|
+
return discoveredConfig;
|
|
5934
|
+
}
|
|
5935
|
+
async function repoSlug() {
|
|
5936
|
+
const remote = await gitOut(["remote", "get-url", "origin"]);
|
|
5937
|
+
return (remote.replace(/\.git$/, "").split("/").pop() || "-").toLowerCase();
|
|
5301
5938
|
}
|
|
5302
5939
|
var DEFAULT_RULES_SOURCE = "https://raw.githubusercontent.com/mutmutco/MMI-Hub/development";
|
|
5303
5940
|
var SESSION_FILE = ".mmi/.session";
|
|
@@ -5458,11 +6095,8 @@ function readRepoVersion() {
|
|
|
5458
6095
|
}
|
|
5459
6096
|
async function fetchReleasedVersion() {
|
|
5460
6097
|
try {
|
|
5461
|
-
const
|
|
5462
|
-
|
|
5463
|
-
});
|
|
5464
|
-
if (!res.ok) return void 0;
|
|
5465
|
-
return (await res.json()).version;
|
|
6098
|
+
const { stdout } = await execFileP3("gh", pluginManifestVersionArgs(), { timeout: 5e3 });
|
|
6099
|
+
return parseManifestVersion(stdout);
|
|
5466
6100
|
} catch {
|
|
5467
6101
|
return void 0;
|
|
5468
6102
|
}
|
|
@@ -5501,7 +6135,7 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
|
|
|
5501
6135
|
if (!opts.quiet) console.log('mmi-cli rules: source repo (orgRulesSource: "self") \u2014 skipping self-sync');
|
|
5502
6136
|
return;
|
|
5503
6137
|
}
|
|
5504
|
-
const base = (cfg.orgRulesSource
|
|
6138
|
+
const base = resolveRulesBase(cfg.orgRulesSource, DEFAULT_RULES_SOURCE);
|
|
5505
6139
|
const token = await githubToken();
|
|
5506
6140
|
let changed = 0;
|
|
5507
6141
|
for (const file of ["AGENTS.md", "CLAUDE.md", ".claude/settings.json"]) {
|
|
@@ -5567,7 +6201,10 @@ saga.command("show").option("--quiet", "no-op silently when unconfigured/unreach
|
|
|
5567
6201
|
const key = await sagaKey(cfg);
|
|
5568
6202
|
const qs = opts.latestAnywhere ? "scope=anywhere" : new URLSearchParams({ project: key.project, branch: key.branch }).toString();
|
|
5569
6203
|
const res = await fetch(`${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
|
|
5570
|
-
if (res.ok)
|
|
6204
|
+
if (res.ok) {
|
|
6205
|
+
console.log(resumeCue());
|
|
6206
|
+
return console.log(await res.text());
|
|
6207
|
+
}
|
|
5571
6208
|
if (!opts.quiet) console.log(`saga show failed: HTTP ${res.status}`);
|
|
5572
6209
|
} catch (e) {
|
|
5573
6210
|
if (!opts.quiet) console.error(`saga show: ${e.message}`);
|
|
@@ -5712,7 +6349,7 @@ async function resolveRepo(repo) {
|
|
|
5712
6349
|
}
|
|
5713
6350
|
}
|
|
5714
6351
|
async function attachToProject(issueNumber, repo, priority) {
|
|
5715
|
-
const cfg = await
|
|
6352
|
+
const cfg = await loadConfigOrDiscover();
|
|
5716
6353
|
if (!cfg.projectId) return void 0;
|
|
5717
6354
|
if (repo) {
|
|
5718
6355
|
const skip = boardAttachSkipReason(await resolveRepo(), repo);
|
|
@@ -5825,17 +6462,27 @@ async function withPlan(quiet, run) {
|
|
|
5825
6462
|
}
|
|
5826
6463
|
await run(makePlanDeps(cfg));
|
|
5827
6464
|
}
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5832
|
-
|
|
5833
|
-
(
|
|
5834
|
-
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
);
|
|
5838
|
-
|
|
6465
|
+
function registerNorthStarCommands(cmd) {
|
|
6466
|
+
cmd.command("push <slug>").description("push a local North Star plan (plans/<slug>.md) to the server").option("--project <name>", "override the project key").option("--force", "overwrite the remote even if it changed since your last sync").action((slug, o) => withPlan(false, async (d) => {
|
|
6467
|
+
await planPush(d, slug, o);
|
|
6468
|
+
}));
|
|
6469
|
+
cmd.command("pull <slug>").description("pull a North Star plan from the server into plans/<slug>.md").option("--project <name>", "override the project key").option("--force", "overwrite local even if it has unpushed edits").action((slug, o) => withPlan(false, (d) => planPull(d, slug, o)));
|
|
6470
|
+
cmd.command("list").description("list your North Star plans (cross-device)").option("--project <name>", "filter by project").option("--json", "machine-readable output").option("--quiet", "silent when unconfigured/empty/unreachable (SessionStart hook)").action((o) => withPlan(o.quiet ?? false, (d) => planList(d, o)));
|
|
6471
|
+
cmd.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
|
|
6472
|
+
(slug, o) => withPlan(false, async (d) => {
|
|
6473
|
+
await planPull(d, slug, { project: o.project });
|
|
6474
|
+
openInEditor(planPath(slug));
|
|
6475
|
+
})
|
|
6476
|
+
);
|
|
6477
|
+
cmd.command("delete <slug>").description("delete a North Star plan from the server and the local copy").option("--project <name>", "override the project key").action((slug, o) => withPlan(false, (d) => planDelete(d, slug, o)));
|
|
6478
|
+
cmd.command("graduate <slug>").description("mark a built-and-merged North Star plan as org-visible and push it").requiredOption("--merged-pr <url|number>", "merged PR URL or number proving the plan shipped").option("--org-visible", "confirm this plan is safe to queue for org KB curation").option("--project <name>", "override the project key").option("--force", "overwrite the remote even if it changed since your last sync").action(
|
|
6479
|
+
(slug, o) => withPlan(false, (d) => planGraduate(d, slug, o))
|
|
6480
|
+
);
|
|
6481
|
+
}
|
|
6482
|
+
var northstar = program2.command("northstar").description("North Star \u2014 your cross-device plans/SSOTs (S3-backed, git-clean)");
|
|
6483
|
+
registerNorthStarCommands(northstar);
|
|
6484
|
+
var plan = program2.command("plan").description("Alias for `northstar` (kept for compatibility)");
|
|
6485
|
+
registerNorthStarCommands(plan);
|
|
5839
6486
|
async function readSecretStdin() {
|
|
5840
6487
|
if (process.stdin.isTTY) {
|
|
5841
6488
|
process.stderr.write(
|
|
@@ -5867,6 +6514,7 @@ async function withSecrets(run) {
|
|
|
5867
6514
|
await run(makeSecretsDeps(cfg));
|
|
5868
6515
|
}
|
|
5869
6516
|
var secrets = program2.command("secrets").description("two-tier project secrets \u2014 self-serve your repo dev/* tier; org tier is master-gated");
|
|
6517
|
+
secrets.command("where").description("print where this repo\u2019s secrets live \u2014 the two-tier vault layout + well-known keys (no values)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((o) => withSecrets((d) => secretsWhere(d, o)));
|
|
5870
6518
|
secrets.command("list").description("list secret NAMES + tier for this repo (never values)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((o) => withSecrets((d) => secretsList(d, o)));
|
|
5871
6519
|
secrets.command("get <key>").description("print one secret value over TLS (prints once, raw \u2014 do not log/paste it)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsGet(d, key, o)));
|
|
5872
6520
|
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((d) => secretsSet(d, key, o)));
|
|
@@ -5875,6 +6523,140 @@ secrets.command("rm <key>").description("remove a secret (project tier self-serv
|
|
|
5875
6523
|
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)));
|
|
5876
6524
|
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, {})));
|
|
5877
6525
|
secrets.command("revoke <repo> <login> <key>").description("MASTER-ONLY: withdraw a previously granted org-tier secret access").action((repo, login, key) => withSecrets((d) => secretsRevoke(d, repo, login, key, {})));
|
|
6526
|
+
function registryClientDeps(cfg) {
|
|
6527
|
+
return { baseUrl: cfg.sagaApiUrl, token: githubToken };
|
|
6528
|
+
}
|
|
6529
|
+
function slugOf(repoOrSlug) {
|
|
6530
|
+
return (repoOrSlug.includes("/") ? repoOrSlug.split("/").pop() : repoOrSlug).toLowerCase();
|
|
6531
|
+
}
|
|
6532
|
+
function reportWrite(label, res) {
|
|
6533
|
+
if (res.ok) {
|
|
6534
|
+
console.log(JSON.stringify(res.body));
|
|
6535
|
+
return;
|
|
6536
|
+
}
|
|
6537
|
+
if (res.error) return fail(`${label}: ${res.error}`);
|
|
6538
|
+
const detail = res.body?.error ?? "";
|
|
6539
|
+
fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
|
|
6540
|
+
}
|
|
6541
|
+
var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); set is master-only");
|
|
6542
|
+
project.command("list").description("list all projects (identity + board, never deploy coords)").option("--json", "machine-readable output").action(async (o) => {
|
|
6543
|
+
const cfg = await loadConfig();
|
|
6544
|
+
const projects = await fetchProjectsList(registryClientDeps(cfg));
|
|
6545
|
+
if (!projects) return fail("project list: Hub API unreachable or this repo is not bootstrapped");
|
|
6546
|
+
if (o.json) {
|
|
6547
|
+
console.log(JSON.stringify(projects));
|
|
6548
|
+
return;
|
|
6549
|
+
}
|
|
6550
|
+
for (const p of projects) {
|
|
6551
|
+
console.log(`${p.slug ?? "?"} - ${p.name ?? ""}${p.division ? ` [${p.division}]` : ""}${p.class ? ` (${p.class})` : ""}`);
|
|
6552
|
+
}
|
|
6553
|
+
});
|
|
6554
|
+
project.command("get <owner/repo>").description("a project's META (board ids + pointers) by repo or slug \u2014 identity, NOT deploy coords").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
|
|
6555
|
+
const cfg = await loadConfig();
|
|
6556
|
+
const meta = await fetchProjectBySlug(slugOf(repoOrSlug), registryClientDeps(cfg));
|
|
6557
|
+
if (!meta) return fail(`project get: no registry META for ${repoOrSlug} (unknown, unbootstrapped, or Hub unreachable)`);
|
|
6558
|
+
console.log(JSON.stringify(meta));
|
|
6559
|
+
});
|
|
6560
|
+
project.command("resolve <owner/repo>").description("deploy coords for a stage \u2014 for diagnosis. NOTE: /deploy-coords is OIDC-gated (a deploy job\u2019s id-token), so a gh-token CLI cannot read it from a dev machine").option("--stage <main|rc>", "deploy stage", "main").option("--json", "machine-readable output").action((_repoOrRepo, o) => {
|
|
6561
|
+
const msg = "project resolve: deploy coords are served only to a deploy workflow (GitHub OIDC id-token, repo-scoped). A gh-token CLI on a dev machine cannot read /deploy-coords; inspect the DEPLOY# item via the AWS console / a master DDB read instead.";
|
|
6562
|
+
if (o.json) {
|
|
6563
|
+
console.log(JSON.stringify({ ok: false, stage: o.stage, error: msg }));
|
|
6564
|
+
process.exitCode = 1;
|
|
6565
|
+
return;
|
|
6566
|
+
}
|
|
6567
|
+
fail(msg);
|
|
6568
|
+
});
|
|
6569
|
+
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("--var <KEY=VALUE...>", "META field to set (repeatable): name, division, projectId, branch, wikiRepo, vaultPath, kbPointer").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
|
|
6570
|
+
const cfg = await loadConfig();
|
|
6571
|
+
const slug = slugOf(repoOrSlug);
|
|
6572
|
+
const patch = {};
|
|
6573
|
+
if (o.class) {
|
|
6574
|
+
if (o.class !== "deployable" && o.class !== "content") return fail("project set: --class must be deployable or content");
|
|
6575
|
+
patch.class = o.class;
|
|
6576
|
+
}
|
|
6577
|
+
for (let i = 0; i < process.argv.length - 1; i++) {
|
|
6578
|
+
if (process.argv[i] === "--var") {
|
|
6579
|
+
const eq = process.argv[i + 1].indexOf("=");
|
|
6580
|
+
if (eq > 0) patch[process.argv[i + 1].slice(0, eq)] = process.argv[i + 1].slice(eq + 1);
|
|
6581
|
+
}
|
|
6582
|
+
}
|
|
6583
|
+
if (Object.keys(patch).length === 0) return fail("project set: nothing to set \u2014 pass --class and/or --var KEY=VALUE");
|
|
6584
|
+
const res = await upsertProject(slug, patch, registryClientDeps(cfg));
|
|
6585
|
+
reportWrite("project set", res);
|
|
6586
|
+
});
|
|
6587
|
+
var registry = program2.command("registry").description("the DDB org registry \u2014 org-level constants");
|
|
6588
|
+
registry.command("org").description("the org config (account id, region, orgProjectId, sagaApiUrl)").option("--json", "machine-readable output").action(async (_o) => {
|
|
6589
|
+
const cfg = await loadConfig();
|
|
6590
|
+
const org = await fetchOrgConfig(registryClientDeps(cfg));
|
|
6591
|
+
if (!org) return fail("registry org: Hub API unreachable, unseeded, or this repo is not bootstrapped");
|
|
6592
|
+
console.log(JSON.stringify(org));
|
|
6593
|
+
});
|
|
6594
|
+
var oauth = program2.command("oauth").description("per-repo Google OAuth \u2014 plan the canonical URI set, verify the client is port-agnostic");
|
|
6595
|
+
oauth.command("plan", { isDefault: true }).description("print the canonical JS origins + redirect URIs + SSM cred param names for this repo").option("--repo <owner/repo>", "slug source (defaults to the current repo)").option("--json", "machine-readable output").action(async (o) => {
|
|
6596
|
+
const cfg = await loadConfig();
|
|
6597
|
+
const slug = (o.repo ? o.repo.split("/").pop() : cfg.project ?? await repoSlug()).toLowerCase();
|
|
6598
|
+
const meta = await fetchProjectBySlug(slug, registryClientDeps(cfg));
|
|
6599
|
+
let oc;
|
|
6600
|
+
try {
|
|
6601
|
+
oc = parseOauthConfig(meta ?? {}, slug);
|
|
6602
|
+
} catch (e) {
|
|
6603
|
+
return fail(`oauth plan: ${e.message}`);
|
|
6604
|
+
}
|
|
6605
|
+
const origins = expectedJsOrigins(oc);
|
|
6606
|
+
const redirects = expectedRedirectUris(oc);
|
|
6607
|
+
const ssm = oauthSsmKeys();
|
|
6608
|
+
if (o.json) {
|
|
6609
|
+
console.log(JSON.stringify({ slug, oauth: oc, jsOrigins: origins, redirectUris: redirects, ssmKeys: ssm }, null, 2));
|
|
6610
|
+
return;
|
|
6611
|
+
}
|
|
6612
|
+
console.log(`OAuth plan for ${slug} (callback ${oc.callbackPath}):
|
|
6613
|
+
`);
|
|
6614
|
+
console.log("Authorized JavaScript origins:");
|
|
6615
|
+
origins.forEach((u) => console.log(` ${u}`));
|
|
6616
|
+
console.log("\nAuthorized redirect URIs:");
|
|
6617
|
+
redirects.forEach((u) => console.log(` ${u}`));
|
|
6618
|
+
console.log(`
|
|
6619
|
+
SSM cred params (under /mmi-future/${slug}/):`);
|
|
6620
|
+
ssm.forEach((k) => console.log(` ${k}`));
|
|
6621
|
+
console.log("\nProvision/repair the Console client per docs/Guides/oauth-provision.md; creds via `mmi-cli secrets set`.");
|
|
6622
|
+
});
|
|
6623
|
+
oauth.command("verify").description("probe Google authorize with an arbitrary port (:9123) to confirm the client is port-agnostic").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--client-id <id>", "OAuth client_id (else read dev/GOOGLE_CLIENT_ID from SSM)").option("--json", "machine-readable output").action(async (o) => {
|
|
6624
|
+
const cfg = await loadConfig();
|
|
6625
|
+
const slug = (o.repo ? o.repo.split("/").pop() : cfg.project ?? await repoSlug()).toLowerCase();
|
|
6626
|
+
const meta = await fetchProjectBySlug(slug, registryClientDeps(cfg));
|
|
6627
|
+
let oc;
|
|
6628
|
+
try {
|
|
6629
|
+
oc = parseOauthConfig(meta ?? {}, slug);
|
|
6630
|
+
} catch (e) {
|
|
6631
|
+
return fail(`oauth verify: ${e.message}`);
|
|
6632
|
+
}
|
|
6633
|
+
let clientId = o.clientId;
|
|
6634
|
+
if (!clientId) {
|
|
6635
|
+
await withSecrets(async (d) => {
|
|
6636
|
+
clientId = await fetchSecretValue(d, "dev/GOOGLE_CLIENT_ID", { repo: o.repo }) ?? void 0;
|
|
6637
|
+
});
|
|
6638
|
+
}
|
|
6639
|
+
if (!clientId) {
|
|
6640
|
+
return fail("oauth verify: no client_id (pass --client-id, or provision the repo so dev/GOOGLE_CLIENT_ID exists)");
|
|
6641
|
+
}
|
|
6642
|
+
const redirectUri = probeRedirectUri(oc.callbackPath);
|
|
6643
|
+
let body = "";
|
|
6644
|
+
try {
|
|
6645
|
+
const res = await fetch(buildAuthorizeProbeUrl(clientId, redirectUri), { redirect: "follow" });
|
|
6646
|
+
body = await res.text();
|
|
6647
|
+
} catch (e) {
|
|
6648
|
+
return fail(`oauth verify: probe request failed: ${e.message}`);
|
|
6649
|
+
}
|
|
6650
|
+
const mismatch = authorizeBodyHasMismatch(body);
|
|
6651
|
+
if (o.json) {
|
|
6652
|
+
console.log(JSON.stringify({ slug, redirectUri, portAgnostic: !mismatch }));
|
|
6653
|
+
} else if (mismatch) {
|
|
6654
|
+
console.error(`FAIL ${slug}: redirect_uri_mismatch for ${redirectUri} \u2014 client is not port-agnostic (run /oauth-provision)`);
|
|
6655
|
+
} else {
|
|
6656
|
+
console.log(`PASS ${slug}: ${redirectUri} accepted \u2014 port-agnostic OAuth is live`);
|
|
6657
|
+
}
|
|
6658
|
+
if (mismatch) process.exitCode = 1;
|
|
6659
|
+
});
|
|
5878
6660
|
var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
|
|
5879
6661
|
issue.command("create").description("create an issue (type \u2192 label) and print {number,url,label} JSON").requiredOption("--type <type>", "bug | feature | task (sets the matching label)").requiredOption("--title <title>", "issue title").requiredOption("--body <body>", "issue body (markdown)").requiredOption("--priority <priority>", "urgent | high | medium | low (label + board Priority field when configured)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--label <label...>", "extra label(s) to attach (repeatable; auto-created if missing)").option("--no-related", "skip the auto related-issues comment").action(async (o) => {
|
|
5880
6662
|
let args;
|
|
@@ -5949,7 +6731,7 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
|
|
|
5949
6731
|
async function runBoardRead(o) {
|
|
5950
6732
|
try {
|
|
5951
6733
|
const report = await readBoard({
|
|
5952
|
-
config: await
|
|
6734
|
+
config: await loadConfigOrDiscover(),
|
|
5953
6735
|
repo: o.repo,
|
|
5954
6736
|
includeBundleDetails: o.bundleDetails,
|
|
5955
6737
|
allowPartial: o.allowPartial
|
|
@@ -5964,7 +6746,7 @@ board.command("read", { isDefault: true }).description("read the board and print
|
|
|
5964
6746
|
board.command("claim <issue>").description("assign a Todo issue and move its Project v2 Status to In Progress").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 (issueRef, o) => {
|
|
5965
6747
|
try {
|
|
5966
6748
|
const result = await claimBoardIssue({
|
|
5967
|
-
config: await
|
|
6749
|
+
config: await loadConfigOrDiscover(),
|
|
5968
6750
|
selector: issueRef,
|
|
5969
6751
|
repo: o.repo,
|
|
5970
6752
|
assignee: o.for,
|
|
@@ -5978,7 +6760,7 @@ board.command("claim <issue>").description("assign a Todo issue and move its Pro
|
|
|
5978
6760
|
});
|
|
5979
6761
|
board.command("show <issue>").alias("open").description("print one board item (status, assignees, type, url) with its body and comments").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--allow-partial", "return the item even if its body/comments fetch fails").action(async (issueRef, o) => {
|
|
5980
6762
|
try {
|
|
5981
|
-
const item = await showBoardItem({ config: await
|
|
6763
|
+
const item = await showBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, repo: o.repo, allowPartial: o.allowPartial });
|
|
5982
6764
|
console.log(o.json ? JSON.stringify(item) : renderBoardItem(item));
|
|
5983
6765
|
} catch (e) {
|
|
5984
6766
|
fail(`board show failed: ${e.message}`);
|
|
@@ -5989,7 +6771,7 @@ board.command("move <issue> <status>").description(`move a board item's Status t
|
|
|
5989
6771
|
return fail(`board move failed: unknown status '${status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
|
|
5990
6772
|
}
|
|
5991
6773
|
try {
|
|
5992
|
-
const result = await moveBoardItem({ config: await
|
|
6774
|
+
const result = await moveBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, status, repo: o.repo, allowPartial: o.allowPartial });
|
|
5993
6775
|
if (o.json) return console.log(JSON.stringify(result));
|
|
5994
6776
|
console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> ${result.status}`);
|
|
5995
6777
|
} catch (e) {
|
|
@@ -5999,7 +6781,7 @@ board.command("move <issue> <status>").description(`move a board item's Status t
|
|
|
5999
6781
|
board.command("backfill-priority").description("set board Priority from priority:* labels or issue timeline for items missing the field").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--dry-run", "report what would be set without writing").option("--concurrency <n>", "parallel timeline reads (default 8)", "8").action(async (o) => {
|
|
6000
6782
|
try {
|
|
6001
6783
|
const result = await backfillBoardPriorities({
|
|
6002
|
-
config: await
|
|
6784
|
+
config: await loadConfigOrDiscover(),
|
|
6003
6785
|
repo: o.repo,
|
|
6004
6786
|
dryRun: o.dryRun,
|
|
6005
6787
|
concurrency: Number(o.concurrency) || 8
|
|
@@ -6015,7 +6797,7 @@ board.command("backfill-priority").description("set board Priority from priority
|
|
|
6015
6797
|
});
|
|
6016
6798
|
board.command("done <issue>").description("set a board item's Status to Done (does not close the GitHub issue; use `gh issue close`)").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--allow-partial", "return success JSON if the item resolves but the status move fails").action(async (issueRef, o) => {
|
|
6017
6799
|
try {
|
|
6018
|
-
const result = await moveBoardItem({ config: await
|
|
6800
|
+
const result = await moveBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, status: "Done", repo: o.repo, allowPartial: o.allowPartial });
|
|
6019
6801
|
if (o.json) return console.log(JSON.stringify(result));
|
|
6020
6802
|
console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> Done`);
|
|
6021
6803
|
} catch (e) {
|
|
@@ -6042,9 +6824,15 @@ function printLine(value) {
|
|
|
6042
6824
|
function stageKeepAlive() {
|
|
6043
6825
|
return setTimeout(() => void 0, 5 * 60 * 1e3);
|
|
6044
6826
|
}
|
|
6045
|
-
program2.command("port-range <repo>").description("assign (idempotently) + print the repo's local stage port block
|
|
6827
|
+
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) => {
|
|
6046
6828
|
const path = (0, import_node_path4.join)(process.cwd(), "infra", "port-ranges.json");
|
|
6047
|
-
const
|
|
6829
|
+
const allocate = async (seed) => {
|
|
6830
|
+
const { stdout } = await execFileP3("node", [(0, import_node_path4.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
|
|
6831
|
+
const parsed = JSON.parse(stdout);
|
|
6832
|
+
if (!Array.isArray(parsed.range) || parsed.range.length !== 2) throw new Error("port-ddb: no range in output");
|
|
6833
|
+
return parsed.range;
|
|
6834
|
+
};
|
|
6835
|
+
const { range: [start, end] } = await ensurePortRangeAtomic(repo, path, allocate);
|
|
6048
6836
|
printLine(o.json ? JSON.stringify({ repo, portRange: [start, end] }) : `${repo}: stage.portRange [${start}, ${end}]`);
|
|
6049
6837
|
});
|
|
6050
6838
|
var stage = program2.command("stage").description("plan or run the repo local stage environment").option("--json", "machine-readable output").option("--apply", "run the full local stage: stop previous, build, start, health-check").option("--timeout-ms <ms>", "bounded build/health timeout", "60000").action(async (o) => {
|
|
@@ -6129,9 +6917,14 @@ stage.command("run").description("force-stop previous stage, build, start, and h
|
|
|
6129
6917
|
fail(`stage run: ${e.message}`);
|
|
6130
6918
|
}
|
|
6131
6919
|
});
|
|
6920
|
+
program2.command("stage-live").description("explain that remote rc/live environments use /rcand, /release, and /hotfix; /stage is local only").option("--json", "machine-readable output").option("--apply", "always refused; there is no stage-live mutation path").action((o) => {
|
|
6921
|
+
if (o.apply) return fail("stage-live: not an org command; use mmi-cli stage for local tests, or the gated rc/release/hotfix train for remote environments");
|
|
6922
|
+
const steps = stageLivePlan();
|
|
6923
|
+
console.log(o.json ? JSON.stringify({ command: "stage-live", steps }, null, 2) : renderSteps("mmi-cli stage-live: not an org command", steps));
|
|
6924
|
+
});
|
|
6132
6925
|
for (const commandName of ["rc", "release", "hotfix"]) {
|
|
6133
6926
|
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit approval`).option("--json", "machine-readable output").option("--apply", "reserved for future train execution after explicit admin approval").action((o) => {
|
|
6134
|
-
if (o.apply) return fail(`${commandName}: execution is not implemented yet; use the dry-run plan and the existing /${commandName} skill`);
|
|
6927
|
+
if (o.apply) return fail(`${commandName}: execution is not implemented yet; use the dry-run plan and the existing /${commandName === "rc" ? "rcand" : commandName} skill`);
|
|
6135
6928
|
const steps = trainPlan(commandName);
|
|
6136
6929
|
console.log(o.json ? JSON.stringify({ command: commandName, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps));
|
|
6137
6930
|
});
|
|
@@ -6146,9 +6939,11 @@ var bootstrap = program2.command("bootstrap").description("plan repo bootstrap o
|
|
|
6146
6939
|
bootstrap.command("verify <repo>").description("audit whether an existing repo is bootstrapped correctly; no mutations").option("--class <class>", "deployable | content", "deployable").option("--json", "machine-readable output").action(async (repo) => {
|
|
6147
6940
|
const o = { class: rawValue("--class", "deployable"), json: rawFlag("--json") };
|
|
6148
6941
|
if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
|
|
6942
|
+
const cfg = await loadConfig();
|
|
6943
|
+
const apiProjects = await fetchProjectsJson({ baseUrl: cfg.sagaApiUrl, token: githubToken });
|
|
6149
6944
|
const report = await verifyBootstrap(repo, o.class, {
|
|
6150
6945
|
gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
|
|
6151
|
-
readLocalFile: (path) => (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : null
|
|
6946
|
+
readLocalFile: (path) => path === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : null
|
|
6152
6947
|
});
|
|
6153
6948
|
console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
|
|
6154
6949
|
if (!report.ok) process.exitCode = 1;
|
|
@@ -6178,12 +6973,17 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
6178
6973
|
const resolved = { ...seed, target: seed.target.replace("{{REPO_SLUG}}", slug) };
|
|
6179
6974
|
let exists = false;
|
|
6180
6975
|
let sha;
|
|
6976
|
+
let remoteContent = null;
|
|
6181
6977
|
if (resolved.source !== "fanout") {
|
|
6182
6978
|
try {
|
|
6183
6979
|
const r = await gh(["api", `repos/${repo}/contents/${enc(resolved.target)}?ref=${baseBranch}`]);
|
|
6184
6980
|
exists = true;
|
|
6185
6981
|
try {
|
|
6186
|
-
|
|
6982
|
+
const parsed = JSON.parse(r.stdout);
|
|
6983
|
+
sha = parsed.sha;
|
|
6984
|
+
if (parsed.encoding === "base64" && typeof parsed.content === "string") {
|
|
6985
|
+
remoteContent = Buffer.from(parsed.content, "base64").toString("utf8");
|
|
6986
|
+
}
|
|
6187
6987
|
} catch {
|
|
6188
6988
|
}
|
|
6189
6989
|
} catch {
|
|
@@ -6193,15 +6993,18 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
6193
6993
|
const action = planSeedAction(resolved, exists);
|
|
6194
6994
|
actions.push(action);
|
|
6195
6995
|
if (o.execute && (action.action === "create" || action.action === "update")) {
|
|
6196
|
-
const
|
|
6996
|
+
const isBlock = resolved.source === "managed-block";
|
|
6997
|
+
const content = isBlock ? upsertManagedGitignoreBlock(remoteContent).content : resolveSeedContent(resolved, vars, readFile2);
|
|
6197
6998
|
if (content == null) {
|
|
6198
6999
|
applied.push(`skip ${resolved.target} (no resolvable content)`);
|
|
6199
7000
|
continue;
|
|
6200
7001
|
}
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
|
|
7002
|
+
if (!isBlock) {
|
|
7003
|
+
const missing = missingPlaceholders(content);
|
|
7004
|
+
if (missing.length) {
|
|
7005
|
+
applied.push(`skip ${resolved.target} (unfilled: ${missing.join(", ")} \u2014 pass --var)`);
|
|
7006
|
+
continue;
|
|
7007
|
+
}
|
|
6205
7008
|
}
|
|
6206
7009
|
await gh(contentPutArgs(repo, resolved.target, content, baseBranch, action.action === "update" ? sha : void 0));
|
|
6207
7010
|
applied.push(`${action.action} ${resolved.target}`);
|
|
@@ -6217,7 +7020,20 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
6217
7020
|
}
|
|
6218
7021
|
}
|
|
6219
7022
|
}
|
|
6220
|
-
|
|
7023
|
+
const ddbWrites = [];
|
|
7024
|
+
const registerPayload = buildRegisterPayload(repo, o.class, vars);
|
|
7025
|
+
if (o.execute) {
|
|
7026
|
+
const cfg = await loadConfig();
|
|
7027
|
+
const res = await registerProject(registerPayload, { baseUrl: cfg.sagaApiUrl, token: githubToken });
|
|
7028
|
+
if (res.ok) {
|
|
7029
|
+
ddbWrites.push({ slug: registerPayload.slug, action: "register", record: registerPayload });
|
|
7030
|
+
applied.push(`ddb register ${registerPayload.slug}`);
|
|
7031
|
+
} else {
|
|
7032
|
+
const why = res.error ?? `HTTP ${res.status}${res.body?.error ? ` \u2014 ${res.body.error}` : ""}`;
|
|
7033
|
+
applied.push(`ddb register ${registerPayload.slug} (failed: ${why})`);
|
|
7034
|
+
}
|
|
7035
|
+
}
|
|
7036
|
+
if (o.json) console.log(JSON.stringify({ repo, class: o.class, execute: o.execute, actions, applied, ddbWrites }, null, 2));
|
|
6221
7037
|
else {
|
|
6222
7038
|
console.log(renderSeedPlan(actions));
|
|
6223
7039
|
if (o.execute) console.log(`
|
|
@@ -6230,21 +7046,77 @@ access.command("audit").description("audit collaborator roles + train-branch pus
|
|
|
6230
7046
|
const o = { json: rawFlag("--json"), repo: rawValue("--repo", ""), class: rawValue("--class", "deployable") };
|
|
6231
7047
|
const deps = { gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }) };
|
|
6232
7048
|
let targets;
|
|
7049
|
+
const cfg = await loadConfig();
|
|
7050
|
+
const registryProjects = await fetchProjectsList(registryClientDeps(cfg));
|
|
6233
7051
|
if (o.repo) {
|
|
6234
7052
|
if (o.class !== "deployable" && o.class !== "content") return fail("access audit: --class must be deployable or content");
|
|
6235
7053
|
targets = [{ repo: o.repo, class: o.class }];
|
|
6236
7054
|
} else {
|
|
6237
|
-
|
|
7055
|
+
const projectsJson = registryProjects ? JSON.stringify({ projects: registryProjects }) : (0, import_node_fs4.existsSync)("projects.json") ? (0, import_node_fs4.readFileSync)("projects.json", "utf8") : null;
|
|
7056
|
+
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>");
|
|
6238
7057
|
const fanoutJson = (0, import_node_fs4.existsSync)(".github/fanout-targets.json") ? (0, import_node_fs4.readFileSync)(".github/fanout-targets.json", "utf8") : null;
|
|
6239
|
-
targets = loadAccessTargets(
|
|
7058
|
+
targets = loadAccessTargets(projectsJson, fanoutJson);
|
|
6240
7059
|
}
|
|
6241
|
-
const
|
|
6242
|
-
const
|
|
7060
|
+
const derivedMatrix = registryProjects ? accessMatrixFromProjects(registryProjects) : {};
|
|
7061
|
+
const matrix = Object.keys(derivedMatrix).length ? derivedMatrix : (0, import_node_fs4.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs4.readFileSync)("access-matrix.json", "utf8")) : {};
|
|
7062
|
+
const derivedContracts = registryProjects ? dataAccessContractsFromProjects(registryProjects) : { consumers: {} };
|
|
7063
|
+
const dataAccess = Object.keys(derivedContracts.consumers).length ? derivedContracts : (0, import_node_fs4.existsSync)("data-access-contracts.json") ? loadDataAccessContracts((0, import_node_fs4.readFileSync)("data-access-contracts.json", "utf8")) : void 0;
|
|
7064
|
+
const report = await auditOrgAccess(targets, deps, matrix, dataAccess);
|
|
6243
7065
|
console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
|
|
6244
7066
|
if (!report.ok) process.exitCode = 1;
|
|
6245
7067
|
});
|
|
6246
7068
|
var isWin = process.platform === "win32";
|
|
6247
|
-
|
|
7069
|
+
var installedPluginsPath = () => (0, import_node_path4.join)((0, import_node_os.homedir)(), ".claude", "plugins", "installed_plugins.json");
|
|
7070
|
+
function readInstalledPlugins() {
|
|
7071
|
+
try {
|
|
7072
|
+
return JSON.parse((0, import_node_fs4.readFileSync)(installedPluginsPath(), "utf8"));
|
|
7073
|
+
} catch {
|
|
7074
|
+
return null;
|
|
7075
|
+
}
|
|
7076
|
+
}
|
|
7077
|
+
function readClaudeSettings() {
|
|
7078
|
+
try {
|
|
7079
|
+
return JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path4.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
|
|
7080
|
+
} catch {
|
|
7081
|
+
return null;
|
|
7082
|
+
}
|
|
7083
|
+
}
|
|
7084
|
+
function existingMirrorRecord(file) {
|
|
7085
|
+
const records = file?.plugins?.[MMI_PLUGIN_ID];
|
|
7086
|
+
if (!Array.isArray(records) || records.length === 0) return void 0;
|
|
7087
|
+
return records.find((r) => r.scope === "user") ?? records[0];
|
|
7088
|
+
}
|
|
7089
|
+
function writeProjectInstallRecord(record) {
|
|
7090
|
+
try {
|
|
7091
|
+
const file = readInstalledPlugins() ?? { version: 2, plugins: {} };
|
|
7092
|
+
if (!file.plugins) file.plugins = {};
|
|
7093
|
+
const list = file.plugins[MMI_PLUGIN_ID] ?? [];
|
|
7094
|
+
list.push(record);
|
|
7095
|
+
file.plugins[MMI_PLUGIN_ID] = list;
|
|
7096
|
+
(0, import_node_fs4.writeFileSync)(installedPluginsPath(), `${JSON.stringify(file, null, 2)}
|
|
7097
|
+
`, "utf8");
|
|
7098
|
+
return true;
|
|
7099
|
+
} catch {
|
|
7100
|
+
return false;
|
|
7101
|
+
}
|
|
7102
|
+
}
|
|
7103
|
+
var gitignorePath = () => (0, import_node_path4.join)(process.cwd(), ".gitignore");
|
|
7104
|
+
function readGitignore() {
|
|
7105
|
+
try {
|
|
7106
|
+
return (0, import_node_fs4.readFileSync)(gitignorePath(), "utf8");
|
|
7107
|
+
} catch {
|
|
7108
|
+
return null;
|
|
7109
|
+
}
|
|
7110
|
+
}
|
|
7111
|
+
function writeGitignore(content) {
|
|
7112
|
+
try {
|
|
7113
|
+
(0, import_node_fs4.writeFileSync)(gitignorePath(), content, "utf8");
|
|
7114
|
+
return true;
|
|
7115
|
+
} catch {
|
|
7116
|
+
return false;
|
|
7117
|
+
}
|
|
7118
|
+
}
|
|
7119
|
+
program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone, plugin install record, .gitignore managed block) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--json", "machine-readable output").action(async (opts) => {
|
|
6248
7120
|
const checks = [];
|
|
6249
7121
|
const login = await githubLogin();
|
|
6250
7122
|
let ghInstalled = true;
|
|
@@ -6276,6 +7148,7 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
|
|
|
6276
7148
|
checks.push(versionReport);
|
|
6277
7149
|
const cfg = await loadConfig();
|
|
6278
7150
|
checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "repo config (.mmi/config.json)", fix: "ask a master-admin to run /bootstrap on this repo" });
|
|
7151
|
+
checks.push(buildAwsCrossAccountCheck({ callerArn: await awsCallerArn() }));
|
|
6279
7152
|
const REWRITE_KEY = "url.https://github.com/.insteadOf";
|
|
6280
7153
|
const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
|
|
6281
7154
|
let cloneOk = false;
|
|
@@ -6293,6 +7166,29 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
|
|
|
6293
7166
|
}
|
|
6294
7167
|
}
|
|
6295
7168
|
checks.push({ ok: cloneOk, label: "plugin git clone (SSH\u2192HTTPS rewrite)", fix: CLONE_FIX });
|
|
7169
|
+
const installed = readInstalledPlugins();
|
|
7170
|
+
let pluginCheck = buildPluginInstallRecordCheck({
|
|
7171
|
+
isOrgRepo: Boolean(cfg.sagaApiUrl),
|
|
7172
|
+
settings: readClaudeSettings(),
|
|
7173
|
+
installed,
|
|
7174
|
+
projectPath: process.cwd(),
|
|
7175
|
+
mirrorFrom: existingMirrorRecord(installed)
|
|
7176
|
+
});
|
|
7177
|
+
if (!pluginCheck.ok && pluginCheck.recordToInsert && !opts.json) {
|
|
7178
|
+
if (writeProjectInstallRecord(pluginCheck.recordToInsert)) {
|
|
7179
|
+
pluginCheck = { ...pluginCheck, ok: true };
|
|
7180
|
+
if (!opts.banner) console.error(" \u21BB repaired: registered mmi@mmi project install record \u2014 run /reload-plugins to load it this session");
|
|
7181
|
+
}
|
|
7182
|
+
}
|
|
7183
|
+
checks.push(pluginCheck);
|
|
7184
|
+
let gitignoreCheck = buildGitignoreManagedBlockCheck({ isOrgRepo: Boolean(cfg.sagaApiUrl), content: readGitignore() });
|
|
7185
|
+
if (!gitignoreCheck.ok && gitignoreCheck.contentToWrite && !opts.json) {
|
|
7186
|
+
if (writeGitignore(gitignoreCheck.contentToWrite)) {
|
|
7187
|
+
gitignoreCheck = { ...gitignoreCheck, ok: true };
|
|
7188
|
+
if (!opts.banner) console.error(" \u21BB repaired: enforced .gitignore managed block (.playwright-mcp/, .claude/worktrees/, /*.png)");
|
|
7189
|
+
}
|
|
7190
|
+
}
|
|
7191
|
+
checks.push(gitignoreCheck);
|
|
6296
7192
|
const gaps = checks.filter((c) => !c.ok);
|
|
6297
7193
|
if (opts.json) {
|
|
6298
7194
|
console.log(JSON.stringify({ ok: gaps.length === 0, checks }, null, 2));
|