@mutmutco/cli 0.10.0 → 0.12.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 +18 -7
- package/dist/index.cjs +2291 -183
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -3038,7 +3038,7 @@ var {
|
|
|
3038
3038
|
|
|
3039
3039
|
// src/index.ts
|
|
3040
3040
|
var import_promises = require("node:fs/promises");
|
|
3041
|
-
var
|
|
3041
|
+
var import_node_fs4 = require("node:fs");
|
|
3042
3042
|
var import_node_crypto = require("node:crypto");
|
|
3043
3043
|
|
|
3044
3044
|
// src/rules-sync.ts
|
|
@@ -3064,6 +3064,31 @@ 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
|
+
}
|
|
3071
|
+
|
|
3072
|
+
// src/docs-sync.ts
|
|
3073
|
+
var SYNCED_DOCS = ["README.md", "architecture.md"];
|
|
3074
|
+
async function syncDocs(deps, docs2 = SYNCED_DOCS) {
|
|
3075
|
+
const updated = [];
|
|
3076
|
+
const skippedDirty = [];
|
|
3077
|
+
for (const file of docs2) {
|
|
3078
|
+
if (await deps.isDirty(file)) {
|
|
3079
|
+
skippedDirty.push(file);
|
|
3080
|
+
continue;
|
|
3081
|
+
}
|
|
3082
|
+
const origin = await deps.originContent(file);
|
|
3083
|
+
if (origin === null) continue;
|
|
3084
|
+
const local = await deps.localContent(file);
|
|
3085
|
+
if (needsUpdate(origin, local)) {
|
|
3086
|
+
await deps.writeDoc(file, normalizeEol(origin));
|
|
3087
|
+
updated.push(file);
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
return { updated, skippedDirty };
|
|
3091
|
+
}
|
|
3067
3092
|
|
|
3068
3093
|
// src/saga-capture.ts
|
|
3069
3094
|
function parseHookInput(stdin) {
|
|
@@ -3079,6 +3104,7 @@ function parseHookInput(stdin) {
|
|
|
3079
3104
|
var import_node_child_process4 = require("node:child_process");
|
|
3080
3105
|
var import_node_util3 = require("node:util");
|
|
3081
3106
|
var import_node_path4 = require("node:path");
|
|
3107
|
+
var import_node_os = require("node:os");
|
|
3082
3108
|
|
|
3083
3109
|
// src/saga-head-maintainer.ts
|
|
3084
3110
|
var import_node_child_process = require("node:child_process");
|
|
@@ -3194,7 +3220,7 @@ async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
|
|
|
3194
3220
|
|
|
3195
3221
|
// src/gh-create.ts
|
|
3196
3222
|
var ISSUE_TYPES = ["bug", "feature", "task"];
|
|
3197
|
-
var PRIORITIES = ["high", "medium", "low"];
|
|
3223
|
+
var PRIORITIES = ["urgent", "high", "medium", "low"];
|
|
3198
3224
|
function parseCreatedUrl(stdout) {
|
|
3199
3225
|
const re = /https:\/\/github\.com\/[^\s]+\/(?:issues|pull)\/(\d+)/g;
|
|
3200
3226
|
let match;
|
|
@@ -3206,7 +3232,7 @@ function parseCreatedUrl(stdout) {
|
|
|
3206
3232
|
${stdout.trim() || "(empty)"}`);
|
|
3207
3233
|
return last;
|
|
3208
3234
|
}
|
|
3209
|
-
function buildIssueArgs({ type, title, body, priority, repo }) {
|
|
3235
|
+
function buildIssueArgs({ type, title, body, priority, repo, labels }) {
|
|
3210
3236
|
if (!ISSUE_TYPES.includes(type)) throw new Error(`unknown issue type "${type}" \u2014 expected one of: ${ISSUE_TYPES.join(", ")}`);
|
|
3211
3237
|
if (!PRIORITIES.includes(priority)) {
|
|
3212
3238
|
throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${PRIORITIES.join(", ")}`);
|
|
@@ -3214,9 +3240,36 @@ function buildIssueArgs({ type, title, body, priority, repo }) {
|
|
|
3214
3240
|
const args = ["issue", "create"];
|
|
3215
3241
|
if (repo) args.push("--repo", repo);
|
|
3216
3242
|
args.push("--title", title, "--body", body, "--label", type);
|
|
3217
|
-
args.push("--label",
|
|
3243
|
+
for (const label of labels ?? []) args.push("--label", label);
|
|
3218
3244
|
return args;
|
|
3219
3245
|
}
|
|
3246
|
+
function boardAttachSkipReason(cwdRepo, targetRepo2) {
|
|
3247
|
+
if (targetRepo2 && cwdRepo && targetRepo2 !== cwdRepo) {
|
|
3248
|
+
return `issue was created in ${targetRepo2}, not the board's repo ${cwdRepo}`;
|
|
3249
|
+
}
|
|
3250
|
+
return null;
|
|
3251
|
+
}
|
|
3252
|
+
function buildAddToProjectArgs(projectId, contentId) {
|
|
3253
|
+
if (!projectId) throw new Error("addToProject: projectId is required");
|
|
3254
|
+
if (!contentId) throw new Error("addToProject: contentId is required");
|
|
3255
|
+
return [
|
|
3256
|
+
"api",
|
|
3257
|
+
"graphql",
|
|
3258
|
+
"-f",
|
|
3259
|
+
"query=mutation($p:ID!,$c:ID!){addProjectV2ItemById(input:{projectId:$p,contentId:$c}){item{id}}}",
|
|
3260
|
+
"-f",
|
|
3261
|
+
`p=${projectId}`,
|
|
3262
|
+
"-f",
|
|
3263
|
+
`c=${contentId}`
|
|
3264
|
+
];
|
|
3265
|
+
}
|
|
3266
|
+
function parseAddedItemId(stdout) {
|
|
3267
|
+
try {
|
|
3268
|
+
return JSON.parse(stdout)?.data?.addProjectV2ItemById?.item?.id || void 0;
|
|
3269
|
+
} catch {
|
|
3270
|
+
return void 0;
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3220
3273
|
function buildPrArgs({ title, body, base, head, repo }) {
|
|
3221
3274
|
const args = ["pr", "create"];
|
|
3222
3275
|
if (repo) args.push("--repo", repo);
|
|
@@ -3239,6 +3292,12 @@ function resolveSession(d) {
|
|
|
3239
3292
|
return { id, source: "generated" };
|
|
3240
3293
|
}
|
|
3241
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
|
+
|
|
3242
3301
|
// src/saga-health.ts
|
|
3243
3302
|
function buildHealth(i) {
|
|
3244
3303
|
const problems = [];
|
|
@@ -3264,8 +3323,17 @@ function healthBanner(report) {
|
|
|
3264
3323
|
const suffix = report.problems.length > 2 ? ` (+${report.problems.length - 2} more)` : "";
|
|
3265
3324
|
return `saga health: CHECK - ${summary}${suffix}`;
|
|
3266
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
|
+
}
|
|
3267
3329
|
|
|
3268
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
|
+
}
|
|
3269
3337
|
function buildNoteCapture(summary, o, id, evidence) {
|
|
3270
3338
|
const queueOp = o.queueAdd ? { op: "add", text: o.queueAdd } : o.queueDone != null ? { op: "done", index: Number(o.queueDone) } : void 0;
|
|
3271
3339
|
const state = o.diagnostic ? "diagnostic" : o.verified ? "verified" : "asserted";
|
|
@@ -3286,7 +3354,7 @@ function buildNoteCapture(summary, o, id, evidence) {
|
|
|
3286
3354
|
state,
|
|
3287
3355
|
source,
|
|
3288
3356
|
evidence: Object.keys(ev).length ? ev : void 0,
|
|
3289
|
-
surface:
|
|
3357
|
+
surface: agentSurface(),
|
|
3290
3358
|
supersedes: o.supersedes,
|
|
3291
3359
|
anchor,
|
|
3292
3360
|
anchorForce: o.anchorForce || void 0
|
|
@@ -3343,6 +3411,20 @@ function buildVersionLagReport(input) {
|
|
|
3343
3411
|
releasedVersion: input.releasedVersion
|
|
3344
3412
|
};
|
|
3345
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
|
+
}
|
|
3424
|
+
function versionAutoUpdateAction(report, hasPluginRoot) {
|
|
3425
|
+
if (report.ok || report.staleAgainst !== "released") return "none";
|
|
3426
|
+
return hasPluginRoot ? "plugin-pull" : "npm";
|
|
3427
|
+
}
|
|
3346
3428
|
|
|
3347
3429
|
// src/issue-related.ts
|
|
3348
3430
|
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
@@ -3401,7 +3483,50 @@ ${lines.join("\n")}`;
|
|
|
3401
3483
|
// src/board.ts
|
|
3402
3484
|
var import_node_child_process2 = require("node:child_process");
|
|
3403
3485
|
var import_node_util = require("node:util");
|
|
3404
|
-
|
|
3486
|
+
|
|
3487
|
+
// src/board-priority.ts
|
|
3488
|
+
var BOARD_PRIORITY_NAMES = ["Urgent", "High", "Medium", "Low"];
|
|
3489
|
+
var CLI_PRIORITIES = ["urgent", "high", "medium", "low"];
|
|
3490
|
+
var LABEL_PREFIX = "priority:";
|
|
3491
|
+
function cliPriorityToFieldName(priority) {
|
|
3492
|
+
if (!CLI_PRIORITIES.includes(priority)) {
|
|
3493
|
+
throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${CLI_PRIORITIES.join(", ")}`);
|
|
3494
|
+
}
|
|
3495
|
+
return priority.charAt(0).toUpperCase() + priority.slice(1);
|
|
3496
|
+
}
|
|
3497
|
+
function labelToFieldPriority(label) {
|
|
3498
|
+
if (!label.startsWith(LABEL_PREFIX)) return void 0;
|
|
3499
|
+
const slug = label.slice(LABEL_PREFIX.length).toLowerCase();
|
|
3500
|
+
if (!CLI_PRIORITIES.includes(slug)) return void 0;
|
|
3501
|
+
return cliPriorityToFieldName(slug);
|
|
3502
|
+
}
|
|
3503
|
+
function resolvePriorityOptionId(cfg, priority) {
|
|
3504
|
+
if (!cfg.priorityFieldId || !cfg.priorityOptions) return void 0;
|
|
3505
|
+
const name = cliPriorityToFieldName(priority);
|
|
3506
|
+
return cfg.priorityOptions[name];
|
|
3507
|
+
}
|
|
3508
|
+
function isPriorityFieldConfigured(cfg) {
|
|
3509
|
+
return Boolean(
|
|
3510
|
+
cfg.priorityFieldId && BOARD_PRIORITY_NAMES.every((name) => cfg.priorityOptions?.[name])
|
|
3511
|
+
);
|
|
3512
|
+
}
|
|
3513
|
+
function recoverPriorityFromEvents(events) {
|
|
3514
|
+
let found;
|
|
3515
|
+
for (const event of events) {
|
|
3516
|
+
if (event.event !== "labeled" || !event.label?.name) continue;
|
|
3517
|
+
const mapped = labelToFieldPriority(event.label.name);
|
|
3518
|
+
if (mapped) found = mapped;
|
|
3519
|
+
}
|
|
3520
|
+
return found;
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
// src/board.ts
|
|
3524
|
+
var rawExecFileP = (0, import_node_util.promisify)(import_node_child_process2.execFile);
|
|
3525
|
+
var execFileP = (file, args, options = {}) => (
|
|
3526
|
+
// encoding 'utf8' guarantees string output at runtime; the cast pins the type (promisify's
|
|
3527
|
+
// overloads widen to string|Buffer when options is spread in).
|
|
3528
|
+
rawExecFileP(file, args, { encoding: "utf8", windowsHide: true, ...options })
|
|
3529
|
+
);
|
|
3405
3530
|
var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
|
|
3406
3531
|
var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["Todo", "In Progress", "In Review"]);
|
|
3407
3532
|
var STATUS_ORDER = new Map(BOARD_STATUSES.map((s, i) => [s, i]));
|
|
@@ -3419,7 +3544,7 @@ var defaultGit = async (args) => {
|
|
|
3419
3544
|
}
|
|
3420
3545
|
};
|
|
3421
3546
|
var PROJECT_ITEMS_QUERY = `
|
|
3422
|
-
query($owner: String!, $number: Int!, $
|
|
3547
|
+
query($owner: String!, $number: Int!, $after: String) {
|
|
3423
3548
|
viewer { login }
|
|
3424
3549
|
organization(login: $owner) {
|
|
3425
3550
|
projectV2(number: $number) {
|
|
@@ -3429,8 +3554,14 @@ query($owner: String!, $number: Int!, $statusField: String!, $after: String) {
|
|
|
3429
3554
|
pageInfo { hasNextPage endCursor }
|
|
3430
3555
|
nodes {
|
|
3431
3556
|
id
|
|
3432
|
-
|
|
3433
|
-
|
|
3557
|
+
fieldValues(first: 8) {
|
|
3558
|
+
nodes {
|
|
3559
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
3560
|
+
name
|
|
3561
|
+
optionId
|
|
3562
|
+
field { ... on ProjectV2SingleSelectField { name } }
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3434
3565
|
}
|
|
3435
3566
|
content {
|
|
3436
3567
|
__typename
|
|
@@ -3476,7 +3607,9 @@ function resolveBoardConfig(cfg) {
|
|
|
3476
3607
|
projectNumber: cfg.projectNumber,
|
|
3477
3608
|
projectId: cfg.projectId,
|
|
3478
3609
|
statusFieldId: cfg.statusFieldId,
|
|
3479
|
-
statusOptions: cfg.statusOptions
|
|
3610
|
+
statusOptions: cfg.statusOptions,
|
|
3611
|
+
priorityFieldId: cfg.priorityFieldId,
|
|
3612
|
+
priorityOptions: cfg.priorityOptions
|
|
3480
3613
|
};
|
|
3481
3614
|
}
|
|
3482
3615
|
function repoFromGitRemote(remote) {
|
|
@@ -3499,7 +3632,7 @@ function parseIssueSelector(selector, defaultRepo) {
|
|
|
3499
3632
|
if (local) return { repo: defaultRepo, number: Number(local[1]) };
|
|
3500
3633
|
throw new Error(`expected an issue selector like 123, #123, owner/repo#123, or a GitHub issue URL`);
|
|
3501
3634
|
}
|
|
3502
|
-
function partitionBoardItems(items, viewer, currentRepo) {
|
|
3635
|
+
function partitionBoardItems(items, viewer, currentRepo, writableRepos) {
|
|
3503
3636
|
const empty = () => ({ userOwned: [], claimable: [], taken: [] });
|
|
3504
3637
|
const groups = { primary: empty(), secondary: empty() };
|
|
3505
3638
|
const viewerKey = viewer.toLowerCase();
|
|
@@ -3511,7 +3644,7 @@ function partitionBoardItems(items, viewer, currentRepo) {
|
|
|
3511
3644
|
const assignedToViewer = assignees.includes(viewerKey);
|
|
3512
3645
|
if (assignedToViewer) {
|
|
3513
3646
|
groups[scope].userOwned.push(item);
|
|
3514
|
-
} 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()))) {
|
|
3515
3648
|
groups[scope].claimable.push(item);
|
|
3516
3649
|
} else if (item.assignees.length > 0) {
|
|
3517
3650
|
groups[scope].taken.push(item);
|
|
@@ -3549,6 +3682,22 @@ function findClaimableItem(report, selector) {
|
|
|
3549
3682
|
}
|
|
3550
3683
|
throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
|
|
3551
3684
|
}
|
|
3685
|
+
function renderBoardItem(item) {
|
|
3686
|
+
const assignees = item.assignees.length ? `@${item.assignees.join(", @")}` : "unassigned";
|
|
3687
|
+
const lines = [
|
|
3688
|
+
`${item.ref} - ${item.title}`,
|
|
3689
|
+
`Status: ${item.status} \xB7 ${assignees}${item.priority ? ` \xB7 Priority: ${item.priority}` : ""}`,
|
|
3690
|
+
`Type: ${item.type ?? "item"}${item.labels.length ? ` \xB7 ${item.labels.join(", ")}` : ""}`,
|
|
3691
|
+
item.url
|
|
3692
|
+
];
|
|
3693
|
+
if (item.details) {
|
|
3694
|
+
lines.push("", item.details.body.trim() || "_(no body)_");
|
|
3695
|
+
for (const comment of item.details.comments) {
|
|
3696
|
+
lines.push("", `\u2014 @${comment.author}:`, comment.body.trim());
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
return lines.join("\n");
|
|
3700
|
+
}
|
|
3552
3701
|
function renderBoardReport(report) {
|
|
3553
3702
|
const lines = [`Board \xB7 ${report.project.title} \xB7 @${report.viewer}`];
|
|
3554
3703
|
renderScope(lines, "PRIMARY", report.repo, report.primary);
|
|
@@ -3559,8 +3708,7 @@ function renderBoardReport(report) {
|
|
|
3559
3708
|
}
|
|
3560
3709
|
return lines.join("\n");
|
|
3561
3710
|
}
|
|
3562
|
-
async function
|
|
3563
|
-
const cfg = resolveBoardConfig(options.config);
|
|
3711
|
+
async function collectBoardItems(cfg, options, deps) {
|
|
3564
3712
|
const gh = deps.gh ?? defaultGh;
|
|
3565
3713
|
const git = deps.git ?? defaultGit;
|
|
3566
3714
|
const currentRepo = options.repo ?? repoFromGitRemote(await git(["remote", "get-url", "origin"])) ?? "";
|
|
@@ -3576,12 +3724,12 @@ async function readBoard(options, deps = {}) {
|
|
|
3576
3724
|
try {
|
|
3577
3725
|
const page = await fetchProjectPage(gh, cfg, after);
|
|
3578
3726
|
viewer ||= page.viewer.login;
|
|
3579
|
-
const
|
|
3580
|
-
if (!
|
|
3581
|
-
projectId =
|
|
3582
|
-
projectTitle ||=
|
|
3583
|
-
nodes.push(...
|
|
3584
|
-
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;
|
|
3585
3733
|
} catch (e) {
|
|
3586
3734
|
const message = `partial board read: ${e.message}`;
|
|
3587
3735
|
if (!nodes.length || !options.allowPartial) throw new Error(message);
|
|
@@ -3590,30 +3738,154 @@ async function readBoard(options, deps = {}) {
|
|
|
3590
3738
|
after = void 0;
|
|
3591
3739
|
}
|
|
3592
3740
|
} while (after);
|
|
3593
|
-
|
|
3594
|
-
|
|
3741
|
+
return { items: nodesToItems(nodes, warnings), viewer, repo: currentRepo, projectId, projectTitle, warnings, partial };
|
|
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
|
+
}
|
|
3775
|
+
async function readBoard(options, deps = {}) {
|
|
3776
|
+
const cfg = resolveBoardConfig(options.config);
|
|
3777
|
+
const gh = deps.gh ?? defaultGh;
|
|
3778
|
+
const collected = await collectBoardItems(cfg, options, deps);
|
|
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);
|
|
3595
3783
|
const report = {
|
|
3596
|
-
project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: projectId, title: projectTitle || String(cfg.projectNumber) },
|
|
3597
|
-
viewer,
|
|
3598
|
-
repo:
|
|
3784
|
+
project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: collected.projectId, title: collected.projectTitle || String(cfg.projectNumber) },
|
|
3785
|
+
viewer: collected.viewer,
|
|
3786
|
+
repo: collected.repo,
|
|
3599
3787
|
...groups,
|
|
3600
|
-
warnings,
|
|
3601
|
-
partial
|
|
3788
|
+
warnings: collected.warnings,
|
|
3789
|
+
partial: collected.partial
|
|
3602
3790
|
};
|
|
3603
3791
|
if (options.includeBundleDetails) {
|
|
3604
3792
|
await attachBundleDetails(report, gh, options.allowPartial ?? false);
|
|
3605
3793
|
}
|
|
3606
3794
|
return report;
|
|
3607
3795
|
}
|
|
3796
|
+
function findBoardItem(items, selector) {
|
|
3797
|
+
const found = items.find(
|
|
3798
|
+
(candidate) => candidate.repository.toLowerCase() === selector.repo.toLowerCase() && candidate.number === selector.number
|
|
3799
|
+
);
|
|
3800
|
+
if (!found) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
|
|
3801
|
+
return found;
|
|
3802
|
+
}
|
|
3803
|
+
async function moveBoardItem(options, deps = {}) {
|
|
3804
|
+
if (!BOARD_STATUSES.includes(options.status)) {
|
|
3805
|
+
throw new Error(`unknown status '${options.status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
|
|
3806
|
+
}
|
|
3807
|
+
const cfg = resolveBoardConfig(options.config);
|
|
3808
|
+
const gh = deps.gh ?? defaultGh;
|
|
3809
|
+
const collected = await collectBoardItems(cfg, options, deps);
|
|
3810
|
+
const selector = parseIssueSelector(options.selector, collected.repo);
|
|
3811
|
+
const item = findBoardItem(collected.items, selector);
|
|
3812
|
+
if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
|
|
3813
|
+
const optionId = cfg.statusOptions[options.status];
|
|
3814
|
+
try {
|
|
3815
|
+
await gh([
|
|
3816
|
+
"project",
|
|
3817
|
+
"item-edit",
|
|
3818
|
+
"--id",
|
|
3819
|
+
item.itemId,
|
|
3820
|
+
"--project-id",
|
|
3821
|
+
cfg.projectId,
|
|
3822
|
+
"--field-id",
|
|
3823
|
+
cfg.statusFieldId,
|
|
3824
|
+
"--single-select-option-id",
|
|
3825
|
+
optionId
|
|
3826
|
+
]);
|
|
3827
|
+
} catch (e) {
|
|
3828
|
+
const warning = `partial move: ${item.ref} status was not changed to ${options.status} (${ghError(e)})`;
|
|
3829
|
+
if (!options.allowPartial) throw new Error(warning);
|
|
3830
|
+
return { item, viewer: collected.viewer, repo: collected.repo, status: item.status, partial: true, warning };
|
|
3831
|
+
}
|
|
3832
|
+
return {
|
|
3833
|
+
item: { ...item, status: options.status, statusOptionId: optionId },
|
|
3834
|
+
viewer: collected.viewer,
|
|
3835
|
+
repo: collected.repo,
|
|
3836
|
+
status: options.status,
|
|
3837
|
+
partial: false
|
|
3838
|
+
};
|
|
3839
|
+
}
|
|
3840
|
+
async function showBoardItem(options, deps = {}) {
|
|
3841
|
+
const cfg = resolveBoardConfig(options.config);
|
|
3842
|
+
const gh = deps.gh ?? defaultGh;
|
|
3843
|
+
const collected = await collectBoardItems(cfg, options, deps);
|
|
3844
|
+
const selector = parseIssueSelector(options.selector, collected.repo);
|
|
3845
|
+
const item = findBoardItem(collected.items, selector);
|
|
3846
|
+
if (item.contentType === "Issue") {
|
|
3847
|
+
try {
|
|
3848
|
+
const { stdout } = await gh(["issue", "view", String(item.number), "--repo", item.repository, "--json", "body,comments"]);
|
|
3849
|
+
const detail = JSON.parse(stdout);
|
|
3850
|
+
item.details = {
|
|
3851
|
+
body: detail.body ?? "",
|
|
3852
|
+
comments: (detail.comments ?? []).map((comment) => ({
|
|
3853
|
+
author: comment.author?.login ?? "",
|
|
3854
|
+
body: comment.body ?? ""
|
|
3855
|
+
}))
|
|
3856
|
+
};
|
|
3857
|
+
} catch (e) {
|
|
3858
|
+
if (!options.allowPartial) throw new Error(`detail read failed: ${item.ref}: ${ghError(e)}`);
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
return item;
|
|
3862
|
+
}
|
|
3608
3863
|
async function claimBoardIssue(options, deps = {}) {
|
|
3609
3864
|
const cfg = resolveBoardConfig(options.config);
|
|
3610
3865
|
const gh = deps.gh ?? defaultGh;
|
|
3611
|
-
const
|
|
3612
|
-
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
|
+
}
|
|
3613
3883
|
const item = findClaimableItem(report, selector);
|
|
3614
3884
|
if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
|
|
3885
|
+
const assignee = options.assignee ?? "@me";
|
|
3886
|
+
const assignedLogin = assignee === "@me" ? report.viewer : assignee.replace(/^@/, "");
|
|
3615
3887
|
try {
|
|
3616
|
-
await gh(["issue", "edit", String(item.number), "--repo", item.repository, "--add-assignee",
|
|
3888
|
+
await gh(["issue", "edit", String(item.number), "--repo", item.repository, "--add-assignee", assignee]);
|
|
3617
3889
|
} catch (e) {
|
|
3618
3890
|
throw new Error(`claim failed before board status changed: ${ghError(e)}`);
|
|
3619
3891
|
}
|
|
@@ -3631,11 +3903,117 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
3631
3903
|
cfg.statusOptions["In Progress"]
|
|
3632
3904
|
]);
|
|
3633
3905
|
} catch (e) {
|
|
3634
|
-
const warning = `partial claim: ${item.ref} was assigned to @${
|
|
3906
|
+
const warning = `partial claim: ${item.ref} was assigned to @${assignedLogin}, but Status was not moved to In Progress (${ghError(e)})`;
|
|
3635
3907
|
if (!options.allowPartial) throw new Error(warning);
|
|
3636
3908
|
return { item, viewer: report.viewer, repo: report.repo, status: "Todo", partial: true, warning };
|
|
3637
3909
|
}
|
|
3638
|
-
return {
|
|
3910
|
+
return {
|
|
3911
|
+
item: {
|
|
3912
|
+
...item,
|
|
3913
|
+
assignees: item.assignees.includes(assignedLogin) ? item.assignees : [...item.assignees, assignedLogin],
|
|
3914
|
+
status: "In Progress",
|
|
3915
|
+
statusOptionId: cfg.statusOptions["In Progress"]
|
|
3916
|
+
},
|
|
3917
|
+
viewer: report.viewer,
|
|
3918
|
+
repo: report.repo,
|
|
3919
|
+
status: "In Progress",
|
|
3920
|
+
partial: false
|
|
3921
|
+
};
|
|
3922
|
+
}
|
|
3923
|
+
async function setBoardItemPriority(gh, cfg, itemId, priority) {
|
|
3924
|
+
if (!isPriorityFieldConfigured(cfg)) return void 0;
|
|
3925
|
+
const optionId = resolvePriorityOptionId(cfg, priority);
|
|
3926
|
+
if (!optionId || !cfg.priorityFieldId || !cfg.projectId) return void 0;
|
|
3927
|
+
await gh([
|
|
3928
|
+
"project",
|
|
3929
|
+
"item-edit",
|
|
3930
|
+
"--id",
|
|
3931
|
+
itemId,
|
|
3932
|
+
"--project-id",
|
|
3933
|
+
cfg.projectId,
|
|
3934
|
+
"--field-id",
|
|
3935
|
+
cfg.priorityFieldId,
|
|
3936
|
+
"--single-select-option-id",
|
|
3937
|
+
optionId
|
|
3938
|
+
]);
|
|
3939
|
+
return cliPriorityToFieldName(priority);
|
|
3940
|
+
}
|
|
3941
|
+
async function backfillBoardPriorities(options, deps = {}) {
|
|
3942
|
+
const cfg = resolveBoardConfig(options.config);
|
|
3943
|
+
if (!isPriorityFieldConfigured(cfg)) {
|
|
3944
|
+
throw new Error("priority field is not configured in .mmi/config.json (priorityFieldId + priorityOptions)");
|
|
3945
|
+
}
|
|
3946
|
+
const gh = deps.gh ?? defaultGh;
|
|
3947
|
+
const collected = await collectBoardItems(cfg, { repo: options.repo }, deps);
|
|
3948
|
+
const issues = collected.items.filter((item) => item.contentType === "Issue");
|
|
3949
|
+
const concurrency = Math.max(1, options.concurrency ?? 8);
|
|
3950
|
+
const result = { scanned: issues.length, set: 0, skipped: 0, failed: 0, details: [] };
|
|
3951
|
+
async function work(item) {
|
|
3952
|
+
if (item.priority) {
|
|
3953
|
+
result.skipped += 1;
|
|
3954
|
+
return;
|
|
3955
|
+
}
|
|
3956
|
+
try {
|
|
3957
|
+
const priority = await recoverIssuePriority(gh, item);
|
|
3958
|
+
if (!priority) {
|
|
3959
|
+
result.skipped += 1;
|
|
3960
|
+
return;
|
|
3961
|
+
}
|
|
3962
|
+
if (options.dryRun) {
|
|
3963
|
+
result.set += 1;
|
|
3964
|
+
result.details.push(`${item.ref} \u2192 ${priority} (dry-run)`);
|
|
3965
|
+
return;
|
|
3966
|
+
}
|
|
3967
|
+
const optionId = cfg.priorityOptions?.[priority];
|
|
3968
|
+
if (!optionId) throw new Error(`no option id for ${priority}`);
|
|
3969
|
+
await gh([
|
|
3970
|
+
"project",
|
|
3971
|
+
"item-edit",
|
|
3972
|
+
"--id",
|
|
3973
|
+
item.itemId,
|
|
3974
|
+
"--project-id",
|
|
3975
|
+
cfg.projectId,
|
|
3976
|
+
"--field-id",
|
|
3977
|
+
cfg.priorityFieldId,
|
|
3978
|
+
"--single-select-option-id",
|
|
3979
|
+
optionId
|
|
3980
|
+
]);
|
|
3981
|
+
result.set += 1;
|
|
3982
|
+
result.details.push(`${item.ref} \u2192 ${priority}`);
|
|
3983
|
+
} catch (e) {
|
|
3984
|
+
result.failed += 1;
|
|
3985
|
+
result.details.push(`${item.ref}: ${ghError(e)}`);
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
3988
|
+
for (let i = 0; i < issues.length; i += concurrency) {
|
|
3989
|
+
await Promise.all(issues.slice(i, i + concurrency).map(work));
|
|
3990
|
+
}
|
|
3991
|
+
return result;
|
|
3992
|
+
}
|
|
3993
|
+
async function recoverIssuePriority(gh, item) {
|
|
3994
|
+
for (const label of item.labels) {
|
|
3995
|
+
const fromLabel = labelToFieldPriority(label);
|
|
3996
|
+
if (fromLabel) return fromLabel;
|
|
3997
|
+
}
|
|
3998
|
+
const { stdout } = await gh(["api", `repos/${item.repository}/issues/${item.number}/events`, "--paginate"]);
|
|
3999
|
+
return recoverPriorityFromEvents(parsePaginatedEvents(stdout));
|
|
4000
|
+
}
|
|
4001
|
+
function parsePaginatedEvents(stdout) {
|
|
4002
|
+
const trimmed = stdout.trim();
|
|
4003
|
+
if (!trimmed) return [];
|
|
4004
|
+
try {
|
|
4005
|
+
const parsed = JSON.parse(trimmed);
|
|
4006
|
+
if (Array.isArray(parsed)) return parsed;
|
|
4007
|
+
} catch {
|
|
4008
|
+
}
|
|
4009
|
+
return trimmed.split(/\r?\n/).filter(Boolean).flatMap((line) => {
|
|
4010
|
+
try {
|
|
4011
|
+
const parsed = JSON.parse(line);
|
|
4012
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4013
|
+
} catch {
|
|
4014
|
+
return [];
|
|
4015
|
+
}
|
|
4016
|
+
});
|
|
3639
4017
|
}
|
|
3640
4018
|
async function fetchProjectPage(gh, cfg, after) {
|
|
3641
4019
|
const args = [
|
|
@@ -3646,9 +4024,7 @@ async function fetchProjectPage(gh, cfg, after) {
|
|
|
3646
4024
|
"-f",
|
|
3647
4025
|
`owner=${cfg.projectOwner}`,
|
|
3648
4026
|
"-F",
|
|
3649
|
-
`number=${cfg.projectNumber}
|
|
3650
|
-
"-f",
|
|
3651
|
-
"statusField=Status"
|
|
4027
|
+
`number=${cfg.projectNumber}`
|
|
3652
4028
|
];
|
|
3653
4029
|
if (after) args.push("-f", `after=${after}`);
|
|
3654
4030
|
const { stdout } = await gh(args);
|
|
@@ -3665,10 +4041,27 @@ function nodesToItems(nodes, warnings) {
|
|
|
3665
4041
|
}
|
|
3666
4042
|
return items;
|
|
3667
4043
|
}
|
|
4044
|
+
function parseSingleSelectFields(nodes) {
|
|
4045
|
+
const out = {};
|
|
4046
|
+
for (const node of nodes ?? []) {
|
|
4047
|
+
const fieldName = node.field?.name;
|
|
4048
|
+
if (fieldName === "Status") {
|
|
4049
|
+
const status = asBoardStatus(node.name);
|
|
4050
|
+
if (status) out.status = { name: status, optionId: node.optionId };
|
|
4051
|
+
} else if (fieldName === "Priority" && node.name) {
|
|
4052
|
+
const priority = node.name;
|
|
4053
|
+
if (["Urgent", "High", "Medium", "Low"].includes(priority)) {
|
|
4054
|
+
out.priority = { name: priority, optionId: node.optionId };
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
}
|
|
4058
|
+
return out;
|
|
4059
|
+
}
|
|
3668
4060
|
function nodeToItem(node) {
|
|
3669
4061
|
const content = node.content;
|
|
3670
4062
|
if (!node.id || !isSupportedContent(content)) return void 0;
|
|
3671
|
-
const
|
|
4063
|
+
const fields = parseSingleSelectFields(node.fieldValues?.nodes);
|
|
4064
|
+
const status = fields.status?.name;
|
|
3672
4065
|
const repository = content.repository?.nameWithOwner;
|
|
3673
4066
|
if (!status || !content.id || !content.number || !content.title || !content.url || !repository) return void 0;
|
|
3674
4067
|
const labels = (content.labels?.nodes ?? []).map((l) => l.name).filter((name) => Boolean(name));
|
|
@@ -3684,7 +4077,9 @@ function nodeToItem(node) {
|
|
|
3684
4077
|
title: content.title,
|
|
3685
4078
|
state: content.state ?? "",
|
|
3686
4079
|
status,
|
|
3687
|
-
statusOptionId:
|
|
4080
|
+
statusOptionId: fields.status?.optionId,
|
|
4081
|
+
priority: fields.priority?.name,
|
|
4082
|
+
priorityOptionId: fields.priority?.optionId,
|
|
3688
4083
|
assignees,
|
|
3689
4084
|
labels,
|
|
3690
4085
|
type: labels.find((label) => TYPE_LABELS.includes(label)) ?? labels[0]
|
|
@@ -3743,7 +4138,8 @@ function renderTaken(lines, items) {
|
|
|
3743
4138
|
for (const item of items) lines.push(` ${item.ref} \xB7 ${item.status} \xB7 @${item.assignees.join(", @")}`);
|
|
3744
4139
|
}
|
|
3745
4140
|
function renderTitledItem(item) {
|
|
3746
|
-
|
|
4141
|
+
const pri = item.priority ? ` \xB7 ${item.priority}` : "";
|
|
4142
|
+
return `${item.ref} - [${item.type ?? "item"}]${pri} ${item.title}`;
|
|
3747
4143
|
}
|
|
3748
4144
|
function hasItems(buckets) {
|
|
3749
4145
|
return buckets.userOwned.length > 0 || buckets.claimable.length > 0 || buckets.taken.length > 0;
|
|
@@ -3889,6 +4285,12 @@ function stagePlan(stage2 = {}) {
|
|
|
3889
4285
|
{ label: "check health", command: stage2.healthUrl ? `curl --fail ${stage2.healthUrl}` : "(no stage.healthUrl configured)" }
|
|
3890
4286
|
];
|
|
3891
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
|
+
}
|
|
3892
4294
|
function trainPlan(command) {
|
|
3893
4295
|
if (command === "rc") {
|
|
3894
4296
|
return [
|
|
@@ -3927,8 +4329,83 @@ function bootstrapPlan(repo, repoClass) {
|
|
|
3927
4329
|
];
|
|
3928
4330
|
}
|
|
3929
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
|
+
|
|
3930
4406
|
// src/doctor.ts
|
|
3931
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";
|
|
3932
4409
|
function buildGithubAuthCheck(input) {
|
|
3933
4410
|
const ok = Boolean(input.login?.trim());
|
|
3934
4411
|
return {
|
|
@@ -3937,11 +4414,68 @@ function buildGithubAuthCheck(input) {
|
|
|
3937
4414
|
fix: input.ghInstalled ? GH_PROJECT_LOGIN_FIX : `install GitHub CLI (https://cli.github.com), then: ${GH_PROJECT_LOGIN_FIX.replace(/^run: /, "")}`
|
|
3938
4415
|
};
|
|
3939
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
|
+
}
|
|
3940
4473
|
|
|
3941
4474
|
// src/stage-runner.ts
|
|
3942
4475
|
var import_node_child_process3 = require("node:child_process");
|
|
3943
4476
|
var import_node_fs2 = require("node:fs");
|
|
3944
4477
|
var import_node_path2 = require("node:path");
|
|
4478
|
+
var import_node_net = require("node:net");
|
|
3945
4479
|
var import_node_util2 = require("node:util");
|
|
3946
4480
|
var execFileP2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
|
|
3947
4481
|
function stageStatePath(cwd = process.cwd()) {
|
|
@@ -3954,8 +4488,29 @@ function validateStageConfig(config = {}, action) {
|
|
|
3954
4488
|
if (config.healthUrl != null && config.healthUrl.trim() && !/^https?:\/\//.test(config.healthUrl.trim())) {
|
|
3955
4489
|
problems.push("stage.healthUrl must be an http(s) URL");
|
|
3956
4490
|
}
|
|
4491
|
+
if (config.portRange != null) {
|
|
4492
|
+
const r = config.portRange;
|
|
4493
|
+
const ok = Array.isArray(r) && r.length === 2 && r.every((n) => Number.isInteger(n) && n >= 1024 && n <= 65535) && r[0] <= r[1];
|
|
4494
|
+
if (!ok) problems.push("stage.portRange must be [start, end] within 1024-65535 with start <= end");
|
|
4495
|
+
}
|
|
3957
4496
|
return problems;
|
|
3958
4497
|
}
|
|
4498
|
+
function pickStagePort(range, isFree) {
|
|
4499
|
+
if (!range) return void 0;
|
|
4500
|
+
const [start, end] = range;
|
|
4501
|
+
for (let port = start; port <= end; port++) {
|
|
4502
|
+
if (isFree(port)) return port;
|
|
4503
|
+
}
|
|
4504
|
+
throw new Error(`no free stage port in range ${start}-${end} \u2014 every port is in use`);
|
|
4505
|
+
}
|
|
4506
|
+
function isPortFree(port) {
|
|
4507
|
+
return new Promise((resolve) => {
|
|
4508
|
+
const srv = (0, import_node_net.createServer)();
|
|
4509
|
+
srv.once("error", () => resolve(false));
|
|
4510
|
+
srv.once("listening", () => srv.close(() => resolve(true)));
|
|
4511
|
+
srv.listen(port, "127.0.0.1");
|
|
4512
|
+
});
|
|
4513
|
+
}
|
|
3959
4514
|
async function shell(command, cwd, timeoutMs) {
|
|
3960
4515
|
await execFileP2(command, [], {
|
|
3961
4516
|
cwd,
|
|
@@ -4030,24 +4585,34 @@ async function startStage(config = {}, opts = {}) {
|
|
|
4030
4585
|
const statePath = opts.statePath ?? stageStatePath(cwd);
|
|
4031
4586
|
const dir = statePath.slice(0, Math.max(statePath.lastIndexOf("/"), statePath.lastIndexOf("\\")));
|
|
4032
4587
|
(0, import_node_fs2.mkdirSync)(dir, { recursive: true });
|
|
4033
|
-
|
|
4588
|
+
let stagePort;
|
|
4589
|
+
if (config.portRange) {
|
|
4590
|
+
const [s, e] = config.portRange;
|
|
4591
|
+
const free = /* @__PURE__ */ new Set();
|
|
4592
|
+
for (let p = s; p <= e; p++) if (await isPortFree(p)) free.add(p);
|
|
4593
|
+
stagePort = pickStagePort(config.portRange, (p) => free.has(p));
|
|
4594
|
+
}
|
|
4595
|
+
const sub = (s) => s != null && stagePort != null ? s.replace(/\$\{?STAGE_PORT\}?/g, String(stagePort)) : s;
|
|
4596
|
+
const up = sub(config.up.trim());
|
|
4034
4597
|
const child = (0, import_node_child_process3.spawn)(up, {
|
|
4035
4598
|
cwd,
|
|
4036
4599
|
shell: true,
|
|
4037
4600
|
detached: true,
|
|
4038
4601
|
windowsHide: true,
|
|
4039
|
-
stdio: "ignore"
|
|
4602
|
+
stdio: "ignore",
|
|
4603
|
+
env: stagePort != null ? { ...process.env, STAGE_PORT: String(stagePort) } : process.env
|
|
4040
4604
|
});
|
|
4041
4605
|
const state = {
|
|
4042
4606
|
pid: child.pid ?? 0,
|
|
4043
4607
|
command: up,
|
|
4044
4608
|
cwd,
|
|
4045
4609
|
startedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
4046
|
-
healthUrl: config.healthUrl?.trim() || void 0
|
|
4610
|
+
healthUrl: sub(config.healthUrl?.trim()) || void 0,
|
|
4611
|
+
port: stagePort
|
|
4047
4612
|
};
|
|
4048
4613
|
(0, import_node_fs2.writeFileSync)(statePath, JSON.stringify(state, null, 2), "utf8");
|
|
4049
4614
|
if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4);
|
|
4050
|
-
const result = { ok: true, action: "start", statePath, pid: state.pid, message: `started stage pid ${state.pid}` };
|
|
4615
|
+
const result = { ok: true, action: "start", statePath, pid: state.pid, message: `started stage pid ${state.pid}${stagePort != null ? ` on port ${stagePort}` : ""}` };
|
|
4051
4616
|
opts.onReady?.(result);
|
|
4052
4617
|
child.unref();
|
|
4053
4618
|
return result;
|
|
@@ -4063,28 +4628,65 @@ async function runStage(config = {}, opts = {}) {
|
|
|
4063
4628
|
return { ...started, action: "run", message: `built and ${started.message}` };
|
|
4064
4629
|
}
|
|
4065
4630
|
|
|
4066
|
-
// src/
|
|
4067
|
-
var
|
|
4068
|
-
var
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
function
|
|
4631
|
+
// src/port-registry.ts
|
|
4632
|
+
var import_node_fs3 = require("node:fs");
|
|
4633
|
+
var BLOCK = 100;
|
|
4634
|
+
var SPAN = 10;
|
|
4635
|
+
var FIRST = 3e3;
|
|
4636
|
+
function nextPortBlock(registry2) {
|
|
4637
|
+
const bases = Object.values(registry2).map(([start]) => start);
|
|
4638
|
+
const base = bases.length ? Math.max(...bases) + BLOCK : FIRST;
|
|
4639
|
+
return [base, base + SPAN];
|
|
4640
|
+
}
|
|
4641
|
+
function loadPortRegistry(path) {
|
|
4642
|
+
if (!(0, import_node_fs3.existsSync)(path)) return {};
|
|
4643
|
+
const raw = JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8"));
|
|
4644
|
+
const out = {};
|
|
4645
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
4646
|
+
if (Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number")) {
|
|
4647
|
+
out[key] = [value[0], value[1]];
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4650
|
+
return out;
|
|
4651
|
+
}
|
|
4652
|
+
function ensurePortRange(repo, path) {
|
|
4653
|
+
const registry2 = loadPortRegistry(path);
|
|
4654
|
+
const existing = registry2[repo];
|
|
4655
|
+
if (existing) return existing;
|
|
4656
|
+
const range = nextPortBlock(registry2);
|
|
4657
|
+
const raw = (0, import_node_fs3.existsSync)(path) ? JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8")) : {};
|
|
4658
|
+
raw[repo] = range;
|
|
4659
|
+
(0, import_node_fs3.writeFileSync)(path, JSON.stringify(raw, null, 2) + "\n", "utf8");
|
|
4660
|
+
return range;
|
|
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
|
+
}
|
|
4681
|
+
|
|
4682
|
+
// src/access.ts
|
|
4683
|
+
var OWNER = "mutmutco";
|
|
4684
|
+
var LOCKED_APP = "mmi-github-app";
|
|
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
|
+
};
|
|
4689
|
+
function lockedBranches(repoClass) {
|
|
4088
4690
|
return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
|
|
4089
4691
|
}
|
|
4090
4692
|
function safeJson(text, fallback) {
|
|
@@ -4101,59 +4703,330 @@ async function ghJson(deps, args, fallback) {
|
|
|
4101
4703
|
return fallback;
|
|
4102
4704
|
}
|
|
4103
4705
|
}
|
|
4104
|
-
async function
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`]);
|
|
4108
|
-
return true;
|
|
4109
|
-
} catch {
|
|
4110
|
-
return false;
|
|
4111
|
-
}
|
|
4706
|
+
async function resolveOwners(deps) {
|
|
4707
|
+
const members = await ghJson(deps, ["api", `orgs/${OWNER}/members?role=admin`, "--paginate"], []);
|
|
4708
|
+
return members.map((m) => m.login);
|
|
4112
4709
|
}
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
if (
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4710
|
+
function collaboratorRole(c) {
|
|
4711
|
+
return c.role_name ?? (c.permissions?.admin ? "admin" : c.permissions?.maintain ? "maintain" : "write");
|
|
4712
|
+
}
|
|
4713
|
+
async function auditRepoCollaborators(repo, owners, deps) {
|
|
4714
|
+
const collabs = await ghJson(deps, ["api", `repos/${repo}/collaborators?affiliation=direct`, "--paginate"], []);
|
|
4715
|
+
const findings = [];
|
|
4716
|
+
for (const c of collabs) {
|
|
4717
|
+
if (owners.has(c.login)) continue;
|
|
4718
|
+
const role = collaboratorRole(c);
|
|
4719
|
+
if (OVERGRANT_ROLES.has(role)) {
|
|
4720
|
+
findings.push({
|
|
4721
|
+
repo,
|
|
4722
|
+
kind: "collaborator-overgrant",
|
|
4723
|
+
severity: "high",
|
|
4724
|
+
actor: c.login,
|
|
4725
|
+
detail: `direct collaborator @${c.login} holds role '${role}'; a developer must be 'write' (admin/maintain is master-only)`,
|
|
4726
|
+
remediation: `gh api -X PUT repos/${repo}/collaborators/${c.login} -f permission=push`
|
|
4727
|
+
});
|
|
4728
|
+
}
|
|
4125
4729
|
}
|
|
4730
|
+
return findings;
|
|
4126
4731
|
}
|
|
4127
|
-
async function
|
|
4732
|
+
async function auditTrainBranch(repo, branch, owners, deps, projectAdmins = /* @__PURE__ */ new Set()) {
|
|
4733
|
+
let restrictions = null;
|
|
4128
4734
|
try {
|
|
4129
|
-
await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`]);
|
|
4130
|
-
return true;
|
|
4735
|
+
restrictions = safeJson((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection/restrictions`])).stdout, null);
|
|
4131
4736
|
} catch {
|
|
4132
|
-
|
|
4737
|
+
restrictions = null;
|
|
4738
|
+
}
|
|
4739
|
+
if (!restrictions) {
|
|
4740
|
+
return [{
|
|
4741
|
+
repo,
|
|
4742
|
+
branch,
|
|
4743
|
+
kind: "unprotected-branch",
|
|
4744
|
+
severity: "medium",
|
|
4745
|
+
detail: `${branch} has no push restrictions (branch unprotected, or protection without a user/app allowlist)`,
|
|
4746
|
+
remediation: `initialize the lock \u2014 see docs/Guides/repo-access.md "Initialize the lock"; PUT repos/${repo}/branches/${branch}/protection with restrictions {users:[<owners>], apps:["${LOCKED_APP}"]}`
|
|
4747
|
+
}];
|
|
4748
|
+
}
|
|
4749
|
+
const findings = [];
|
|
4750
|
+
const users = (restrictions.users ?? []).map((u) => u.login);
|
|
4751
|
+
for (const login of users) {
|
|
4752
|
+
if (!owners.has(login) && !projectAdmins.has(login)) {
|
|
4753
|
+
findings.push({
|
|
4754
|
+
repo,
|
|
4755
|
+
branch,
|
|
4756
|
+
kind: "train-allowlist-extra",
|
|
4757
|
+
severity: "medium",
|
|
4758
|
+
actor: login,
|
|
4759
|
+
detail: `@${login} is on the ${branch} push allowlist \u2014 legitimate only if an intended full-write project-admin; confirm`,
|
|
4760
|
+
remediation: `# if NOT an intended full-write member: gh api -X DELETE repos/${repo}/branches/${branch}/protection/restrictions/users --input - <<< '["${login}"]'`
|
|
4761
|
+
});
|
|
4762
|
+
}
|
|
4763
|
+
}
|
|
4764
|
+
for (const owner of owners) {
|
|
4765
|
+
if (!users.includes(owner)) {
|
|
4766
|
+
findings.push({
|
|
4767
|
+
repo,
|
|
4768
|
+
branch,
|
|
4769
|
+
kind: "train-allowlist-missing",
|
|
4770
|
+
severity: "medium",
|
|
4771
|
+
actor: owner,
|
|
4772
|
+
detail: `org owner @${owner} is missing from the ${branch} push allowlist`,
|
|
4773
|
+
remediation: `gh api -X POST repos/${repo}/branches/${branch}/protection/restrictions/users --input - <<< '["${owner}"]'`
|
|
4774
|
+
});
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
const apps = (restrictions.apps ?? []).map((a) => a.slug);
|
|
4778
|
+
if (!apps.includes(LOCKED_APP)) {
|
|
4779
|
+
findings.push({
|
|
4780
|
+
repo,
|
|
4781
|
+
branch,
|
|
4782
|
+
kind: "app-bypass-missing",
|
|
4783
|
+
severity: "high",
|
|
4784
|
+
detail: `the ${LOCKED_APP} App is missing from the ${branch} allowlist \u2014 fanout/promotions will break`,
|
|
4785
|
+
remediation: `gh api -X POST repos/${repo}/branches/${branch}/protection/restrictions/apps --input - <<< '["${LOCKED_APP}"]'`
|
|
4786
|
+
});
|
|
4133
4787
|
}
|
|
4788
|
+
return findings;
|
|
4789
|
+
}
|
|
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) {
|
|
4809
|
+
const findings = [];
|
|
4810
|
+
findings.push(...await auditRepoCollaborators(repo, owners, deps));
|
|
4811
|
+
if (dataAccess) findings.push(...auditDataAccessContracts(repo, dataAccess));
|
|
4812
|
+
for (const branch of lockedBranches(repoClass)) {
|
|
4813
|
+
findings.push(...await auditTrainBranch(repo, branch, owners, deps, projectAdmins));
|
|
4814
|
+
}
|
|
4815
|
+
return { repo, class: repoClass, ok: !findings.some((f) => f.severity === "high"), findings };
|
|
4816
|
+
}
|
|
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) {
|
|
4832
|
+
const owners = new Set(await resolveOwners(deps));
|
|
4833
|
+
const orgFindings = await auditOrgBasePermission(deps);
|
|
4834
|
+
const repos = [];
|
|
4835
|
+
for (const target of targets) {
|
|
4836
|
+
repos.push(await auditRepoAccess(target.repo, target.class, owners, deps, new Set(matrix[target.repo] ?? []), dataAccess));
|
|
4837
|
+
}
|
|
4838
|
+
const ok = orgFindings.every((f) => f.severity !== "high") && repos.every((r) => r.ok);
|
|
4839
|
+
return { ok, owners: [...owners], orgFindings, repos };
|
|
4840
|
+
}
|
|
4841
|
+
function loadAccessTargets(projectsJson, fanoutJson) {
|
|
4842
|
+
const projects = safeJson(projectsJson, {}).projects ?? [];
|
|
4843
|
+
const fanout = fanoutJson ? safeJson(fanoutJson, {}).repos ?? [] : [];
|
|
4844
|
+
const contentNames = new Set(fanout.filter((r) => r.class === "content").map((r) => r.repo));
|
|
4845
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4846
|
+
const targets = [];
|
|
4847
|
+
for (const project2 of projects) {
|
|
4848
|
+
for (const repo of project2.repos ?? []) {
|
|
4849
|
+
if (seen.has(repo)) continue;
|
|
4850
|
+
seen.add(repo);
|
|
4851
|
+
targets.push({ repo, class: contentNames.has(repo.split("/")[1]) ? "content" : "deployable" });
|
|
4852
|
+
}
|
|
4853
|
+
}
|
|
4854
|
+
return targets;
|
|
4855
|
+
}
|
|
4856
|
+
function loadAccessMatrix(matrixJson) {
|
|
4857
|
+
if (!matrixJson) return {};
|
|
4858
|
+
return safeJson(matrixJson, {}).projectAdmins ?? {};
|
|
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
|
+
}
|
|
4885
|
+
function renderAccessReport(report) {
|
|
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
|
+
}
|
|
4891
|
+
for (const repo of report.repos) {
|
|
4892
|
+
lines.push(`${repo.ok ? "OK" : "FLAG"} ${repo.repo} (${repo.class})`);
|
|
4893
|
+
for (const finding of repo.findings) {
|
|
4894
|
+
lines.push(` [${finding.severity}] ${finding.kind}${finding.branch ? ` @${finding.branch}` : ""}: ${finding.detail}`);
|
|
4895
|
+
if (finding.remediation) lines.push(` ${finding.remediation}`);
|
|
4896
|
+
}
|
|
4897
|
+
}
|
|
4898
|
+
return lines.join("\n");
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
// src/bootstrap-verify.ts
|
|
4902
|
+
var requiredDocs = ["README.md", "architecture.md", "AGENTS.md", "CLAUDE.md", ".claude/settings.json", ".mmi/config.json"];
|
|
4903
|
+
var requiredIssueTemplates = [
|
|
4904
|
+
".github/ISSUE_TEMPLATE/bug.yml",
|
|
4905
|
+
".github/ISSUE_TEMPLATE/feature.yml",
|
|
4906
|
+
".github/ISSUE_TEMPLATE/task.yml",
|
|
4907
|
+
".github/ISSUE_TEMPLATE/config.yml"
|
|
4908
|
+
];
|
|
4909
|
+
var requiredWorkflows = [".github/workflows/pr-to-board.yml"];
|
|
4910
|
+
var requiredLabels = ["bug", "feature", "task", "priority:urgent", "priority:high", "priority:medium", "priority:low"];
|
|
4911
|
+
var requiredPriorityOptions = ["Urgent", "High", "Medium", "Low"];
|
|
4912
|
+
var strayDefaultLabels = ["documentation", "duplicate", "enhancement", "good first issue", "help wanted", "invalid", "question", "wontfix"];
|
|
4913
|
+
var requiredStatusOptions = ["Todo", "In Progress", "In Review", "Done"];
|
|
4914
|
+
var requiredProjectWorkflows = [
|
|
4915
|
+
"Auto-add sub-issues to project",
|
|
4916
|
+
"Auto-archive items",
|
|
4917
|
+
"Item added to project",
|
|
4918
|
+
"Item closed"
|
|
4919
|
+
];
|
|
4920
|
+
var requiredOrgRulesetTypes = ["pull_request", "non_fast_forward", "deletion"];
|
|
4921
|
+
var requiredHubStatusChecks = ["cli", "infra", "docs"];
|
|
4922
|
+
var requiredActionsVariables = ["MMI_APP_ID"];
|
|
4923
|
+
var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
|
|
4924
|
+
function expectedBranches(repoClass) {
|
|
4925
|
+
return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
|
|
4926
|
+
}
|
|
4927
|
+
function safeJson2(text, fallback) {
|
|
4928
|
+
try {
|
|
4929
|
+
return JSON.parse(text);
|
|
4930
|
+
} catch {
|
|
4931
|
+
return fallback;
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4934
|
+
async function ghJson2(deps, args, fallback) {
|
|
4935
|
+
try {
|
|
4936
|
+
return safeJson2((await deps.gh(args)).stdout, fallback);
|
|
4937
|
+
} catch {
|
|
4938
|
+
return fallback;
|
|
4939
|
+
}
|
|
4940
|
+
}
|
|
4941
|
+
async function contentExists(deps, repo, branch, path) {
|
|
4942
|
+
try {
|
|
4943
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
4944
|
+
await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`]);
|
|
4945
|
+
return true;
|
|
4946
|
+
} catch {
|
|
4947
|
+
return false;
|
|
4948
|
+
}
|
|
4949
|
+
}
|
|
4950
|
+
async function contentText(deps, repo, branch, path) {
|
|
4951
|
+
try {
|
|
4952
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
4953
|
+
const response = safeJson2(
|
|
4954
|
+
(await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`])).stdout,
|
|
4955
|
+
{}
|
|
4956
|
+
);
|
|
4957
|
+
if (response.content == null) return null;
|
|
4958
|
+
if (response.encoding != null && response.encoding !== "base64") return null;
|
|
4959
|
+
return Buffer.from(response.content.replace(/\s/g, ""), "base64").toString("utf8");
|
|
4960
|
+
} catch {
|
|
4961
|
+
return null;
|
|
4962
|
+
}
|
|
4963
|
+
}
|
|
4964
|
+
async function getProtection(deps, repo, branch) {
|
|
4965
|
+
try {
|
|
4966
|
+
return safeJson2((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`])).stdout, {});
|
|
4967
|
+
} catch {
|
|
4968
|
+
return null;
|
|
4969
|
+
}
|
|
4970
|
+
}
|
|
4971
|
+
function hasPushAllowlist(p) {
|
|
4972
|
+
return Array.isArray(p?.restrictions?.users) && p.restrictions.users.length > 0;
|
|
4134
4973
|
}
|
|
4135
4974
|
function optionDetail(missing) {
|
|
4136
4975
|
return missing.length === 0 ? void 0 : `missing: ${missing.join(", ")}`;
|
|
4137
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
|
+
}
|
|
4138
4998
|
function localRegistryCheck(deps, path, predicate) {
|
|
4139
4999
|
const text = deps.readLocalFile?.(path);
|
|
4140
5000
|
if (text == null) return null;
|
|
4141
|
-
return predicate(
|
|
5001
|
+
return predicate(safeJson2(text, null));
|
|
4142
5002
|
}
|
|
4143
5003
|
async function verifyBootstrap(repo, repoClass, deps) {
|
|
4144
5004
|
const branchesWanted = expectedBranches(repoClass);
|
|
4145
5005
|
const baseBranch = repoClass === "content" ? "main" : "development";
|
|
4146
5006
|
const checks = [];
|
|
4147
|
-
const repoInfo = await
|
|
5007
|
+
const repoInfo = await ghJson2(deps, ["api", `repos/${repo}`], {});
|
|
4148
5008
|
checks.push({ ok: Boolean(repoInfo.default_branch), label: "repo exists" });
|
|
4149
5009
|
checks.push({ ok: repoInfo.default_branch === baseBranch, label: `default branch is ${baseBranch}`, detail: repoInfo.default_branch || "missing" });
|
|
4150
5010
|
checks.push({ ok: repoInfo.has_wiki === true, label: "wiki enabled", detail: repoInfo.has_wiki === true ? void 0 : "has_wiki is false or unavailable" });
|
|
4151
|
-
const branchList = await
|
|
5011
|
+
const branchList = await ghJson2(deps, ["api", `repos/${repo}/branches`, "--paginate"], []);
|
|
4152
5012
|
const branchNames = new Set(branchList.map((b) => b.name));
|
|
4153
5013
|
for (const branch of branchesWanted) {
|
|
4154
5014
|
checks.push({ ok: branchNames.has(branch), label: `branch exists: ${branch}` });
|
|
4155
|
-
|
|
5015
|
+
const protection = await getProtection(deps, repo, branch);
|
|
5016
|
+
checks.push({ ok: protection != null, label: `branch protection exists: ${branch}` });
|
|
5017
|
+
checks.push({
|
|
5018
|
+
ok: hasPushAllowlist(protection),
|
|
5019
|
+
label: `push allowlist configured: ${branch}`,
|
|
5020
|
+
detail: hasPushAllowlist(protection) ? void 0 : "restrictions.users is empty or unset"
|
|
5021
|
+
});
|
|
4156
5022
|
}
|
|
5023
|
+
const owners = new Set(await resolveOwners(deps));
|
|
5024
|
+
const overgrants = await auditRepoCollaborators(repo, owners, deps);
|
|
5025
|
+
checks.push({
|
|
5026
|
+
ok: overgrants.length === 0,
|
|
5027
|
+
label: "collaborator roles are master-only (no admin/maintain over-grant)",
|
|
5028
|
+
detail: overgrants.length ? `over-granted: ${overgrants.map((f) => f.actor).join(", ")}` : void 0
|
|
5029
|
+
});
|
|
4157
5030
|
for (const path of requiredDocs) {
|
|
4158
5031
|
checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `bootstrap artifact exists: ${path}` });
|
|
4159
5032
|
}
|
|
@@ -4163,36 +5036,42 @@ async function verifyBootstrap(repo, repoClass, deps) {
|
|
|
4163
5036
|
for (const path of requiredWorkflows) {
|
|
4164
5037
|
checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `automation workflow exists: ${path}` });
|
|
4165
5038
|
}
|
|
5039
|
+
if (repoClass === "deployable") {
|
|
5040
|
+
const trainScript = "scripts/next-version.mjs";
|
|
5041
|
+
checks.push({ ok: await contentExists(deps, repo, baseBranch, trainScript), label: `train tooling script exists: ${trainScript}` });
|
|
5042
|
+
}
|
|
4166
5043
|
checks.push({ ok: await contentExists(deps, repo, baseBranch, ".cursor/environment.json"), label: "Cursor environment committed" });
|
|
4167
|
-
const labels = await
|
|
5044
|
+
const labels = await ghJson2(deps, ["label", "list", "--repo", repo, "--limit", "200", "--json", "name"], []);
|
|
4168
5045
|
const labelNames = new Set(labels.map((l) => l.name));
|
|
4169
5046
|
for (const label of requiredLabels) {
|
|
4170
5047
|
checks.push({ ok: labelNames.has(label), label: `label exists: ${label}` });
|
|
4171
5048
|
}
|
|
4172
|
-
const
|
|
5049
|
+
const strays = strayDefaultLabels.filter((l) => labelNames.has(l));
|
|
5050
|
+
checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: presentDetail(strays) });
|
|
5051
|
+
const actions = await ghJson2(deps, ["api", `repos/${repo}/actions/permissions`], {});
|
|
4173
5052
|
checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
|
|
4174
|
-
const variables = await
|
|
5053
|
+
const variables = await ghJson2(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
|
|
4175
5054
|
const variableNames = new Set(variables.map((v) => v.name));
|
|
4176
5055
|
for (const variable of requiredActionsVariables) {
|
|
4177
5056
|
checks.push({ ok: variableNames.has(variable), label: `Actions variable exists: ${variable}` });
|
|
4178
5057
|
}
|
|
4179
|
-
const
|
|
4180
|
-
const secretNames = new Set(
|
|
5058
|
+
const secrets2 = await ghJson2(deps, ["secret", "list", "--repo", repo, "--json", "name"], []);
|
|
5059
|
+
const secretNames = new Set(secrets2.map((s) => s.name));
|
|
4181
5060
|
for (const secret of requiredActionsSecrets) {
|
|
4182
5061
|
checks.push({ ok: secretNames.has(secret), label: `Actions secret exists: ${secret}` });
|
|
4183
5062
|
}
|
|
4184
|
-
const config =
|
|
5063
|
+
const config = safeJson2(await contentText(deps, repo, baseBranch, ".mmi/config.json") || "", null);
|
|
4185
5064
|
checks.push({
|
|
4186
5065
|
ok: Boolean(config?.projectOwner && config?.projectNumber),
|
|
4187
5066
|
label: ".mmi project board config exists"
|
|
4188
5067
|
});
|
|
4189
5068
|
if (config?.projectOwner && config.projectNumber != null) {
|
|
4190
|
-
const
|
|
5069
|
+
const project2 = await ghJson2(
|
|
4191
5070
|
deps,
|
|
4192
5071
|
["project", "field-list", String(config.projectNumber), "--owner", config.projectOwner, "--format", "json"],
|
|
4193
5072
|
{}
|
|
4194
5073
|
);
|
|
4195
|
-
const fields =
|
|
5074
|
+
const fields = project2.fields || [];
|
|
4196
5075
|
const statusField = fields.find((field) => field.name === "Status");
|
|
4197
5076
|
const labelField = fields.find((field) => field.name === "Labels");
|
|
4198
5077
|
checks.push({
|
|
@@ -4223,8 +5102,33 @@ async function verifyBootstrap(repo, repoClass, deps) {
|
|
|
4223
5102
|
});
|
|
4224
5103
|
}
|
|
4225
5104
|
}
|
|
5105
|
+
const priorityField = fields.find((field) => field.name === "Priority" && (field.options?.length ?? 0) > 0);
|
|
5106
|
+
checks.push({
|
|
5107
|
+
ok: Boolean(priorityField),
|
|
5108
|
+
label: `Project Priority field exists (API-writable): ${config.projectOwner}#${config.projectNumber}`
|
|
5109
|
+
});
|
|
5110
|
+
if (priorityField != null) {
|
|
5111
|
+
const priorityNames = new Set((priorityField.options || []).map((option) => option.name));
|
|
5112
|
+
const missingPriority = requiredPriorityOptions.filter((option) => !priorityNames.has(option));
|
|
5113
|
+
checks.push({
|
|
5114
|
+
ok: missingPriority.length === 0,
|
|
5115
|
+
label: "Project Priority options configured",
|
|
5116
|
+
detail: optionDetail(missingPriority)
|
|
5117
|
+
});
|
|
5118
|
+
checks.push({
|
|
5119
|
+
ok: config.priorityFieldId === priorityField.id,
|
|
5120
|
+
label: ".mmi priorityFieldId matches project"
|
|
5121
|
+
});
|
|
5122
|
+
for (const optionName of requiredPriorityOptions) {
|
|
5123
|
+
const projectOption = priorityField.options?.find((option) => option.name === optionName);
|
|
5124
|
+
checks.push({
|
|
5125
|
+
ok: Boolean(projectOption?.id && config.priorityOptions?.[optionName] === projectOption.id),
|
|
5126
|
+
label: `.mmi priority option matches project: ${optionName}`
|
|
5127
|
+
});
|
|
5128
|
+
}
|
|
5129
|
+
}
|
|
4226
5130
|
const workflowQuery = "query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { workflows(first: 30) { nodes { name enabled } } } } }";
|
|
4227
|
-
const workflowResponse = await
|
|
5131
|
+
const workflowResponse = await ghJson2(
|
|
4228
5132
|
deps,
|
|
4229
5133
|
["api", "graphql", "-f", `query=${workflowQuery}`, "-f", `login=${config.projectOwner}`, "-F", `number=${config.projectNumber}`],
|
|
4230
5134
|
{}
|
|
@@ -4241,22 +5145,257 @@ async function verifyBootstrap(repo, repoClass, deps) {
|
|
|
4241
5145
|
if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
|
|
4242
5146
|
const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
|
|
4243
5147
|
if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
|
|
4244
|
-
|
|
5148
|
+
const rulesetList = await ghJson2(
|
|
5149
|
+
deps,
|
|
5150
|
+
["api", `repos/${repo}/rulesets?includes_parents=true`],
|
|
5151
|
+
[]
|
|
5152
|
+
);
|
|
5153
|
+
const rulesets = await rulesetDetails(deps, repo, rulesetList);
|
|
5154
|
+
const activeOrgRulesets = rulesets.filter(
|
|
5155
|
+
(r) => r.source_type === "Organization" && r.target === "branch" && r.enforcement === "active"
|
|
5156
|
+
);
|
|
5157
|
+
const orgRuleset = activeOrgRulesets.find((ruleset) => missingRuleTypes(ruleset, requiredOrgRulesetTypes).length === 0);
|
|
5158
|
+
const missingOrgRuleTypes = activeOrgRulesets.length === 0 ? requiredOrgRulesetTypes : missingRuleTypes(activeOrgRulesets[0], requiredOrgRulesetTypes);
|
|
5159
|
+
checks.push({
|
|
5160
|
+
ok: Boolean(orgRuleset),
|
|
5161
|
+
label: "covered by an active org ruleset",
|
|
5162
|
+
detail: orgRuleset ? void 0 : activeOrgRulesets.length === 0 ? "no active Organization-sourced branch ruleset targets this repo" : `missing rule types: ${missingOrgRuleTypes.join(", ")}`
|
|
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
|
+
}
|
|
5173
|
+
const waived = applyWaivers(checks, config?.verifyWaivers ?? []);
|
|
5174
|
+
return { ok: waived.every((c) => c.ok || c.waived), repo, class: repoClass, baseBranch, checks: waived };
|
|
5175
|
+
}
|
|
5176
|
+
function applyWaivers(checks, waivers) {
|
|
5177
|
+
if (!waivers?.length) return checks;
|
|
5178
|
+
const set = new Set(waivers);
|
|
5179
|
+
return checks.map((c) => !c.ok && set.has(c.label) ? { ...c, waived: true } : c);
|
|
4245
5180
|
}
|
|
4246
5181
|
function renderBootstrapVerifyReport(report) {
|
|
4247
5182
|
const lines = [`mmi-cli bootstrap verify: ${report.ok ? "OK" : "CHECK"} ${report.repo} (${report.class}, ${report.baseBranch})`];
|
|
4248
5183
|
for (const check of report.checks) {
|
|
4249
|
-
|
|
5184
|
+
const status = check.ok ? "OK" : check.waived ? "WAIVE" : "FAIL";
|
|
5185
|
+
lines.push(`${status} ${check.label}${check.detail ? ` - ${check.detail}` : ""}`);
|
|
5186
|
+
}
|
|
5187
|
+
return lines.join("\n");
|
|
5188
|
+
}
|
|
5189
|
+
|
|
5190
|
+
// src/bootstrap-apply.ts
|
|
5191
|
+
function planSeedAction(seed, exists) {
|
|
5192
|
+
if (seed.source === "fanout") {
|
|
5193
|
+
return { target: seed.target, action: "skip", ownership: "fanout", reason: "delivered by the fanout pipeline" };
|
|
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
|
+
}
|
|
5198
|
+
if (seed.ownership === "repo") {
|
|
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" };
|
|
4250
5200
|
}
|
|
5201
|
+
return exists ? { target: seed.target, action: "update", ownership: "org", reason: "org-owned, refresh to current" } : { target: seed.target, action: "create", ownership: "org", reason: "org-owned, missing" };
|
|
5202
|
+
}
|
|
5203
|
+
function renderSeedPlan(actions) {
|
|
5204
|
+
const lines = ["bootstrap apply \u2014 seed plan (dry-run; no mutations):"];
|
|
5205
|
+
for (const a of actions) {
|
|
5206
|
+
lines.push(` ${a.action.toUpperCase().padEnd(6)} ${a.target} (${a.ownership}: ${a.reason})`);
|
|
5207
|
+
}
|
|
5208
|
+
const order = ["create", "update", "skip"];
|
|
5209
|
+
lines.push(` \u2014 ${order.map((k) => `${actions.filter((a) => a.action === k).length} ${k}`).join(", ")}`);
|
|
4251
5210
|
return lines.join("\n");
|
|
4252
5211
|
}
|
|
5212
|
+
function resolveSeedContent(seed, vars, readFile2) {
|
|
5213
|
+
if (seed.source === "self") return readFile2(seed.target);
|
|
5214
|
+
if (seed.source.startsWith("seed:")) {
|
|
5215
|
+
const tmpl = readFile2(`skills/bootstrap/seeds/${seed.source.slice("seed:".length)}`);
|
|
5216
|
+
return tmpl == null ? null : renderSeed(tmpl, vars);
|
|
5217
|
+
}
|
|
5218
|
+
return null;
|
|
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
|
+
}
|
|
5263
|
+
function contentPutArgs(repo, path, content, branch, sha) {
|
|
5264
|
+
const args = [
|
|
5265
|
+
"api",
|
|
5266
|
+
"-X",
|
|
5267
|
+
"PUT",
|
|
5268
|
+
`repos/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}`,
|
|
5269
|
+
"-f",
|
|
5270
|
+
`message=bootstrap: seed ${path}`,
|
|
5271
|
+
"-f",
|
|
5272
|
+
`content=${Buffer.from(content, "utf8").toString("base64")}`,
|
|
5273
|
+
"-f",
|
|
5274
|
+
`branch=${branch}`
|
|
5275
|
+
];
|
|
5276
|
+
if (sha) args.push("-f", `sha=${sha}`);
|
|
5277
|
+
return args;
|
|
5278
|
+
}
|
|
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
|
+
|
|
5367
|
+
// src/kb.ts
|
|
5368
|
+
var DEFAULT_KB = { owner: "mutmutco", repo: "MM-KB", ref: "main" };
|
|
5369
|
+
function resolveKbSource(rawBase) {
|
|
5370
|
+
if (!rawBase) return DEFAULT_KB;
|
|
5371
|
+
const m = rawBase.match(/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/?#]+)/);
|
|
5372
|
+
if (!m) return DEFAULT_KB;
|
|
5373
|
+
return { owner: m[1], repo: m[2], ref: m[3] };
|
|
5374
|
+
}
|
|
5375
|
+
function buildKbGetArgs(src, path) {
|
|
5376
|
+
const clean = path.replace(/^\/+/, "");
|
|
5377
|
+
return ["api", `repos/${src.owner}/${src.repo}/contents/${clean}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
|
|
5378
|
+
}
|
|
5379
|
+
function buildKbTreeArgs(src) {
|
|
5380
|
+
return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
|
|
5381
|
+
}
|
|
5382
|
+
function parseKbTree(stdout, prefix) {
|
|
5383
|
+
let tree;
|
|
5384
|
+
try {
|
|
5385
|
+
tree = JSON.parse(stdout)?.tree ?? [];
|
|
5386
|
+
} catch {
|
|
5387
|
+
return [];
|
|
5388
|
+
}
|
|
5389
|
+
const pre = prefix ? prefix.replace(/^\/+/, "") : void 0;
|
|
5390
|
+
return tree.filter((t) => t.type === "blob" && typeof t.path === "string" && t.path.startsWith("kb/")).map((t) => t.path).filter((p) => pre ? p.startsWith(pre) : true).sort();
|
|
5391
|
+
}
|
|
4253
5392
|
|
|
4254
5393
|
// src/plan.ts
|
|
4255
5394
|
var import_node_path3 = require("node:path");
|
|
4256
5395
|
var PLANS_DIR = "plans";
|
|
4257
5396
|
var META_FILE = (0, import_node_path3.join)(PLANS_DIR, ".plan-meta.json");
|
|
4258
5397
|
var planPath = (slug) => (0, import_node_path3.join)(PLANS_DIR, `${slug}.md`);
|
|
4259
|
-
var metaKey = (
|
|
5398
|
+
var metaKey = (project2, slug) => `${project2}/${slug}`;
|
|
4260
5399
|
function parseMeta(raw) {
|
|
4261
5400
|
if (!raw) return {};
|
|
4262
5401
|
try {
|
|
@@ -4278,23 +5417,46 @@ function hashContent(s) {
|
|
|
4278
5417
|
return (h >>> 0).toString(16);
|
|
4279
5418
|
}
|
|
4280
5419
|
function staleHint(slug) {
|
|
4281
|
-
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`;
|
|
4282
5421
|
}
|
|
4283
5422
|
function formatPlanList(plans) {
|
|
4284
5423
|
return plans.map((p) => `${p.slug} \xB7 ${p.updatedAt ?? "-"} \xB7 ${p.project}`).join("\n");
|
|
4285
5424
|
}
|
|
4286
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
|
+
}
|
|
4287
5449
|
async function planPush(deps, slug, opts = {}) {
|
|
4288
5450
|
const raw = deps.readLocal(slug);
|
|
4289
5451
|
if (raw == null) {
|
|
4290
5452
|
deps.err(`no local ${planPath(slug)} to push`);
|
|
4291
|
-
return;
|
|
5453
|
+
return false;
|
|
4292
5454
|
}
|
|
4293
5455
|
const content = normalizeEol(raw);
|
|
4294
|
-
const
|
|
5456
|
+
const project2 = opts.project ?? await deps.project();
|
|
4295
5457
|
const meta = parseMeta(deps.readMetaRaw());
|
|
4296
|
-
const entry = meta[metaKey(
|
|
4297
|
-
const body = { project, slug, content };
|
|
5458
|
+
const entry = meta[metaKey(project2, slug)];
|
|
5459
|
+
const body = { project: project2, slug, content };
|
|
4298
5460
|
if (opts.force) body.force = true;
|
|
4299
5461
|
else if (entry?.etag) body.baseEtag = entry.etag;
|
|
4300
5462
|
const res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
|
|
@@ -4305,25 +5467,28 @@ async function planPush(deps, slug, opts = {}) {
|
|
|
4305
5467
|
});
|
|
4306
5468
|
if (res.ok) {
|
|
4307
5469
|
const out = await res.json();
|
|
4308
|
-
meta[metaKey(
|
|
5470
|
+
meta[metaKey(project2, slug)] = { etag: out.etag, hash: hashContent(content), syncedAt: deps.now() };
|
|
4309
5471
|
deps.writeMetaRaw(serializeMeta(meta));
|
|
4310
5472
|
deps.log(`pushed ${slug}`);
|
|
5473
|
+
return true;
|
|
4311
5474
|
} else if (res.status === 409) {
|
|
4312
5475
|
deps.err(staleHint(slug));
|
|
5476
|
+
return false;
|
|
4313
5477
|
} else {
|
|
4314
5478
|
deps.err(`plan push failed: HTTP ${res.status}`);
|
|
5479
|
+
return false;
|
|
4315
5480
|
}
|
|
4316
5481
|
}
|
|
4317
5482
|
async function planPull(deps, slug, opts = {}) {
|
|
4318
|
-
const
|
|
5483
|
+
const project2 = opts.project ?? await deps.project();
|
|
4319
5484
|
const meta = parseMeta(deps.readMetaRaw());
|
|
4320
|
-
const entry = meta[metaKey(
|
|
5485
|
+
const entry = meta[metaKey(project2, slug)];
|
|
4321
5486
|
const local = deps.readLocal(slug);
|
|
4322
5487
|
if (local != null && entry && !opts.force && hashContent(normalizeEol(local)) !== entry.hash) {
|
|
4323
5488
|
deps.err(`local ${planPath(slug)} has unpushed edits \u2014 push it, or pull with --force to overwrite`);
|
|
4324
5489
|
return;
|
|
4325
5490
|
}
|
|
4326
|
-
const qs = new URLSearchParams({ project, slug }).toString();
|
|
5491
|
+
const qs = new URLSearchParams({ project: project2, slug }).toString();
|
|
4327
5492
|
const res = await deps.fetch(`${deps.apiUrl}/plan/get?${qs}`, {
|
|
4328
5493
|
method: "GET",
|
|
4329
5494
|
headers: await deps.headers(),
|
|
@@ -4340,7 +5505,7 @@ async function planPull(deps, slug, opts = {}) {
|
|
|
4340
5505
|
const doc = await res.json();
|
|
4341
5506
|
const content = normalizeEol(doc.content ?? "");
|
|
4342
5507
|
deps.writeLocal(slug, content);
|
|
4343
|
-
meta[metaKey(
|
|
5508
|
+
meta[metaKey(project2, slug)] = { etag: doc.etag, hash: hashContent(content), syncedAt: deps.now() };
|
|
4344
5509
|
deps.writeMetaRaw(serializeMeta(meta));
|
|
4345
5510
|
deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
|
|
4346
5511
|
}
|
|
@@ -4373,26 +5538,331 @@ async function planList(deps, opts = {}) {
|
|
|
4373
5538
|
deps.log(formatPlanList(plans));
|
|
4374
5539
|
}
|
|
4375
5540
|
async function planDelete(deps, slug, opts = {}) {
|
|
4376
|
-
const
|
|
5541
|
+
const project2 = opts.project ?? await deps.project();
|
|
4377
5542
|
const res = await deps.fetch(`${deps.apiUrl}/plan/delete`, {
|
|
4378
5543
|
method: "POST",
|
|
4379
5544
|
headers: await deps.headers({ "content-type": "application/json" }),
|
|
4380
|
-
body: JSON.stringify({ project, slug }),
|
|
4381
|
-
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
5545
|
+
body: JSON.stringify({ project: project2, slug }),
|
|
5546
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
5547
|
+
});
|
|
5548
|
+
if (!res.ok) {
|
|
5549
|
+
deps.err(`plan delete failed: HTTP ${res.status}`);
|
|
5550
|
+
return;
|
|
5551
|
+
}
|
|
5552
|
+
deps.removeLocal(slug);
|
|
5553
|
+
const meta = parseMeta(deps.readMetaRaw());
|
|
5554
|
+
delete meta[metaKey(project2, slug)];
|
|
5555
|
+
deps.writeMetaRaw(serializeMeta(meta));
|
|
5556
|
+
deps.log(`deleted ${slug}`);
|
|
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
|
+
}
|
|
5577
|
+
|
|
5578
|
+
// src/secrets.ts
|
|
5579
|
+
var OWNER2 = "mutmutco";
|
|
5580
|
+
var SSM_ROOT = "/mmi-future";
|
|
5581
|
+
var PROJECT_TIER_SEGMENT = "dev";
|
|
5582
|
+
var KEY_RE = /^(?:[a-z][a-z0-9-]*\/)?[A-Za-z][A-Za-z0-9_]*$/;
|
|
5583
|
+
function isValidSecretKey(key) {
|
|
5584
|
+
if (!key || key.length > 256) return false;
|
|
5585
|
+
if (key.includes("..") || key.startsWith("/") || key.includes("*")) return false;
|
|
5586
|
+
return KEY_RE.test(key);
|
|
5587
|
+
}
|
|
5588
|
+
function classifyTier(_slug, key) {
|
|
5589
|
+
const slash = key.indexOf("/");
|
|
5590
|
+
if (slash === -1) return "project";
|
|
5591
|
+
return key.slice(0, slash) === PROJECT_TIER_SEGMENT ? "project" : "org";
|
|
5592
|
+
}
|
|
5593
|
+
function secretParamName(slug, key) {
|
|
5594
|
+
const rel = key.includes("/") ? key : `${PROJECT_TIER_SEGMENT}/${key}`;
|
|
5595
|
+
return `${SSM_ROOT}/${slug}/${rel}`;
|
|
5596
|
+
}
|
|
5597
|
+
function formatSecretList(items) {
|
|
5598
|
+
if (!items.length) return "no secrets";
|
|
5599
|
+
const width = Math.max(...items.map((i) => i.key.length));
|
|
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.");
|
|
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
|
+
}
|
|
5634
|
+
var TIMEOUT_MS2 = 8e3;
|
|
5635
|
+
var repoOf = (slug) => `${OWNER2}/${slug}`;
|
|
5636
|
+
async function targetRepo(deps, opts) {
|
|
5637
|
+
return opts.repo ?? repoOf(await deps.slug());
|
|
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
|
+
}
|
|
5643
|
+
async function readErr(res) {
|
|
5644
|
+
try {
|
|
5645
|
+
const j = await res.json();
|
|
5646
|
+
return j?.error ? `: ${j.error}` : "";
|
|
5647
|
+
} catch {
|
|
5648
|
+
return "";
|
|
5649
|
+
}
|
|
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
|
+
}
|
|
5668
|
+
async function secretsList(deps, opts) {
|
|
5669
|
+
const repo = await targetRepo(deps, opts);
|
|
5670
|
+
const qs = new URLSearchParams({ repo }).toString();
|
|
5671
|
+
let res;
|
|
5672
|
+
try {
|
|
5673
|
+
res = await deps.fetch(`${deps.apiUrl}/secrets/list?${qs}`, {
|
|
5674
|
+
method: "GET",
|
|
5675
|
+
headers: await deps.headers(),
|
|
5676
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5677
|
+
});
|
|
5678
|
+
} catch (e) {
|
|
5679
|
+
deps.err(`secrets list: ${e.message}`);
|
|
5680
|
+
return;
|
|
5681
|
+
}
|
|
5682
|
+
if (!res.ok) {
|
|
5683
|
+
deps.err(`secrets list failed: HTTP ${res.status}${await readErr(res)}`);
|
|
5684
|
+
return;
|
|
5685
|
+
}
|
|
5686
|
+
const { secrets: secrets2 } = await res.json();
|
|
5687
|
+
deps.log(formatSecretList(secrets2 ?? []));
|
|
5688
|
+
}
|
|
5689
|
+
async function secretsGet(deps, key, opts) {
|
|
5690
|
+
if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
5691
|
+
const repo = await targetRepo(deps, opts);
|
|
5692
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/get`, {
|
|
5693
|
+
method: "POST",
|
|
5694
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5695
|
+
body: JSON.stringify({ repo, key }),
|
|
5696
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5697
|
+
});
|
|
5698
|
+
if (!res.ok) {
|
|
5699
|
+
deps.err(
|
|
5700
|
+
res.status === 403 ? `secrets get: not authorized for ${key} (HTTP 403)${await readErr(res)}` : `secrets get failed: HTTP ${res.status}${await readErr(res)}`
|
|
5701
|
+
);
|
|
5702
|
+
return;
|
|
5703
|
+
}
|
|
5704
|
+
const { value } = await res.json();
|
|
5705
|
+
deps.log(value ?? "");
|
|
5706
|
+
}
|
|
5707
|
+
async function secretsSet(deps, key, opts) {
|
|
5708
|
+
if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
5709
|
+
const repo = await targetRepo(deps, opts);
|
|
5710
|
+
const value = await deps.readSecretValue(`value for ${key} (input hidden; will not be echoed): `);
|
|
5711
|
+
if (!value) {
|
|
5712
|
+
deps.err("secrets set: empty value \u2014 aborted (nothing written)");
|
|
5713
|
+
return;
|
|
5714
|
+
}
|
|
5715
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/set`, {
|
|
5716
|
+
method: "POST",
|
|
5717
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5718
|
+
body: JSON.stringify({ repo, key, value }),
|
|
5719
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5720
|
+
});
|
|
5721
|
+
if (!res.ok) {
|
|
5722
|
+
deps.err(
|
|
5723
|
+
res.status === 403 ? `secrets set: not authorized to write ${key} (HTTP 403)${await readErr(res)}` : `secrets set failed: HTTP ${res.status}${await readErr(res)}`
|
|
5724
|
+
);
|
|
5725
|
+
return;
|
|
5726
|
+
}
|
|
5727
|
+
deps.log(`set ${key} (${classifyTier(await deps.slug(), key)} tier)`);
|
|
5728
|
+
}
|
|
5729
|
+
async function secretsEdit(deps, key, opts) {
|
|
5730
|
+
return secretsSet(deps, key, opts);
|
|
5731
|
+
}
|
|
5732
|
+
async function secretsRemove(deps, key, opts) {
|
|
5733
|
+
if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
5734
|
+
const repo = await targetRepo(deps, opts);
|
|
5735
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/rm`, {
|
|
5736
|
+
method: "POST",
|
|
5737
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5738
|
+
body: JSON.stringify({ repo, key }),
|
|
5739
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5740
|
+
});
|
|
5741
|
+
if (!res.ok) {
|
|
5742
|
+
deps.err(
|
|
5743
|
+
res.status === 403 ? `secrets rm: not authorized to remove ${key} (HTTP 403)${await readErr(res)}` : `secrets rm failed: HTTP ${res.status}${await readErr(res)}`
|
|
5744
|
+
);
|
|
5745
|
+
return;
|
|
5746
|
+
}
|
|
5747
|
+
deps.log(`removed ${key}`);
|
|
5748
|
+
}
|
|
5749
|
+
async function secretsGrant(deps, repo, login, key, _opts) {
|
|
5750
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/grant`, {
|
|
5751
|
+
method: "POST",
|
|
5752
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5753
|
+
body: JSON.stringify({ repo, login, key }),
|
|
5754
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5755
|
+
});
|
|
5756
|
+
if (!res.ok) {
|
|
5757
|
+
deps.err(
|
|
5758
|
+
res.status === 403 ? `secrets grant: master-admin only (HTTP 403)${await readErr(res)}` : `secrets grant failed: HTTP ${res.status}${await readErr(res)}`
|
|
5759
|
+
);
|
|
5760
|
+
return;
|
|
5761
|
+
}
|
|
5762
|
+
deps.log(`granted @${login} access to ${key} in ${repo}`);
|
|
5763
|
+
}
|
|
5764
|
+
async function secretsRevoke(deps, repo, login, key, _opts) {
|
|
5765
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/revoke`, {
|
|
5766
|
+
method: "POST",
|
|
5767
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5768
|
+
body: JSON.stringify({ repo, login, key }),
|
|
5769
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
4382
5770
|
});
|
|
4383
5771
|
if (!res.ok) {
|
|
4384
|
-
deps.err(
|
|
5772
|
+
deps.err(
|
|
5773
|
+
res.status === 403 ? `secrets revoke: master-admin only (HTTP 403)${await readErr(res)}` : `secrets revoke failed: HTTP ${res.status}${await readErr(res)}`
|
|
5774
|
+
);
|
|
4385
5775
|
return;
|
|
4386
5776
|
}
|
|
4387
|
-
deps.
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
deps.
|
|
4391
|
-
|
|
5777
|
+
deps.log(`revoked @${login}'s access to ${key} in ${repo}`);
|
|
5778
|
+
}
|
|
5779
|
+
async function secretsUse(deps, key, _opts) {
|
|
5780
|
+
const slug = await deps.slug();
|
|
5781
|
+
const tier = classifyTier(slug, key);
|
|
5782
|
+
const path = secretParamName(slug, key);
|
|
5783
|
+
deps.log(
|
|
5784
|
+
[
|
|
5785
|
+
`${key} \u2192 ${path} (${tier} tier)`,
|
|
5786
|
+
"",
|
|
5787
|
+
"Consume it WITHOUT committing it:",
|
|
5788
|
+
` \u2022 Runtime / agents: read it keylessly at runtime via the box's OIDC role (it can read its own ${tier} tier). Never bake it into an image or commit it.`,
|
|
5789
|
+
` \u2022 CI (GitHub Actions): the workflow assumes its OIDC role and runs \`aws ssm get-parameter --with-decryption --name ${path}\` \u2014 no GitHub secret.`,
|
|
5790
|
+
" \u2022 Local dev: pull it into a gitignored .env from the vault \u2014 `mmi-cli secrets get " + key + " > /dev/null` to confirm access, then export it in your shell. Never paste it into tracked files or chat.",
|
|
5791
|
+
tier === "project" ? " \u2022 If this dev secret graduates to a real prod credential, ask the master to promote it to the org tier (rc/ or main/)." : " \u2022 This is an ORG-tier secret \u2014 master-gated. If you need standing access, ask the master for a `secrets grant`."
|
|
5792
|
+
].join("\n")
|
|
5793
|
+
);
|
|
5794
|
+
}
|
|
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);
|
|
4392
5857
|
}
|
|
4393
5858
|
|
|
4394
5859
|
// src/index.ts
|
|
4395
|
-
var
|
|
5860
|
+
var rawExecFileP2 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
|
|
5861
|
+
var execFileP3 = (file, args, options = {}) => (
|
|
5862
|
+
// encoding 'utf8' guarantees string stdout/stderr at runtime; the cast pins the type because
|
|
5863
|
+
// promisify(execFile)'s overloads widen to string|Buffer when options is spread in.
|
|
5864
|
+
rawExecFileP2(file, args, { encoding: "utf8", windowsHide: true, ...options })
|
|
5865
|
+
);
|
|
4396
5866
|
var GIT_TIMEOUT_MS = 1e4;
|
|
4397
5867
|
var GC_GH_TIMEOUT_MS = 2e4;
|
|
4398
5868
|
async function githubToken() {
|
|
@@ -4413,19 +5883,60 @@ async function githubLogin() {
|
|
|
4413
5883
|
return void 0;
|
|
4414
5884
|
}
|
|
4415
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
|
+
}
|
|
4416
5898
|
async function sagaHeaders(extra = {}) {
|
|
4417
5899
|
const t = await githubToken();
|
|
4418
5900
|
return t ? { ...extra, Authorization: `Bearer ${t}` } : extra;
|
|
4419
5901
|
}
|
|
4420
5902
|
async function loadConfig() {
|
|
5903
|
+
let file = {};
|
|
4421
5904
|
try {
|
|
4422
|
-
|
|
5905
|
+
file = JSON.parse(await (0, import_promises.readFile)(".mmi/config.json", "utf8"));
|
|
4423
5906
|
} catch {
|
|
4424
|
-
|
|
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;
|
|
4425
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();
|
|
4426
5938
|
}
|
|
4427
5939
|
var DEFAULT_RULES_SOURCE = "https://raw.githubusercontent.com/mutmutco/MMI-Hub/development";
|
|
4428
|
-
var DEFAULT_KB_SOURCE = "https://raw.githubusercontent.com/mutmutco/MM-KB/main";
|
|
4429
5940
|
var SESSION_FILE = ".mmi/.session";
|
|
4430
5941
|
var gitOut = async (args) => {
|
|
4431
5942
|
try {
|
|
@@ -4439,7 +5950,7 @@ function sessionDeps() {
|
|
|
4439
5950
|
env: process.env,
|
|
4440
5951
|
readPersisted: () => {
|
|
4441
5952
|
try {
|
|
4442
|
-
return (0,
|
|
5953
|
+
return (0, import_node_fs4.readFileSync)(SESSION_FILE, "utf8");
|
|
4443
5954
|
} catch {
|
|
4444
5955
|
return null;
|
|
4445
5956
|
}
|
|
@@ -4452,8 +5963,8 @@ function sessionDeps() {
|
|
|
4452
5963
|
var resolveSessionId = () => resolveSession(sessionDeps());
|
|
4453
5964
|
function persistSession(id) {
|
|
4454
5965
|
try {
|
|
4455
|
-
(0,
|
|
4456
|
-
(0,
|
|
5966
|
+
(0, import_node_fs4.mkdirSync)(".mmi", { recursive: true });
|
|
5967
|
+
(0, import_node_fs4.writeFileSync)(SESSION_FILE, id, "utf8");
|
|
4457
5968
|
} catch {
|
|
4458
5969
|
}
|
|
4459
5970
|
}
|
|
@@ -4473,7 +5984,11 @@ async function postCapture(capture, quiet = false) {
|
|
|
4473
5984
|
method: "POST",
|
|
4474
5985
|
headers: await sagaHeaders({ "content-type": "application/json" }),
|
|
4475
5986
|
body: JSON.stringify({ ...capture, ...await sagaKey(cfg) }),
|
|
4476
|
-
|
|
5987
|
+
// Capture latency is high + variable (server-side HEAD render); 8s dropped larger notes. Match the
|
|
5988
|
+
// head-write timeout (20s) so a continuity note isn't lost to a slow/cold backend. No client retry:
|
|
5989
|
+
// the capture isn't guaranteed idempotent, so a retry after a server-side-completed write could
|
|
5990
|
+
// duplicate the note. Backend capture-latency root cause tracked in #255.
|
|
5991
|
+
signal: AbortSignal.timeout(2e4)
|
|
4477
5992
|
});
|
|
4478
5993
|
if (!quiet) console.log(res.ok ? "noted" : `saga: HTTP ${res.status}`);
|
|
4479
5994
|
} catch (e) {
|
|
@@ -4537,14 +6052,35 @@ async function applyGcPlan(plan2, remote) {
|
|
|
4537
6052
|
await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
|
|
4538
6053
|
}
|
|
4539
6054
|
}
|
|
6055
|
+
async function cleanupLocalBranch(branch) {
|
|
6056
|
+
const result = { branchDeleted: false };
|
|
6057
|
+
if (!branch) return result;
|
|
6058
|
+
const { stdout } = await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
|
|
6059
|
+
const wt = parseWorktreePorcelain(stdout).find((w) => w.branch === branch);
|
|
6060
|
+
if (wt) {
|
|
6061
|
+
await execFileP3("git", ["worktree", "remove", "--force", wt.path], { timeout: GIT_TIMEOUT_MS }).catch(() => {
|
|
6062
|
+
});
|
|
6063
|
+
result.worktreeRemoved = wt.path;
|
|
6064
|
+
}
|
|
6065
|
+
const current = await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "";
|
|
6066
|
+
if (branch !== current) {
|
|
6067
|
+
await execFileP3("git", ["branch", "-D", branch], { timeout: GIT_TIMEOUT_MS }).then(() => {
|
|
6068
|
+
result.branchDeleted = true;
|
|
6069
|
+
}).catch(() => {
|
|
6070
|
+
});
|
|
6071
|
+
}
|
|
6072
|
+
if (wt) await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS }).catch(() => {
|
|
6073
|
+
});
|
|
6074
|
+
return result;
|
|
6075
|
+
}
|
|
4540
6076
|
function resolveVersion() {
|
|
4541
6077
|
try {
|
|
4542
6078
|
const manifest = (0, import_node_path4.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
|
|
4543
|
-
return JSON.parse((0,
|
|
6079
|
+
return JSON.parse((0, import_node_fs4.readFileSync)(manifest, "utf8")).version || "0.0.0";
|
|
4544
6080
|
} catch {
|
|
4545
6081
|
try {
|
|
4546
6082
|
const pkg = (0, import_node_path4.join)(__dirname, "..", "package.json");
|
|
4547
|
-
return JSON.parse((0,
|
|
6083
|
+
return JSON.parse((0, import_node_fs4.readFileSync)(pkg, "utf8")).version || "0.0.0";
|
|
4548
6084
|
} catch {
|
|
4549
6085
|
return "0.0.0";
|
|
4550
6086
|
}
|
|
@@ -4552,22 +6088,44 @@ function resolveVersion() {
|
|
|
4552
6088
|
}
|
|
4553
6089
|
function readRepoVersion() {
|
|
4554
6090
|
try {
|
|
4555
|
-
return JSON.parse((0,
|
|
6091
|
+
return JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path4.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
|
|
4556
6092
|
} catch {
|
|
4557
6093
|
return void 0;
|
|
4558
6094
|
}
|
|
4559
6095
|
}
|
|
4560
6096
|
async function fetchReleasedVersion() {
|
|
4561
6097
|
try {
|
|
4562
|
-
const
|
|
4563
|
-
|
|
4564
|
-
});
|
|
4565
|
-
if (!res.ok) return void 0;
|
|
4566
|
-
return (await res.json()).version;
|
|
6098
|
+
const { stdout } = await execFileP3("gh", pluginManifestVersionArgs(), { timeout: 5e3 });
|
|
6099
|
+
return parseManifestVersion(stdout);
|
|
4567
6100
|
} catch {
|
|
4568
6101
|
return void 0;
|
|
4569
6102
|
}
|
|
4570
6103
|
}
|
|
6104
|
+
var NPM_UPDATE_TIMEOUT_MS = 12e4;
|
|
6105
|
+
var PLUGIN_PULL_TIMEOUT_MS = 3e4;
|
|
6106
|
+
async function applyVersionAutoUpdate(report, log) {
|
|
6107
|
+
const action = versionAutoUpdateAction(report, Boolean(process.env.CLAUDE_PLUGIN_ROOT));
|
|
6108
|
+
if (action === "none") return report;
|
|
6109
|
+
const target = report.releasedVersion ?? "latest";
|
|
6110
|
+
if (action === "plugin-pull") {
|
|
6111
|
+
try {
|
|
6112
|
+
const root = (await execFileP3("git", ["-C", process.env.CLAUDE_PLUGIN_ROOT, "rev-parse", "--show-toplevel"], { timeout: PLUGIN_PULL_TIMEOUT_MS })).stdout.trim();
|
|
6113
|
+
log(` \u21BB refreshing MMI plugin ${report.currentVersion} \u2192 ${target} (effective next session)\u2026`);
|
|
6114
|
+
await execFileP3("git", ["-C", root, "pull", "--ff-only"], { timeout: PLUGIN_PULL_TIMEOUT_MS });
|
|
6115
|
+
return { ...report, ok: true };
|
|
6116
|
+
} catch {
|
|
6117
|
+
return report;
|
|
6118
|
+
}
|
|
6119
|
+
}
|
|
6120
|
+
try {
|
|
6121
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
6122
|
+
log(` \u21BB updating mmi-cli ${report.currentVersion} \u2192 ${target}\u2026`);
|
|
6123
|
+
await execFileP3(npm, ["install", "-g", "@mutmutco/cli@latest"], { timeout: NPM_UPDATE_TIMEOUT_MS });
|
|
6124
|
+
return { ...report, ok: true };
|
|
6125
|
+
} catch {
|
|
6126
|
+
return report;
|
|
6127
|
+
}
|
|
6128
|
+
}
|
|
4571
6129
|
var program2 = new Command();
|
|
4572
6130
|
program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveVersion());
|
|
4573
6131
|
var rules = program2.command("rules").description("org rules delivery");
|
|
@@ -4577,7 +6135,7 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
|
|
|
4577
6135
|
if (!opts.quiet) console.log('mmi-cli rules: source repo (orgRulesSource: "self") \u2014 skipping self-sync');
|
|
4578
6136
|
return;
|
|
4579
6137
|
}
|
|
4580
|
-
const base = (cfg.orgRulesSource
|
|
6138
|
+
const base = resolveRulesBase(cfg.orgRulesSource, DEFAULT_RULES_SOURCE);
|
|
4581
6139
|
const token = await githubToken();
|
|
4582
6140
|
let changed = 0;
|
|
4583
6141
|
for (const file of ["AGENTS.md", "CLAUDE.md", ".claude/settings.json"]) {
|
|
@@ -4591,10 +6149,10 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
|
|
|
4591
6149
|
if (!opts.quiet) console.error(`mmi-cli rules: could not fetch ${file} (${e.message}); left it untouched`);
|
|
4592
6150
|
continue;
|
|
4593
6151
|
}
|
|
4594
|
-
const current = (0,
|
|
6152
|
+
const current = (0, import_node_fs4.existsSync)(file) ? await (0, import_promises.readFile)(file, "utf8") : null;
|
|
4595
6153
|
if (needsUpdate(source, current)) {
|
|
4596
6154
|
const slash = file.lastIndexOf("/");
|
|
4597
|
-
if (slash > 0) (0,
|
|
6155
|
+
if (slash > 0) (0, import_node_fs4.mkdirSync)(file.slice(0, slash), { recursive: true });
|
|
4598
6156
|
await (0, import_promises.writeFile)(file, normalizeEol(source), "utf8");
|
|
4599
6157
|
changed++;
|
|
4600
6158
|
if (!opts.quiet) console.log(`mmi-cli rules: updated ${file}`);
|
|
@@ -4602,6 +6160,29 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
|
|
|
4602
6160
|
}
|
|
4603
6161
|
if (!opts.quiet && changed === 0) console.log("mmi-cli rules: up to date");
|
|
4604
6162
|
});
|
|
6163
|
+
var docs = program2.command("docs").description("repo-owned authoritative docs");
|
|
6164
|
+
docs.command("sync").option("--quiet", "stay silent unless something changed or errored").description("refresh README.md / architecture.md from the repo default branch (keeper-authored); never clobbers uncommitted edits").action(async (opts) => {
|
|
6165
|
+
const ref = await gitOut(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
|
|
6166
|
+
const def = (ref.startsWith("origin/") ? ref.slice("origin/".length) : ref) || "development";
|
|
6167
|
+
await gitOut(["fetch", "origin", def, "--quiet"]);
|
|
6168
|
+
const result = await syncDocs({
|
|
6169
|
+
isDirty: async (f) => await gitOut(["status", "--porcelain", "--", f]) !== "",
|
|
6170
|
+
originContent: async (f) => {
|
|
6171
|
+
try {
|
|
6172
|
+
return (await execFileP3("git", ["show", `origin/${def}:${f}`], { maxBuffer: 10 * 1024 * 1024 })).stdout;
|
|
6173
|
+
} catch {
|
|
6174
|
+
return null;
|
|
6175
|
+
}
|
|
6176
|
+
},
|
|
6177
|
+
localContent: async (f) => (0, import_node_fs4.existsSync)(f) ? await (0, import_promises.readFile)(f, "utf8") : null,
|
|
6178
|
+
writeDoc: async (f, c) => {
|
|
6179
|
+
await (0, import_promises.writeFile)(f, c, "utf8");
|
|
6180
|
+
}
|
|
6181
|
+
});
|
|
6182
|
+
for (const f of result.updated) console.log(`mmi-cli docs: updated ${f} (from origin/${def})`);
|
|
6183
|
+
if (!opts.quiet && result.skippedDirty.length) console.log(`mmi-cli docs: kept local edits in ${result.skippedDirty.join(", ")}`);
|
|
6184
|
+
if (!opts.quiet && result.updated.length === 0 && result.skippedDirty.length === 0) console.log("mmi-cli docs: up to date");
|
|
6185
|
+
});
|
|
4605
6186
|
var saga = program2.command("saga").description("per-session continuity");
|
|
4606
6187
|
async function runNote(summary, o) {
|
|
4607
6188
|
const [sha, key] = await Promise.all([gitOut(["rev-parse", "--short", "HEAD"]), sagaKey(await loadConfig())]);
|
|
@@ -4620,7 +6201,10 @@ saga.command("show").option("--quiet", "no-op silently when unconfigured/unreach
|
|
|
4620
6201
|
const key = await sagaKey(cfg);
|
|
4621
6202
|
const qs = opts.latestAnywhere ? "scope=anywhere" : new URLSearchParams({ project: key.project, branch: key.branch }).toString();
|
|
4622
6203
|
const res = await fetch(`${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
|
|
4623
|
-
if (res.ok)
|
|
6204
|
+
if (res.ok) {
|
|
6205
|
+
console.log(resumeCue());
|
|
6206
|
+
return console.log(await res.text());
|
|
6207
|
+
}
|
|
4624
6208
|
if (!opts.quiet) console.log(`saga show failed: HTTP ${res.status}`);
|
|
4625
6209
|
} catch (e) {
|
|
4626
6210
|
if (!opts.quiet) console.error(`saga show: ${e.message}`);
|
|
@@ -4719,11 +6303,28 @@ program2.command("gc").description("dry-run cleanup for merged/closed PR branche
|
|
|
4719
6303
|
fail(`gc: ${e.message}`);
|
|
4720
6304
|
}
|
|
4721
6305
|
});
|
|
4722
|
-
program2.command("kb").description("org knowledgebase (read-only)")
|
|
4723
|
-
|
|
4724
|
-
const
|
|
4725
|
-
|
|
4726
|
-
|
|
6306
|
+
var kb = program2.command("kb").description("org knowledgebase (read-only)");
|
|
6307
|
+
kb.command("get <path>").description("print a KB document by path").action(async (path) => {
|
|
6308
|
+
const src = resolveKbSource((await loadConfig()).kbSource);
|
|
6309
|
+
try {
|
|
6310
|
+
const { stdout } = await execFileP3("gh", buildKbGetArgs(src, path), { timeout: 1e4 });
|
|
6311
|
+
process.stdout.write(stdout);
|
|
6312
|
+
} catch (e) {
|
|
6313
|
+
const err = e;
|
|
6314
|
+
fail(`kb get failed: ${(err.stderr || err.message || String(e)).trim()}`);
|
|
6315
|
+
}
|
|
6316
|
+
});
|
|
6317
|
+
kb.command("list [prefix]").description("list KB document paths (optionally under a prefix)").action(async (prefix) => {
|
|
6318
|
+
const src = resolveKbSource((await loadConfig()).kbSource);
|
|
6319
|
+
try {
|
|
6320
|
+
const { stdout } = await execFileP3("gh", buildKbTreeArgs(src), { timeout: 1e4 });
|
|
6321
|
+
const paths = parseKbTree(stdout, prefix);
|
|
6322
|
+
if (!paths.length) return fail(`kb list: no documents${prefix ? ` under ${prefix}` : ""}`);
|
|
6323
|
+
console.log(paths.join("\n"));
|
|
6324
|
+
} catch (e) {
|
|
6325
|
+
const err = e;
|
|
6326
|
+
fail(`kb list failed: ${(err.stderr || err.message || String(e)).trim()}`);
|
|
6327
|
+
}
|
|
4727
6328
|
});
|
|
4728
6329
|
async function ghCreate(args) {
|
|
4729
6330
|
try {
|
|
@@ -4734,7 +6335,7 @@ async function ghCreate(args) {
|
|
|
4734
6335
|
fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}`);
|
|
4735
6336
|
}
|
|
4736
6337
|
}
|
|
4737
|
-
async function
|
|
6338
|
+
async function ghJson3(args, timeout = 1e4) {
|
|
4738
6339
|
const { stdout } = await execFileP3("gh", args, { timeout });
|
|
4739
6340
|
return JSON.parse(stdout);
|
|
4740
6341
|
}
|
|
@@ -4747,6 +6348,47 @@ async function resolveRepo(repo) {
|
|
|
4747
6348
|
return void 0;
|
|
4748
6349
|
}
|
|
4749
6350
|
}
|
|
6351
|
+
async function attachToProject(issueNumber, repo, priority) {
|
|
6352
|
+
const cfg = await loadConfigOrDiscover();
|
|
6353
|
+
if (!cfg.projectId) return void 0;
|
|
6354
|
+
if (repo) {
|
|
6355
|
+
const skip = boardAttachSkipReason(await resolveRepo(), repo);
|
|
6356
|
+
if (skip) {
|
|
6357
|
+
process.stderr.write(`warning: issue #${issueNumber} NOT added to this board \u2014 ${skip}
|
|
6358
|
+
`);
|
|
6359
|
+
return void 0;
|
|
6360
|
+
}
|
|
6361
|
+
}
|
|
6362
|
+
try {
|
|
6363
|
+
const viewArgs = ["issue", "view", String(issueNumber), "--json", "id", "--jq", ".id"];
|
|
6364
|
+
if (repo) viewArgs.push("--repo", repo);
|
|
6365
|
+
const { stdout: idOut } = await execFileP3("gh", viewArgs, { timeout: 1e4 });
|
|
6366
|
+
const contentId = idOut.trim();
|
|
6367
|
+
if (!contentId) throw new Error("could not resolve issue node id");
|
|
6368
|
+
const { stdout } = await execFileP3("gh", buildAddToProjectArgs(cfg.projectId, contentId), { timeout: 1e4 });
|
|
6369
|
+
const projectItemId = parseAddedItemId(stdout);
|
|
6370
|
+
if (projectItemId && priority) {
|
|
6371
|
+
try {
|
|
6372
|
+
await setBoardItemPriority(
|
|
6373
|
+
async (args) => execFileP3("gh", args, { timeout: 1e4 }),
|
|
6374
|
+
cfg,
|
|
6375
|
+
projectItemId,
|
|
6376
|
+
priority
|
|
6377
|
+
);
|
|
6378
|
+
} catch (e) {
|
|
6379
|
+
const err = e;
|
|
6380
|
+
process.stderr.write(`warning: issue #${issueNumber} board Priority not set: ${(err.stderr || err.message || String(e)).trim()}
|
|
6381
|
+
`);
|
|
6382
|
+
}
|
|
6383
|
+
}
|
|
6384
|
+
return projectItemId;
|
|
6385
|
+
} catch (e) {
|
|
6386
|
+
const err = e;
|
|
6387
|
+
process.stderr.write(`warning: issue #${issueNumber} created but NOT added to the project board: ${(err.stderr || err.message || String(e)).trim()}
|
|
6388
|
+
`);
|
|
6389
|
+
return void 0;
|
|
6390
|
+
}
|
|
6391
|
+
}
|
|
4750
6392
|
function scheduleRelatedDiscovery(o) {
|
|
4751
6393
|
try {
|
|
4752
6394
|
const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
|
|
@@ -4761,7 +6403,7 @@ function scheduleRelatedDiscovery(o) {
|
|
|
4761
6403
|
}
|
|
4762
6404
|
}
|
|
4763
6405
|
function makePlanDeps(cfg) {
|
|
4764
|
-
const ensureDir = () => (0,
|
|
6406
|
+
const ensureDir = () => (0, import_node_fs4.mkdirSync)(PLANS_DIR, { recursive: true });
|
|
4765
6407
|
return {
|
|
4766
6408
|
apiUrl: cfg.sagaApiUrl,
|
|
4767
6409
|
fetch: (url, init) => fetch(url, init),
|
|
@@ -4769,31 +6411,31 @@ function makePlanDeps(cfg) {
|
|
|
4769
6411
|
project: async () => (await sagaKey(cfg)).project,
|
|
4770
6412
|
readLocal: (slug) => {
|
|
4771
6413
|
try {
|
|
4772
|
-
return (0,
|
|
6414
|
+
return (0, import_node_fs4.readFileSync)(planPath(slug), "utf8");
|
|
4773
6415
|
} catch {
|
|
4774
6416
|
return null;
|
|
4775
6417
|
}
|
|
4776
6418
|
},
|
|
4777
6419
|
writeLocal: (slug, content) => {
|
|
4778
6420
|
ensureDir();
|
|
4779
|
-
(0,
|
|
6421
|
+
(0, import_node_fs4.writeFileSync)(planPath(slug), content, "utf8");
|
|
4780
6422
|
},
|
|
4781
6423
|
removeLocal: (slug) => {
|
|
4782
6424
|
try {
|
|
4783
|
-
(0,
|
|
6425
|
+
(0, import_node_fs4.rmSync)(planPath(slug));
|
|
4784
6426
|
} catch {
|
|
4785
6427
|
}
|
|
4786
6428
|
},
|
|
4787
6429
|
readMetaRaw: () => {
|
|
4788
6430
|
try {
|
|
4789
|
-
return (0,
|
|
6431
|
+
return (0, import_node_fs4.readFileSync)(META_FILE, "utf8");
|
|
4790
6432
|
} catch {
|
|
4791
6433
|
return null;
|
|
4792
6434
|
}
|
|
4793
6435
|
},
|
|
4794
6436
|
writeMetaRaw: (raw) => {
|
|
4795
6437
|
ensureDir();
|
|
4796
|
-
(0,
|
|
6438
|
+
(0, import_node_fs4.writeFileSync)(META_FILE, raw, "utf8");
|
|
4797
6439
|
},
|
|
4798
6440
|
log: (m) => console.log(m),
|
|
4799
6441
|
err: (m) => console.error(m),
|
|
@@ -4820,28 +6462,221 @@ async function withPlan(quiet, run) {
|
|
|
4820
6462
|
}
|
|
4821
6463
|
await run(makePlanDeps(cfg));
|
|
4822
6464
|
}
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
(
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
);
|
|
4833
|
-
|
|
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);
|
|
6486
|
+
async function readSecretStdin() {
|
|
6487
|
+
if (process.stdin.isTTY) {
|
|
6488
|
+
process.stderr.write(
|
|
6489
|
+
'secrets set: pipe the value on stdin (it is never an argument) \u2014 e.g.\n printf %s "$VALUE" | mmi-cli secrets set <KEY>\n'
|
|
6490
|
+
);
|
|
6491
|
+
return "";
|
|
6492
|
+
}
|
|
6493
|
+
const chunks = [];
|
|
6494
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
6495
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
6496
|
+
}
|
|
6497
|
+
function makeSecretsDeps(cfg) {
|
|
6498
|
+
return {
|
|
6499
|
+
apiUrl: cfg.sagaApiUrl,
|
|
6500
|
+
fetch: (url, init) => fetch(url, init),
|
|
6501
|
+
headers: (extra) => sagaHeaders(extra),
|
|
6502
|
+
slug: async () => (await sagaKey(cfg)).project,
|
|
6503
|
+
readSecretValue: () => readSecretStdin(),
|
|
6504
|
+
log: (m) => console.log(m),
|
|
6505
|
+
err: (m) => console.error(m)
|
|
6506
|
+
};
|
|
6507
|
+
}
|
|
6508
|
+
async function withSecrets(run) {
|
|
6509
|
+
const cfg = await loadConfig();
|
|
6510
|
+
if (!cfg.sagaApiUrl) {
|
|
6511
|
+
fail("secrets: sagaApiUrl not configured in .mmi/config.json (this repo is not bootstrapped)");
|
|
6512
|
+
return;
|
|
6513
|
+
}
|
|
6514
|
+
await run(makeSecretsDeps(cfg));
|
|
6515
|
+
}
|
|
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)));
|
|
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)));
|
|
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)));
|
|
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)));
|
|
6521
|
+
secrets.command("edit <key>").description("alias for set \u2014 replace a secret value (read from stdin)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsEdit(d, key, o)));
|
|
6522
|
+
secrets.command("rm <key>").description("remove a secret (project tier self-serve; org tier needs a grant)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsRemove(d, key, o)));
|
|
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)));
|
|
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, {})));
|
|
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
|
+
});
|
|
4834
6660
|
var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
|
|
4835
|
-
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>", "high | medium | low (
|
|
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) => {
|
|
4836
6662
|
let args;
|
|
4837
6663
|
try {
|
|
4838
|
-
args = buildIssueArgs({ type: o.type, title: o.title, body: o.body, priority: o.priority, repo: o.repo });
|
|
6664
|
+
args = buildIssueArgs({ type: o.type, title: o.title, body: o.body, priority: o.priority, repo: o.repo, labels: o.label });
|
|
4839
6665
|
} catch (e) {
|
|
4840
6666
|
return fail(`issue create: ${e.message}`);
|
|
4841
6667
|
}
|
|
6668
|
+
for (const label of o.label ?? []) {
|
|
6669
|
+
const la = ["label", "create", label, "--color", "ededed"];
|
|
6670
|
+
if (o.repo) la.push("--repo", o.repo);
|
|
6671
|
+
try {
|
|
6672
|
+
await execFileP3("gh", la, { timeout: 1e4 });
|
|
6673
|
+
} catch {
|
|
6674
|
+
}
|
|
6675
|
+
}
|
|
4842
6676
|
const created = await ghCreate(args);
|
|
4843
|
-
|
|
4844
|
-
|
|
6677
|
+
const projectItemId = await attachToProject(created.number, o.repo, o.priority);
|
|
6678
|
+
if (o.related !== false) scheduleRelatedDiscovery({ repo: o.repo, number: created.number, title: o.title, body: o.body });
|
|
6679
|
+
console.log(JSON.stringify({ ...created, label: o.type, priority: o.priority, projectItemId }));
|
|
4845
6680
|
});
|
|
4846
6681
|
issue.command("discover-related").description("find related issues for an existing issue and post only high-confidence links").requiredOption("--number <number>", "created issue number").requiredOption("--title <title>", "created issue title").requiredOption("--body <body>", "created issue body").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "print candidates instead of posting").action(async (o) => {
|
|
4847
6682
|
const number = Number(o.number);
|
|
@@ -4849,7 +6684,7 @@ issue.command("discover-related").description("find related issues for an existi
|
|
|
4849
6684
|
const repo = await resolveRepo(o.repo);
|
|
4850
6685
|
if (!repo) return fail("issue discover-related: could not resolve repo");
|
|
4851
6686
|
try {
|
|
4852
|
-
const issues = await
|
|
6687
|
+
const issues = await ghJson3([
|
|
4853
6688
|
"issue",
|
|
4854
6689
|
"list",
|
|
4855
6690
|
"--repo",
|
|
@@ -4864,7 +6699,7 @@ issue.command("discover-related").description("find related issues for an existi
|
|
|
4864
6699
|
const candidates = findRelatedIssues({ number, title: o.title, body: o.body }, issues);
|
|
4865
6700
|
if (o.json) return console.log(JSON.stringify({ number, repo, candidates }, null, 2));
|
|
4866
6701
|
if (!candidates.length) return;
|
|
4867
|
-
const viewed = await
|
|
6702
|
+
const viewed = await ghJson3([
|
|
4868
6703
|
"issue",
|
|
4869
6704
|
"view",
|
|
4870
6705
|
String(number),
|
|
@@ -4883,10 +6718,20 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
|
|
|
4883
6718
|
const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
|
|
4884
6719
|
console.log(JSON.stringify(created));
|
|
4885
6720
|
});
|
|
6721
|
+
pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree \u2014 no leftover local branch").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (number, o) => {
|
|
6722
|
+
const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
|
|
6723
|
+
const repoArgs = o.repo ? ["--repo", o.repo] : [];
|
|
6724
|
+
const headRef = (await execFileP3("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
|
|
6725
|
+
await execFileP3("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
|
|
6726
|
+
if (!/used by worktree|cannot delete branch|already been merged/i.test(String(e.message || ""))) throw e;
|
|
6727
|
+
});
|
|
6728
|
+
const cleaned = repoArgs.length ? { branchDeleted: false } : await cleanupLocalBranch(headRef);
|
|
6729
|
+
console.log(JSON.stringify({ merged: number, branch: headRef, method: method.slice(2), ...cleaned }));
|
|
6730
|
+
});
|
|
4886
6731
|
async function runBoardRead(o) {
|
|
4887
6732
|
try {
|
|
4888
6733
|
const report = await readBoard({
|
|
4889
|
-
config: await
|
|
6734
|
+
config: await loadConfigOrDiscover(),
|
|
4890
6735
|
repo: o.repo,
|
|
4891
6736
|
includeBundleDetails: o.bundleDetails,
|
|
4892
6737
|
allowPartial: o.allowPartial
|
|
@@ -4896,17 +6741,69 @@ async function runBoardRead(o) {
|
|
|
4896
6741
|
fail(`board read failed: ${e.message}`);
|
|
4897
6742
|
}
|
|
4898
6743
|
}
|
|
4899
|
-
var board = program2.command("board").description("read and
|
|
6744
|
+
var board = program2.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
|
|
4900
6745
|
board.command("read", { isDefault: true }).description("read the board and print user-owned, claimable, and taken items").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--bundle-details", "fetch body/comments only for user-owned and claimable issues").option("--allow-partial", "return partial board results when later page/detail reads fail").action((o) => runBoardRead(o));
|
|
4901
|
-
board.command("claim <issue>").description("assign a Todo issue
|
|
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) => {
|
|
4902
6747
|
try {
|
|
4903
|
-
const result = await claimBoardIssue({
|
|
6748
|
+
const result = await claimBoardIssue({
|
|
6749
|
+
config: await loadConfigOrDiscover(),
|
|
6750
|
+
selector: issueRef,
|
|
6751
|
+
repo: o.repo,
|
|
6752
|
+
assignee: o.for,
|
|
6753
|
+
allowPartial: o.allowPartial
|
|
6754
|
+
});
|
|
4904
6755
|
if (o.json) return console.log(JSON.stringify(result));
|
|
4905
6756
|
console.log(result.partial ? `Partially claimed ${result.item.ref}: ${result.warning}` : `Claimed ${result.item.ref} - In Progress`);
|
|
4906
6757
|
} catch (e) {
|
|
4907
6758
|
fail(`board claim failed: ${e.message}`);
|
|
4908
6759
|
}
|
|
4909
6760
|
});
|
|
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) => {
|
|
6762
|
+
try {
|
|
6763
|
+
const item = await showBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, repo: o.repo, allowPartial: o.allowPartial });
|
|
6764
|
+
console.log(o.json ? JSON.stringify(item) : renderBoardItem(item));
|
|
6765
|
+
} catch (e) {
|
|
6766
|
+
fail(`board show failed: ${e.message}`);
|
|
6767
|
+
}
|
|
6768
|
+
});
|
|
6769
|
+
board.command("move <issue> <status>").description(`move a board item's Status to one of: ${BOARD_STATUSES.join(", ")} (quote multi-word statuses)`).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, status, o) => {
|
|
6770
|
+
if (!BOARD_STATUSES.includes(status)) {
|
|
6771
|
+
return fail(`board move failed: unknown status '${status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
|
|
6772
|
+
}
|
|
6773
|
+
try {
|
|
6774
|
+
const result = await moveBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, status, repo: o.repo, allowPartial: o.allowPartial });
|
|
6775
|
+
if (o.json) return console.log(JSON.stringify(result));
|
|
6776
|
+
console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> ${result.status}`);
|
|
6777
|
+
} catch (e) {
|
|
6778
|
+
fail(`board move failed: ${e.message}`);
|
|
6779
|
+
}
|
|
6780
|
+
});
|
|
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) => {
|
|
6782
|
+
try {
|
|
6783
|
+
const result = await backfillBoardPriorities({
|
|
6784
|
+
config: await loadConfigOrDiscover(),
|
|
6785
|
+
repo: o.repo,
|
|
6786
|
+
dryRun: o.dryRun,
|
|
6787
|
+
concurrency: Number(o.concurrency) || 8
|
|
6788
|
+
});
|
|
6789
|
+
if (o.json) return console.log(JSON.stringify(result));
|
|
6790
|
+
console.log(`backfill-priority: scanned ${result.scanned}, set ${result.set}, skipped ${result.skipped}, failed ${result.failed}`);
|
|
6791
|
+
for (const line of result.details.slice(0, 30)) console.log(` ${line}`);
|
|
6792
|
+
if (result.details.length > 30) console.log(` ... +${result.details.length - 30} more`);
|
|
6793
|
+
if (result.failed) process.exitCode = 1;
|
|
6794
|
+
} catch (e) {
|
|
6795
|
+
fail(`board backfill-priority failed: ${e.message}`);
|
|
6796
|
+
}
|
|
6797
|
+
});
|
|
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) => {
|
|
6799
|
+
try {
|
|
6800
|
+
const result = await moveBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, status: "Done", repo: o.repo, allowPartial: o.allowPartial });
|
|
6801
|
+
if (o.json) return console.log(JSON.stringify(result));
|
|
6802
|
+
console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> Done`);
|
|
6803
|
+
} catch (e) {
|
|
6804
|
+
fail(`board done failed: ${e.message}`);
|
|
6805
|
+
}
|
|
6806
|
+
});
|
|
4910
6807
|
function renderSteps(title, steps) {
|
|
4911
6808
|
return [
|
|
4912
6809
|
title,
|
|
@@ -4921,12 +6818,23 @@ function rawValue(flag, fallback) {
|
|
|
4921
6818
|
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
|
4922
6819
|
}
|
|
4923
6820
|
function printLine(value) {
|
|
4924
|
-
(0,
|
|
6821
|
+
(0, import_node_fs4.writeSync)(1, `${value}
|
|
4925
6822
|
`);
|
|
4926
6823
|
}
|
|
4927
6824
|
function stageKeepAlive() {
|
|
4928
6825
|
return setTimeout(() => void 0, 5 * 60 * 1e3);
|
|
4929
6826
|
}
|
|
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) => {
|
|
6828
|
+
const path = (0, import_node_path4.join)(process.cwd(), "infra", "port-ranges.json");
|
|
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);
|
|
6836
|
+
printLine(o.json ? JSON.stringify({ repo, portRange: [start, end] }) : `${repo}: stage.portRange [${start}, ${end}]`);
|
|
6837
|
+
});
|
|
4930
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) => {
|
|
4931
6839
|
const cfg = (await loadConfig()).stage;
|
|
4932
6840
|
if (o.apply) {
|
|
@@ -5009,9 +6917,14 @@ stage.command("run").description("force-stop previous stage, build, start, and h
|
|
|
5009
6917
|
fail(`stage run: ${e.message}`);
|
|
5010
6918
|
}
|
|
5011
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
|
+
});
|
|
5012
6925
|
for (const commandName of ["rc", "release", "hotfix"]) {
|
|
5013
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) => {
|
|
5014
|
-
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`);
|
|
5015
6928
|
const steps = trainPlan(commandName);
|
|
5016
6929
|
console.log(o.json ? JSON.stringify({ command: commandName, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps));
|
|
5017
6930
|
});
|
|
@@ -5026,15 +6939,184 @@ var bootstrap = program2.command("bootstrap").description("plan repo bootstrap o
|
|
|
5026
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) => {
|
|
5027
6940
|
const o = { class: rawValue("--class", "deployable"), json: rawFlag("--json") };
|
|
5028
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 });
|
|
5029
6944
|
const report = await verifyBootstrap(repo, o.class, {
|
|
5030
6945
|
gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
|
|
5031
|
-
readLocalFile: (path) => (0,
|
|
6946
|
+
readLocalFile: (path) => path === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : null
|
|
5032
6947
|
});
|
|
5033
6948
|
console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
|
|
5034
6949
|
if (!report.ok) process.exitCode = 1;
|
|
5035
6950
|
});
|
|
6951
|
+
bootstrap.command("apply <repo>").description("idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").option("--class <class>", "deployable | content", "deployable").option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo) => {
|
|
6952
|
+
const o = { class: rawValue("--class", "deployable"), execute: rawFlag("--execute"), json: rawFlag("--json") };
|
|
6953
|
+
if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap apply: --class must be deployable or content");
|
|
6954
|
+
const manifestPath = "skills/bootstrap/seeds/manifest.json";
|
|
6955
|
+
if (!(0, import_node_fs4.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; run from the MMI-Hub repo root`);
|
|
6956
|
+
const manifest = loadBootstrapSeeds((0, import_node_fs4.readFileSync)(manifestPath, "utf8"));
|
|
6957
|
+
const baseBranch = o.class === "content" ? "main" : "development";
|
|
6958
|
+
const slug = repo.split("/")[1].toLowerCase();
|
|
6959
|
+
const gh = async (args) => execFileP3("gh", args, { timeout: 2e4 });
|
|
6960
|
+
const readFile2 = (p) => (0, import_node_fs4.existsSync)(p) ? (0, import_node_fs4.readFileSync)(p, "utf8") : null;
|
|
6961
|
+
const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
|
|
6962
|
+
const vars = {};
|
|
6963
|
+
for (let i = 0; i < process.argv.length - 1; i++) {
|
|
6964
|
+
if (process.argv[i] === "--var") {
|
|
6965
|
+
const eq = process.argv[i + 1].indexOf("=");
|
|
6966
|
+
if (eq > 0) vars[process.argv[i + 1].slice(0, eq)] = process.argv[i + 1].slice(eq + 1);
|
|
6967
|
+
}
|
|
6968
|
+
}
|
|
6969
|
+
const actions = [];
|
|
6970
|
+
const applied = [];
|
|
6971
|
+
for (const seed of manifest.seeds) {
|
|
6972
|
+
if (!seed.classes.includes(o.class)) continue;
|
|
6973
|
+
const resolved = { ...seed, target: seed.target.replace("{{REPO_SLUG}}", slug) };
|
|
6974
|
+
let exists = false;
|
|
6975
|
+
let sha;
|
|
6976
|
+
let remoteContent = null;
|
|
6977
|
+
if (resolved.source !== "fanout") {
|
|
6978
|
+
try {
|
|
6979
|
+
const r = await gh(["api", `repos/${repo}/contents/${enc(resolved.target)}?ref=${baseBranch}`]);
|
|
6980
|
+
exists = true;
|
|
6981
|
+
try {
|
|
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
|
+
}
|
|
6987
|
+
} catch {
|
|
6988
|
+
}
|
|
6989
|
+
} catch {
|
|
6990
|
+
exists = false;
|
|
6991
|
+
}
|
|
6992
|
+
}
|
|
6993
|
+
const action = planSeedAction(resolved, exists);
|
|
6994
|
+
actions.push(action);
|
|
6995
|
+
if (o.execute && (action.action === "create" || action.action === "update")) {
|
|
6996
|
+
const isBlock = resolved.source === "managed-block";
|
|
6997
|
+
const content = isBlock ? upsertManagedGitignoreBlock(remoteContent).content : resolveSeedContent(resolved, vars, readFile2);
|
|
6998
|
+
if (content == null) {
|
|
6999
|
+
applied.push(`skip ${resolved.target} (no resolvable content)`);
|
|
7000
|
+
continue;
|
|
7001
|
+
}
|
|
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
|
+
}
|
|
7008
|
+
}
|
|
7009
|
+
await gh(contentPutArgs(repo, resolved.target, content, baseBranch, action.action === "update" ? sha : void 0));
|
|
7010
|
+
applied.push(`${action.action} ${resolved.target}`);
|
|
7011
|
+
}
|
|
7012
|
+
}
|
|
7013
|
+
if (o.execute) {
|
|
7014
|
+
for (const l of manifest.labels) {
|
|
7015
|
+
try {
|
|
7016
|
+
await gh(["label", "create", l.name, "--color", l.color, "--description", l.description, "--force", "-R", repo]);
|
|
7017
|
+
applied.push(`label ${l.name}`);
|
|
7018
|
+
} catch {
|
|
7019
|
+
applied.push(`label ${l.name} (failed)`);
|
|
7020
|
+
}
|
|
7021
|
+
}
|
|
7022
|
+
}
|
|
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));
|
|
7037
|
+
else {
|
|
7038
|
+
console.log(renderSeedPlan(actions));
|
|
7039
|
+
if (o.execute) console.log(`
|
|
7040
|
+
LIVE apply to ${repo}:
|
|
7041
|
+
${applied.join("\n ")}`);
|
|
7042
|
+
}
|
|
7043
|
+
});
|
|
7044
|
+
var access = program2.command("access").description("org access audit (read-only)");
|
|
7045
|
+
access.command("audit").description("audit collaborator roles + train-branch push allowlists vs the locked state; read-only, emits gh remediation, never applies").option("--json", "machine-readable output").option("--repo <owner/repo>", "audit a single repo instead of the whole org").option("--class <class>", "repo class for --repo (deployable | content)", "deployable").action(async () => {
|
|
7046
|
+
const o = { json: rawFlag("--json"), repo: rawValue("--repo", ""), class: rawValue("--class", "deployable") };
|
|
7047
|
+
const deps = { gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }) };
|
|
7048
|
+
let targets;
|
|
7049
|
+
const cfg = await loadConfig();
|
|
7050
|
+
const registryProjects = await fetchProjectsList(registryClientDeps(cfg));
|
|
7051
|
+
if (o.repo) {
|
|
7052
|
+
if (o.class !== "deployable" && o.class !== "content") return fail("access audit: --class must be deployable or content");
|
|
7053
|
+
targets = [{ repo: o.repo, class: o.class }];
|
|
7054
|
+
} else {
|
|
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>");
|
|
7057
|
+
const fanoutJson = (0, import_node_fs4.existsSync)(".github/fanout-targets.json") ? (0, import_node_fs4.readFileSync)(".github/fanout-targets.json", "utf8") : null;
|
|
7058
|
+
targets = loadAccessTargets(projectsJson, fanoutJson);
|
|
7059
|
+
}
|
|
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);
|
|
7065
|
+
console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
|
|
7066
|
+
if (!report.ok) process.exitCode = 1;
|
|
7067
|
+
});
|
|
5036
7068
|
var isWin = process.platform === "win32";
|
|
5037
|
-
|
|
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) => {
|
|
5038
7120
|
const checks = [];
|
|
5039
7121
|
const login = await githubLogin();
|
|
5040
7122
|
let ghInstalled = true;
|
|
@@ -5054,16 +7136,19 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
|
|
|
5054
7136
|
}
|
|
5055
7137
|
if (!onPath) {
|
|
5056
7138
|
const root = process.env.CLAUDE_PLUGIN_ROOT;
|
|
5057
|
-
if (root && (0,
|
|
7139
|
+
if (root && (0, import_node_fs4.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
|
|
5058
7140
|
}
|
|
5059
7141
|
checks.push({ ok: onPath, label: "mmi-cli on PATH", fix: "auto-provisioned at session start \u2014 reopen the session, or install the MMI plugin" });
|
|
5060
|
-
|
|
7142
|
+
let versionReport = buildVersionLagReport({
|
|
5061
7143
|
currentVersion: resolveVersion(),
|
|
5062
7144
|
repoVersion: readRepoVersion(),
|
|
5063
7145
|
releasedVersion: await fetchReleasedVersion()
|
|
5064
|
-
})
|
|
7146
|
+
});
|
|
7147
|
+
if (!opts.json) versionReport = await applyVersionAutoUpdate(versionReport, (m) => console.error(m));
|
|
7148
|
+
checks.push(versionReport);
|
|
5065
7149
|
const cfg = await loadConfig();
|
|
5066
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() }));
|
|
5067
7152
|
const REWRITE_KEY = "url.https://github.com/.insteadOf";
|
|
5068
7153
|
const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
|
|
5069
7154
|
let cloneOk = false;
|
|
@@ -5081,6 +7166,29 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
|
|
|
5081
7166
|
}
|
|
5082
7167
|
}
|
|
5083
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);
|
|
5084
7192
|
const gaps = checks.filter((c) => !c.ok);
|
|
5085
7193
|
if (opts.json) {
|
|
5086
7194
|
console.log(JSON.stringify({ ok: gaps.length === 0, checks }, null, 2));
|