@mutmutco/cli 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +1302 -90
- package/package.json +3 -3
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
|
|
@@ -3065,6 +3065,27 @@ function rulesSourceAuthHeaders(sourceUrl, token) {
|
|
|
3065
3065
|
return void 0;
|
|
3066
3066
|
}
|
|
3067
3067
|
|
|
3068
|
+
// src/docs-sync.ts
|
|
3069
|
+
var SYNCED_DOCS = ["README.md", "architecture.md"];
|
|
3070
|
+
async function syncDocs(deps, docs2 = SYNCED_DOCS) {
|
|
3071
|
+
const updated = [];
|
|
3072
|
+
const skippedDirty = [];
|
|
3073
|
+
for (const file of docs2) {
|
|
3074
|
+
if (await deps.isDirty(file)) {
|
|
3075
|
+
skippedDirty.push(file);
|
|
3076
|
+
continue;
|
|
3077
|
+
}
|
|
3078
|
+
const origin = await deps.originContent(file);
|
|
3079
|
+
if (origin === null) continue;
|
|
3080
|
+
const local = await deps.localContent(file);
|
|
3081
|
+
if (needsUpdate(origin, local)) {
|
|
3082
|
+
await deps.writeDoc(file, normalizeEol(origin));
|
|
3083
|
+
updated.push(file);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
return { updated, skippedDirty };
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3068
3089
|
// src/saga-capture.ts
|
|
3069
3090
|
function parseHookInput(stdin) {
|
|
3070
3091
|
try {
|
|
@@ -3194,7 +3215,7 @@ async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
|
|
|
3194
3215
|
|
|
3195
3216
|
// src/gh-create.ts
|
|
3196
3217
|
var ISSUE_TYPES = ["bug", "feature", "task"];
|
|
3197
|
-
var PRIORITIES = ["high", "medium", "low"];
|
|
3218
|
+
var PRIORITIES = ["urgent", "high", "medium", "low"];
|
|
3198
3219
|
function parseCreatedUrl(stdout) {
|
|
3199
3220
|
const re = /https:\/\/github\.com\/[^\s]+\/(?:issues|pull)\/(\d+)/g;
|
|
3200
3221
|
let match;
|
|
@@ -3206,7 +3227,7 @@ function parseCreatedUrl(stdout) {
|
|
|
3206
3227
|
${stdout.trim() || "(empty)"}`);
|
|
3207
3228
|
return last;
|
|
3208
3229
|
}
|
|
3209
|
-
function buildIssueArgs({ type, title, body, priority, repo }) {
|
|
3230
|
+
function buildIssueArgs({ type, title, body, priority, repo, labels }) {
|
|
3210
3231
|
if (!ISSUE_TYPES.includes(type)) throw new Error(`unknown issue type "${type}" \u2014 expected one of: ${ISSUE_TYPES.join(", ")}`);
|
|
3211
3232
|
if (!PRIORITIES.includes(priority)) {
|
|
3212
3233
|
throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${PRIORITIES.join(", ")}`);
|
|
@@ -3215,8 +3236,36 @@ function buildIssueArgs({ type, title, body, priority, repo }) {
|
|
|
3215
3236
|
if (repo) args.push("--repo", repo);
|
|
3216
3237
|
args.push("--title", title, "--body", body, "--label", type);
|
|
3217
3238
|
args.push("--label", `priority:${priority}`);
|
|
3239
|
+
for (const label of labels ?? []) args.push("--label", label);
|
|
3218
3240
|
return args;
|
|
3219
3241
|
}
|
|
3242
|
+
function boardAttachSkipReason(cwdRepo, targetRepo2) {
|
|
3243
|
+
if (targetRepo2 && cwdRepo && targetRepo2 !== cwdRepo) {
|
|
3244
|
+
return `issue was created in ${targetRepo2}, not the board's repo ${cwdRepo}`;
|
|
3245
|
+
}
|
|
3246
|
+
return null;
|
|
3247
|
+
}
|
|
3248
|
+
function buildAddToProjectArgs(projectId, contentId) {
|
|
3249
|
+
if (!projectId) throw new Error("addToProject: projectId is required");
|
|
3250
|
+
if (!contentId) throw new Error("addToProject: contentId is required");
|
|
3251
|
+
return [
|
|
3252
|
+
"api",
|
|
3253
|
+
"graphql",
|
|
3254
|
+
"-f",
|
|
3255
|
+
"query=mutation($p:ID!,$c:ID!){addProjectV2ItemById(input:{projectId:$p,contentId:$c}){item{id}}}",
|
|
3256
|
+
"-f",
|
|
3257
|
+
`p=${projectId}`,
|
|
3258
|
+
"-f",
|
|
3259
|
+
`c=${contentId}`
|
|
3260
|
+
];
|
|
3261
|
+
}
|
|
3262
|
+
function parseAddedItemId(stdout) {
|
|
3263
|
+
try {
|
|
3264
|
+
return JSON.parse(stdout)?.data?.addProjectV2ItemById?.item?.id || void 0;
|
|
3265
|
+
} catch {
|
|
3266
|
+
return void 0;
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3220
3269
|
function buildPrArgs({ title, body, base, head, repo }) {
|
|
3221
3270
|
const args = ["pr", "create"];
|
|
3222
3271
|
if (repo) args.push("--repo", repo);
|
|
@@ -3343,6 +3392,10 @@ function buildVersionLagReport(input) {
|
|
|
3343
3392
|
releasedVersion: input.releasedVersion
|
|
3344
3393
|
};
|
|
3345
3394
|
}
|
|
3395
|
+
function versionAutoUpdateAction(report, hasPluginRoot) {
|
|
3396
|
+
if (report.ok || report.staleAgainst !== "released") return "none";
|
|
3397
|
+
return hasPluginRoot ? "plugin-pull" : "npm";
|
|
3398
|
+
}
|
|
3346
3399
|
|
|
3347
3400
|
// src/issue-related.ts
|
|
3348
3401
|
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
@@ -3401,7 +3454,50 @@ ${lines.join("\n")}`;
|
|
|
3401
3454
|
// src/board.ts
|
|
3402
3455
|
var import_node_child_process2 = require("node:child_process");
|
|
3403
3456
|
var import_node_util = require("node:util");
|
|
3404
|
-
|
|
3457
|
+
|
|
3458
|
+
// src/board-priority.ts
|
|
3459
|
+
var BOARD_PRIORITY_NAMES = ["Urgent", "High", "Medium", "Low"];
|
|
3460
|
+
var CLI_PRIORITIES = ["urgent", "high", "medium", "low"];
|
|
3461
|
+
var LABEL_PREFIX = "priority:";
|
|
3462
|
+
function cliPriorityToFieldName(priority) {
|
|
3463
|
+
if (!CLI_PRIORITIES.includes(priority)) {
|
|
3464
|
+
throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${CLI_PRIORITIES.join(", ")}`);
|
|
3465
|
+
}
|
|
3466
|
+
return priority.charAt(0).toUpperCase() + priority.slice(1);
|
|
3467
|
+
}
|
|
3468
|
+
function labelToFieldPriority(label) {
|
|
3469
|
+
if (!label.startsWith(LABEL_PREFIX)) return void 0;
|
|
3470
|
+
const slug = label.slice(LABEL_PREFIX.length).toLowerCase();
|
|
3471
|
+
if (!CLI_PRIORITIES.includes(slug)) return void 0;
|
|
3472
|
+
return cliPriorityToFieldName(slug);
|
|
3473
|
+
}
|
|
3474
|
+
function resolvePriorityOptionId(cfg, priority) {
|
|
3475
|
+
if (!cfg.priorityFieldId || !cfg.priorityOptions) return void 0;
|
|
3476
|
+
const name = cliPriorityToFieldName(priority);
|
|
3477
|
+
return cfg.priorityOptions[name];
|
|
3478
|
+
}
|
|
3479
|
+
function isPriorityFieldConfigured(cfg) {
|
|
3480
|
+
return Boolean(
|
|
3481
|
+
cfg.priorityFieldId && BOARD_PRIORITY_NAMES.every((name) => cfg.priorityOptions?.[name])
|
|
3482
|
+
);
|
|
3483
|
+
}
|
|
3484
|
+
function recoverPriorityFromEvents(events) {
|
|
3485
|
+
let found;
|
|
3486
|
+
for (const event of events) {
|
|
3487
|
+
if (event.event !== "labeled" || !event.label?.name) continue;
|
|
3488
|
+
const mapped = labelToFieldPriority(event.label.name);
|
|
3489
|
+
if (mapped) found = mapped;
|
|
3490
|
+
}
|
|
3491
|
+
return found;
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
// src/board.ts
|
|
3495
|
+
var rawExecFileP = (0, import_node_util.promisify)(import_node_child_process2.execFile);
|
|
3496
|
+
var execFileP = (file, args, options = {}) => (
|
|
3497
|
+
// encoding 'utf8' guarantees string output at runtime; the cast pins the type (promisify's
|
|
3498
|
+
// overloads widen to string|Buffer when options is spread in).
|
|
3499
|
+
rawExecFileP(file, args, { encoding: "utf8", windowsHide: true, ...options })
|
|
3500
|
+
);
|
|
3405
3501
|
var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
|
|
3406
3502
|
var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["Todo", "In Progress", "In Review"]);
|
|
3407
3503
|
var STATUS_ORDER = new Map(BOARD_STATUSES.map((s, i) => [s, i]));
|
|
@@ -3419,7 +3515,7 @@ var defaultGit = async (args) => {
|
|
|
3419
3515
|
}
|
|
3420
3516
|
};
|
|
3421
3517
|
var PROJECT_ITEMS_QUERY = `
|
|
3422
|
-
query($owner: String!, $number: Int!, $
|
|
3518
|
+
query($owner: String!, $number: Int!, $after: String) {
|
|
3423
3519
|
viewer { login }
|
|
3424
3520
|
organization(login: $owner) {
|
|
3425
3521
|
projectV2(number: $number) {
|
|
@@ -3429,8 +3525,14 @@ query($owner: String!, $number: Int!, $statusField: String!, $after: String) {
|
|
|
3429
3525
|
pageInfo { hasNextPage endCursor }
|
|
3430
3526
|
nodes {
|
|
3431
3527
|
id
|
|
3432
|
-
|
|
3433
|
-
|
|
3528
|
+
fieldValues(first: 8) {
|
|
3529
|
+
nodes {
|
|
3530
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
3531
|
+
name
|
|
3532
|
+
optionId
|
|
3533
|
+
field { ... on ProjectV2SingleSelectField { name } }
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3434
3536
|
}
|
|
3435
3537
|
content {
|
|
3436
3538
|
__typename
|
|
@@ -3476,7 +3578,9 @@ function resolveBoardConfig(cfg) {
|
|
|
3476
3578
|
projectNumber: cfg.projectNumber,
|
|
3477
3579
|
projectId: cfg.projectId,
|
|
3478
3580
|
statusFieldId: cfg.statusFieldId,
|
|
3479
|
-
statusOptions: cfg.statusOptions
|
|
3581
|
+
statusOptions: cfg.statusOptions,
|
|
3582
|
+
priorityFieldId: cfg.priorityFieldId,
|
|
3583
|
+
priorityOptions: cfg.priorityOptions
|
|
3480
3584
|
};
|
|
3481
3585
|
}
|
|
3482
3586
|
function repoFromGitRemote(remote) {
|
|
@@ -3549,6 +3653,22 @@ function findClaimableItem(report, selector) {
|
|
|
3549
3653
|
}
|
|
3550
3654
|
throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
|
|
3551
3655
|
}
|
|
3656
|
+
function renderBoardItem(item) {
|
|
3657
|
+
const assignees = item.assignees.length ? `@${item.assignees.join(", @")}` : "unassigned";
|
|
3658
|
+
const lines = [
|
|
3659
|
+
`${item.ref} - ${item.title}`,
|
|
3660
|
+
`Status: ${item.status} \xB7 ${assignees}${item.priority ? ` \xB7 Priority: ${item.priority}` : ""}`,
|
|
3661
|
+
`Type: ${item.type ?? "item"}${item.labels.length ? ` \xB7 ${item.labels.join(", ")}` : ""}`,
|
|
3662
|
+
item.url
|
|
3663
|
+
];
|
|
3664
|
+
if (item.details) {
|
|
3665
|
+
lines.push("", item.details.body.trim() || "_(no body)_");
|
|
3666
|
+
for (const comment of item.details.comments) {
|
|
3667
|
+
lines.push("", `\u2014 @${comment.author}:`, comment.body.trim());
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
return lines.join("\n");
|
|
3671
|
+
}
|
|
3552
3672
|
function renderBoardReport(report) {
|
|
3553
3673
|
const lines = [`Board \xB7 ${report.project.title} \xB7 @${report.viewer}`];
|
|
3554
3674
|
renderScope(lines, "PRIMARY", report.repo, report.primary);
|
|
@@ -3559,8 +3679,7 @@ function renderBoardReport(report) {
|
|
|
3559
3679
|
}
|
|
3560
3680
|
return lines.join("\n");
|
|
3561
3681
|
}
|
|
3562
|
-
async function
|
|
3563
|
-
const cfg = resolveBoardConfig(options.config);
|
|
3682
|
+
async function collectBoardItems(cfg, options, deps) {
|
|
3564
3683
|
const gh = deps.gh ?? defaultGh;
|
|
3565
3684
|
const git = deps.git ?? defaultGit;
|
|
3566
3685
|
const currentRepo = options.repo ?? repoFromGitRemote(await git(["remote", "get-url", "origin"])) ?? "";
|
|
@@ -3590,21 +3709,93 @@ async function readBoard(options, deps = {}) {
|
|
|
3590
3709
|
after = void 0;
|
|
3591
3710
|
}
|
|
3592
3711
|
} while (after);
|
|
3593
|
-
|
|
3594
|
-
|
|
3712
|
+
return { items: nodesToItems(nodes, warnings), viewer, repo: currentRepo, projectId, projectTitle, warnings, partial };
|
|
3713
|
+
}
|
|
3714
|
+
async function readBoard(options, deps = {}) {
|
|
3715
|
+
const cfg = resolveBoardConfig(options.config);
|
|
3716
|
+
const gh = deps.gh ?? defaultGh;
|
|
3717
|
+
const collected = await collectBoardItems(cfg, options, deps);
|
|
3718
|
+
const groups = partitionBoardItems(collected.items, collected.viewer, collected.repo);
|
|
3595
3719
|
const report = {
|
|
3596
|
-
project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: projectId, title: projectTitle || String(cfg.projectNumber) },
|
|
3597
|
-
viewer,
|
|
3598
|
-
repo:
|
|
3720
|
+
project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: collected.projectId, title: collected.projectTitle || String(cfg.projectNumber) },
|
|
3721
|
+
viewer: collected.viewer,
|
|
3722
|
+
repo: collected.repo,
|
|
3599
3723
|
...groups,
|
|
3600
|
-
warnings,
|
|
3601
|
-
partial
|
|
3724
|
+
warnings: collected.warnings,
|
|
3725
|
+
partial: collected.partial
|
|
3602
3726
|
};
|
|
3603
3727
|
if (options.includeBundleDetails) {
|
|
3604
3728
|
await attachBundleDetails(report, gh, options.allowPartial ?? false);
|
|
3605
3729
|
}
|
|
3606
3730
|
return report;
|
|
3607
3731
|
}
|
|
3732
|
+
function findBoardItem(items, selector) {
|
|
3733
|
+
const found = items.find(
|
|
3734
|
+
(candidate) => candidate.repository.toLowerCase() === selector.repo.toLowerCase() && candidate.number === selector.number
|
|
3735
|
+
);
|
|
3736
|
+
if (!found) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
|
|
3737
|
+
return found;
|
|
3738
|
+
}
|
|
3739
|
+
async function moveBoardItem(options, deps = {}) {
|
|
3740
|
+
if (!BOARD_STATUSES.includes(options.status)) {
|
|
3741
|
+
throw new Error(`unknown status '${options.status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
|
|
3742
|
+
}
|
|
3743
|
+
const cfg = resolveBoardConfig(options.config);
|
|
3744
|
+
const gh = deps.gh ?? defaultGh;
|
|
3745
|
+
const collected = await collectBoardItems(cfg, options, deps);
|
|
3746
|
+
const selector = parseIssueSelector(options.selector, collected.repo);
|
|
3747
|
+
const item = findBoardItem(collected.items, selector);
|
|
3748
|
+
if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
|
|
3749
|
+
const optionId = cfg.statusOptions[options.status];
|
|
3750
|
+
try {
|
|
3751
|
+
await gh([
|
|
3752
|
+
"project",
|
|
3753
|
+
"item-edit",
|
|
3754
|
+
"--id",
|
|
3755
|
+
item.itemId,
|
|
3756
|
+
"--project-id",
|
|
3757
|
+
cfg.projectId,
|
|
3758
|
+
"--field-id",
|
|
3759
|
+
cfg.statusFieldId,
|
|
3760
|
+
"--single-select-option-id",
|
|
3761
|
+
optionId
|
|
3762
|
+
]);
|
|
3763
|
+
} catch (e) {
|
|
3764
|
+
const warning = `partial move: ${item.ref} status was not changed to ${options.status} (${ghError(e)})`;
|
|
3765
|
+
if (!options.allowPartial) throw new Error(warning);
|
|
3766
|
+
return { item, viewer: collected.viewer, repo: collected.repo, status: item.status, partial: true, warning };
|
|
3767
|
+
}
|
|
3768
|
+
return {
|
|
3769
|
+
item: { ...item, status: options.status, statusOptionId: optionId },
|
|
3770
|
+
viewer: collected.viewer,
|
|
3771
|
+
repo: collected.repo,
|
|
3772
|
+
status: options.status,
|
|
3773
|
+
partial: false
|
|
3774
|
+
};
|
|
3775
|
+
}
|
|
3776
|
+
async function showBoardItem(options, deps = {}) {
|
|
3777
|
+
const cfg = resolveBoardConfig(options.config);
|
|
3778
|
+
const gh = deps.gh ?? defaultGh;
|
|
3779
|
+
const collected = await collectBoardItems(cfg, options, deps);
|
|
3780
|
+
const selector = parseIssueSelector(options.selector, collected.repo);
|
|
3781
|
+
const item = findBoardItem(collected.items, selector);
|
|
3782
|
+
if (item.contentType === "Issue") {
|
|
3783
|
+
try {
|
|
3784
|
+
const { stdout } = await gh(["issue", "view", String(item.number), "--repo", item.repository, "--json", "body,comments"]);
|
|
3785
|
+
const detail = JSON.parse(stdout);
|
|
3786
|
+
item.details = {
|
|
3787
|
+
body: detail.body ?? "",
|
|
3788
|
+
comments: (detail.comments ?? []).map((comment) => ({
|
|
3789
|
+
author: comment.author?.login ?? "",
|
|
3790
|
+
body: comment.body ?? ""
|
|
3791
|
+
}))
|
|
3792
|
+
};
|
|
3793
|
+
} catch (e) {
|
|
3794
|
+
if (!options.allowPartial) throw new Error(`detail read failed: ${item.ref}: ${ghError(e)}`);
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
return item;
|
|
3798
|
+
}
|
|
3608
3799
|
async function claimBoardIssue(options, deps = {}) {
|
|
3609
3800
|
const cfg = resolveBoardConfig(options.config);
|
|
3610
3801
|
const gh = deps.gh ?? defaultGh;
|
|
@@ -3612,8 +3803,10 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
3612
3803
|
const selector = parseIssueSelector(options.selector, report.repo);
|
|
3613
3804
|
const item = findClaimableItem(report, selector);
|
|
3614
3805
|
if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
|
|
3806
|
+
const assignee = options.assignee ?? "@me";
|
|
3807
|
+
const assignedLogin = assignee === "@me" ? report.viewer : assignee.replace(/^@/, "");
|
|
3615
3808
|
try {
|
|
3616
|
-
await gh(["issue", "edit", String(item.number), "--repo", item.repository, "--add-assignee",
|
|
3809
|
+
await gh(["issue", "edit", String(item.number), "--repo", item.repository, "--add-assignee", assignee]);
|
|
3617
3810
|
} catch (e) {
|
|
3618
3811
|
throw new Error(`claim failed before board status changed: ${ghError(e)}`);
|
|
3619
3812
|
}
|
|
@@ -3631,11 +3824,117 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
3631
3824
|
cfg.statusOptions["In Progress"]
|
|
3632
3825
|
]);
|
|
3633
3826
|
} catch (e) {
|
|
3634
|
-
const warning = `partial claim: ${item.ref} was assigned to @${
|
|
3827
|
+
const warning = `partial claim: ${item.ref} was assigned to @${assignedLogin}, but Status was not moved to In Progress (${ghError(e)})`;
|
|
3635
3828
|
if (!options.allowPartial) throw new Error(warning);
|
|
3636
3829
|
return { item, viewer: report.viewer, repo: report.repo, status: "Todo", partial: true, warning };
|
|
3637
3830
|
}
|
|
3638
|
-
return {
|
|
3831
|
+
return {
|
|
3832
|
+
item: {
|
|
3833
|
+
...item,
|
|
3834
|
+
assignees: item.assignees.includes(assignedLogin) ? item.assignees : [...item.assignees, assignedLogin],
|
|
3835
|
+
status: "In Progress",
|
|
3836
|
+
statusOptionId: cfg.statusOptions["In Progress"]
|
|
3837
|
+
},
|
|
3838
|
+
viewer: report.viewer,
|
|
3839
|
+
repo: report.repo,
|
|
3840
|
+
status: "In Progress",
|
|
3841
|
+
partial: false
|
|
3842
|
+
};
|
|
3843
|
+
}
|
|
3844
|
+
async function setBoardItemPriority(gh, cfg, itemId, priority) {
|
|
3845
|
+
if (!isPriorityFieldConfigured(cfg)) return void 0;
|
|
3846
|
+
const optionId = resolvePriorityOptionId(cfg, priority);
|
|
3847
|
+
if (!optionId || !cfg.priorityFieldId || !cfg.projectId) return void 0;
|
|
3848
|
+
await gh([
|
|
3849
|
+
"project",
|
|
3850
|
+
"item-edit",
|
|
3851
|
+
"--id",
|
|
3852
|
+
itemId,
|
|
3853
|
+
"--project-id",
|
|
3854
|
+
cfg.projectId,
|
|
3855
|
+
"--field-id",
|
|
3856
|
+
cfg.priorityFieldId,
|
|
3857
|
+
"--single-select-option-id",
|
|
3858
|
+
optionId
|
|
3859
|
+
]);
|
|
3860
|
+
return cliPriorityToFieldName(priority);
|
|
3861
|
+
}
|
|
3862
|
+
async function backfillBoardPriorities(options, deps = {}) {
|
|
3863
|
+
const cfg = resolveBoardConfig(options.config);
|
|
3864
|
+
if (!isPriorityFieldConfigured(cfg)) {
|
|
3865
|
+
throw new Error("priority field is not configured in .mmi/config.json (priorityFieldId + priorityOptions)");
|
|
3866
|
+
}
|
|
3867
|
+
const gh = deps.gh ?? defaultGh;
|
|
3868
|
+
const collected = await collectBoardItems(cfg, { repo: options.repo }, deps);
|
|
3869
|
+
const issues = collected.items.filter((item) => item.contentType === "Issue");
|
|
3870
|
+
const concurrency = Math.max(1, options.concurrency ?? 8);
|
|
3871
|
+
const result = { scanned: issues.length, set: 0, skipped: 0, failed: 0, details: [] };
|
|
3872
|
+
async function work(item) {
|
|
3873
|
+
if (item.priority) {
|
|
3874
|
+
result.skipped += 1;
|
|
3875
|
+
return;
|
|
3876
|
+
}
|
|
3877
|
+
try {
|
|
3878
|
+
const priority = await recoverIssuePriority(gh, item);
|
|
3879
|
+
if (!priority) {
|
|
3880
|
+
result.skipped += 1;
|
|
3881
|
+
return;
|
|
3882
|
+
}
|
|
3883
|
+
if (options.dryRun) {
|
|
3884
|
+
result.set += 1;
|
|
3885
|
+
result.details.push(`${item.ref} \u2192 ${priority} (dry-run)`);
|
|
3886
|
+
return;
|
|
3887
|
+
}
|
|
3888
|
+
const optionId = cfg.priorityOptions?.[priority];
|
|
3889
|
+
if (!optionId) throw new Error(`no option id for ${priority}`);
|
|
3890
|
+
await gh([
|
|
3891
|
+
"project",
|
|
3892
|
+
"item-edit",
|
|
3893
|
+
"--id",
|
|
3894
|
+
item.itemId,
|
|
3895
|
+
"--project-id",
|
|
3896
|
+
cfg.projectId,
|
|
3897
|
+
"--field-id",
|
|
3898
|
+
cfg.priorityFieldId,
|
|
3899
|
+
"--single-select-option-id",
|
|
3900
|
+
optionId
|
|
3901
|
+
]);
|
|
3902
|
+
result.set += 1;
|
|
3903
|
+
result.details.push(`${item.ref} \u2192 ${priority}`);
|
|
3904
|
+
} catch (e) {
|
|
3905
|
+
result.failed += 1;
|
|
3906
|
+
result.details.push(`${item.ref}: ${ghError(e)}`);
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
for (let i = 0; i < issues.length; i += concurrency) {
|
|
3910
|
+
await Promise.all(issues.slice(i, i + concurrency).map(work));
|
|
3911
|
+
}
|
|
3912
|
+
return result;
|
|
3913
|
+
}
|
|
3914
|
+
async function recoverIssuePriority(gh, item) {
|
|
3915
|
+
for (const label of item.labels) {
|
|
3916
|
+
const fromLabel = labelToFieldPriority(label);
|
|
3917
|
+
if (fromLabel) return fromLabel;
|
|
3918
|
+
}
|
|
3919
|
+
const { stdout } = await gh(["api", `repos/${item.repository}/issues/${item.number}/events`, "--paginate"]);
|
|
3920
|
+
return recoverPriorityFromEvents(parsePaginatedEvents(stdout));
|
|
3921
|
+
}
|
|
3922
|
+
function parsePaginatedEvents(stdout) {
|
|
3923
|
+
const trimmed = stdout.trim();
|
|
3924
|
+
if (!trimmed) return [];
|
|
3925
|
+
try {
|
|
3926
|
+
const parsed = JSON.parse(trimmed);
|
|
3927
|
+
if (Array.isArray(parsed)) return parsed;
|
|
3928
|
+
} catch {
|
|
3929
|
+
}
|
|
3930
|
+
return trimmed.split(/\r?\n/).filter(Boolean).flatMap((line) => {
|
|
3931
|
+
try {
|
|
3932
|
+
const parsed = JSON.parse(line);
|
|
3933
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
3934
|
+
} catch {
|
|
3935
|
+
return [];
|
|
3936
|
+
}
|
|
3937
|
+
});
|
|
3639
3938
|
}
|
|
3640
3939
|
async function fetchProjectPage(gh, cfg, after) {
|
|
3641
3940
|
const args = [
|
|
@@ -3646,9 +3945,7 @@ async function fetchProjectPage(gh, cfg, after) {
|
|
|
3646
3945
|
"-f",
|
|
3647
3946
|
`owner=${cfg.projectOwner}`,
|
|
3648
3947
|
"-F",
|
|
3649
|
-
`number=${cfg.projectNumber}
|
|
3650
|
-
"-f",
|
|
3651
|
-
"statusField=Status"
|
|
3948
|
+
`number=${cfg.projectNumber}`
|
|
3652
3949
|
];
|
|
3653
3950
|
if (after) args.push("-f", `after=${after}`);
|
|
3654
3951
|
const { stdout } = await gh(args);
|
|
@@ -3665,10 +3962,27 @@ function nodesToItems(nodes, warnings) {
|
|
|
3665
3962
|
}
|
|
3666
3963
|
return items;
|
|
3667
3964
|
}
|
|
3965
|
+
function parseSingleSelectFields(nodes) {
|
|
3966
|
+
const out = {};
|
|
3967
|
+
for (const node of nodes ?? []) {
|
|
3968
|
+
const fieldName = node.field?.name;
|
|
3969
|
+
if (fieldName === "Status") {
|
|
3970
|
+
const status = asBoardStatus(node.name);
|
|
3971
|
+
if (status) out.status = { name: status, optionId: node.optionId };
|
|
3972
|
+
} else if (fieldName === "Priority" && node.name) {
|
|
3973
|
+
const priority = node.name;
|
|
3974
|
+
if (["Urgent", "High", "Medium", "Low"].includes(priority)) {
|
|
3975
|
+
out.priority = { name: priority, optionId: node.optionId };
|
|
3976
|
+
}
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
return out;
|
|
3980
|
+
}
|
|
3668
3981
|
function nodeToItem(node) {
|
|
3669
3982
|
const content = node.content;
|
|
3670
3983
|
if (!node.id || !isSupportedContent(content)) return void 0;
|
|
3671
|
-
const
|
|
3984
|
+
const fields = parseSingleSelectFields(node.fieldValues?.nodes);
|
|
3985
|
+
const status = fields.status?.name;
|
|
3672
3986
|
const repository = content.repository?.nameWithOwner;
|
|
3673
3987
|
if (!status || !content.id || !content.number || !content.title || !content.url || !repository) return void 0;
|
|
3674
3988
|
const labels = (content.labels?.nodes ?? []).map((l) => l.name).filter((name) => Boolean(name));
|
|
@@ -3684,7 +3998,9 @@ function nodeToItem(node) {
|
|
|
3684
3998
|
title: content.title,
|
|
3685
3999
|
state: content.state ?? "",
|
|
3686
4000
|
status,
|
|
3687
|
-
statusOptionId:
|
|
4001
|
+
statusOptionId: fields.status?.optionId,
|
|
4002
|
+
priority: fields.priority?.name,
|
|
4003
|
+
priorityOptionId: fields.priority?.optionId,
|
|
3688
4004
|
assignees,
|
|
3689
4005
|
labels,
|
|
3690
4006
|
type: labels.find((label) => TYPE_LABELS.includes(label)) ?? labels[0]
|
|
@@ -3743,7 +4059,8 @@ function renderTaken(lines, items) {
|
|
|
3743
4059
|
for (const item of items) lines.push(` ${item.ref} \xB7 ${item.status} \xB7 @${item.assignees.join(", @")}`);
|
|
3744
4060
|
}
|
|
3745
4061
|
function renderTitledItem(item) {
|
|
3746
|
-
|
|
4062
|
+
const pri = item.priority ? ` \xB7 ${item.priority}` : "";
|
|
4063
|
+
return `${item.ref} - [${item.type ?? "item"}]${pri} ${item.title}`;
|
|
3747
4064
|
}
|
|
3748
4065
|
function hasItems(buckets) {
|
|
3749
4066
|
return buckets.userOwned.length > 0 || buckets.claimable.length > 0 || buckets.taken.length > 0;
|
|
@@ -3942,6 +4259,7 @@ function buildGithubAuthCheck(input) {
|
|
|
3942
4259
|
var import_node_child_process3 = require("node:child_process");
|
|
3943
4260
|
var import_node_fs2 = require("node:fs");
|
|
3944
4261
|
var import_node_path2 = require("node:path");
|
|
4262
|
+
var import_node_net = require("node:net");
|
|
3945
4263
|
var import_node_util2 = require("node:util");
|
|
3946
4264
|
var execFileP2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
|
|
3947
4265
|
function stageStatePath(cwd = process.cwd()) {
|
|
@@ -3954,8 +4272,29 @@ function validateStageConfig(config = {}, action) {
|
|
|
3954
4272
|
if (config.healthUrl != null && config.healthUrl.trim() && !/^https?:\/\//.test(config.healthUrl.trim())) {
|
|
3955
4273
|
problems.push("stage.healthUrl must be an http(s) URL");
|
|
3956
4274
|
}
|
|
4275
|
+
if (config.portRange != null) {
|
|
4276
|
+
const r = config.portRange;
|
|
4277
|
+
const ok = Array.isArray(r) && r.length === 2 && r.every((n) => Number.isInteger(n) && n >= 1024 && n <= 65535) && r[0] <= r[1];
|
|
4278
|
+
if (!ok) problems.push("stage.portRange must be [start, end] within 1024-65535 with start <= end");
|
|
4279
|
+
}
|
|
3957
4280
|
return problems;
|
|
3958
4281
|
}
|
|
4282
|
+
function pickStagePort(range, isFree) {
|
|
4283
|
+
if (!range) return void 0;
|
|
4284
|
+
const [start, end] = range;
|
|
4285
|
+
for (let port = start; port <= end; port++) {
|
|
4286
|
+
if (isFree(port)) return port;
|
|
4287
|
+
}
|
|
4288
|
+
throw new Error(`no free stage port in range ${start}-${end} \u2014 every port is in use`);
|
|
4289
|
+
}
|
|
4290
|
+
function isPortFree(port) {
|
|
4291
|
+
return new Promise((resolve) => {
|
|
4292
|
+
const srv = (0, import_node_net.createServer)();
|
|
4293
|
+
srv.once("error", () => resolve(false));
|
|
4294
|
+
srv.once("listening", () => srv.close(() => resolve(true)));
|
|
4295
|
+
srv.listen(port, "127.0.0.1");
|
|
4296
|
+
});
|
|
4297
|
+
}
|
|
3959
4298
|
async function shell(command, cwd, timeoutMs) {
|
|
3960
4299
|
await execFileP2(command, [], {
|
|
3961
4300
|
cwd,
|
|
@@ -4030,24 +4369,34 @@ async function startStage(config = {}, opts = {}) {
|
|
|
4030
4369
|
const statePath = opts.statePath ?? stageStatePath(cwd);
|
|
4031
4370
|
const dir = statePath.slice(0, Math.max(statePath.lastIndexOf("/"), statePath.lastIndexOf("\\")));
|
|
4032
4371
|
(0, import_node_fs2.mkdirSync)(dir, { recursive: true });
|
|
4033
|
-
|
|
4372
|
+
let stagePort;
|
|
4373
|
+
if (config.portRange) {
|
|
4374
|
+
const [s, e] = config.portRange;
|
|
4375
|
+
const free = /* @__PURE__ */ new Set();
|
|
4376
|
+
for (let p = s; p <= e; p++) if (await isPortFree(p)) free.add(p);
|
|
4377
|
+
stagePort = pickStagePort(config.portRange, (p) => free.has(p));
|
|
4378
|
+
}
|
|
4379
|
+
const sub = (s) => s != null && stagePort != null ? s.replace(/\$\{?STAGE_PORT\}?/g, String(stagePort)) : s;
|
|
4380
|
+
const up = sub(config.up.trim());
|
|
4034
4381
|
const child = (0, import_node_child_process3.spawn)(up, {
|
|
4035
4382
|
cwd,
|
|
4036
4383
|
shell: true,
|
|
4037
4384
|
detached: true,
|
|
4038
4385
|
windowsHide: true,
|
|
4039
|
-
stdio: "ignore"
|
|
4386
|
+
stdio: "ignore",
|
|
4387
|
+
env: stagePort != null ? { ...process.env, STAGE_PORT: String(stagePort) } : process.env
|
|
4040
4388
|
});
|
|
4041
4389
|
const state = {
|
|
4042
4390
|
pid: child.pid ?? 0,
|
|
4043
4391
|
command: up,
|
|
4044
4392
|
cwd,
|
|
4045
4393
|
startedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
4046
|
-
healthUrl: config.healthUrl?.trim() || void 0
|
|
4394
|
+
healthUrl: sub(config.healthUrl?.trim()) || void 0,
|
|
4395
|
+
port: stagePort
|
|
4047
4396
|
};
|
|
4048
4397
|
(0, import_node_fs2.writeFileSync)(statePath, JSON.stringify(state, null, 2), "utf8");
|
|
4049
4398
|
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}` };
|
|
4399
|
+
const result = { ok: true, action: "start", statePath, pid: state.pid, message: `started stage pid ${state.pid}${stagePort != null ? ` on port ${stagePort}` : ""}` };
|
|
4051
4400
|
opts.onReady?.(result);
|
|
4052
4401
|
child.unref();
|
|
4053
4402
|
return result;
|
|
@@ -4063,6 +4412,190 @@ async function runStage(config = {}, opts = {}) {
|
|
|
4063
4412
|
return { ...started, action: "run", message: `built and ${started.message}` };
|
|
4064
4413
|
}
|
|
4065
4414
|
|
|
4415
|
+
// src/port-registry.ts
|
|
4416
|
+
var import_node_fs3 = require("node:fs");
|
|
4417
|
+
var BLOCK = 100;
|
|
4418
|
+
var SPAN = 10;
|
|
4419
|
+
var FIRST = 3e3;
|
|
4420
|
+
function nextPortBlock(registry) {
|
|
4421
|
+
const bases = Object.values(registry).map(([start]) => start);
|
|
4422
|
+
const base = bases.length ? Math.max(...bases) + BLOCK : FIRST;
|
|
4423
|
+
return [base, base + SPAN];
|
|
4424
|
+
}
|
|
4425
|
+
function loadPortRegistry(path) {
|
|
4426
|
+
if (!(0, import_node_fs3.existsSync)(path)) return {};
|
|
4427
|
+
const raw = JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8"));
|
|
4428
|
+
const out = {};
|
|
4429
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
4430
|
+
if (Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number")) {
|
|
4431
|
+
out[key] = [value[0], value[1]];
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
return out;
|
|
4435
|
+
}
|
|
4436
|
+
function ensurePortRange(repo, path) {
|
|
4437
|
+
const registry = loadPortRegistry(path);
|
|
4438
|
+
const existing = registry[repo];
|
|
4439
|
+
if (existing) return existing;
|
|
4440
|
+
const range = nextPortBlock(registry);
|
|
4441
|
+
const raw = (0, import_node_fs3.existsSync)(path) ? JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8")) : {};
|
|
4442
|
+
raw[repo] = range;
|
|
4443
|
+
(0, import_node_fs3.writeFileSync)(path, JSON.stringify(raw, null, 2) + "\n", "utf8");
|
|
4444
|
+
return range;
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
// src/access.ts
|
|
4448
|
+
var OWNER = "mutmutco";
|
|
4449
|
+
var LOCKED_APP = "mmi-github-app";
|
|
4450
|
+
var OVERGRANT_ROLES = /* @__PURE__ */ new Set(["admin", "maintain"]);
|
|
4451
|
+
function lockedBranches(repoClass) {
|
|
4452
|
+
return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
|
|
4453
|
+
}
|
|
4454
|
+
function safeJson(text, fallback) {
|
|
4455
|
+
try {
|
|
4456
|
+
return JSON.parse(text);
|
|
4457
|
+
} catch {
|
|
4458
|
+
return fallback;
|
|
4459
|
+
}
|
|
4460
|
+
}
|
|
4461
|
+
async function ghJson(deps, args, fallback) {
|
|
4462
|
+
try {
|
|
4463
|
+
return safeJson((await deps.gh(args)).stdout, fallback);
|
|
4464
|
+
} catch {
|
|
4465
|
+
return fallback;
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
async function resolveOwners(deps) {
|
|
4469
|
+
const members = await ghJson(deps, ["api", `orgs/${OWNER}/members?role=admin`, "--paginate"], []);
|
|
4470
|
+
return members.map((m) => m.login);
|
|
4471
|
+
}
|
|
4472
|
+
function collaboratorRole(c) {
|
|
4473
|
+
return c.role_name ?? (c.permissions?.admin ? "admin" : c.permissions?.maintain ? "maintain" : "write");
|
|
4474
|
+
}
|
|
4475
|
+
async function auditRepoCollaborators(repo, owners, deps) {
|
|
4476
|
+
const collabs = await ghJson(deps, ["api", `repos/${repo}/collaborators?affiliation=direct`, "--paginate"], []);
|
|
4477
|
+
const findings = [];
|
|
4478
|
+
for (const c of collabs) {
|
|
4479
|
+
if (owners.has(c.login)) continue;
|
|
4480
|
+
const role = collaboratorRole(c);
|
|
4481
|
+
if (OVERGRANT_ROLES.has(role)) {
|
|
4482
|
+
findings.push({
|
|
4483
|
+
repo,
|
|
4484
|
+
kind: "collaborator-overgrant",
|
|
4485
|
+
severity: "high",
|
|
4486
|
+
actor: c.login,
|
|
4487
|
+
detail: `direct collaborator @${c.login} holds role '${role}'; a developer must be 'write' (admin/maintain is master-only)`,
|
|
4488
|
+
remediation: `gh api -X PUT repos/${repo}/collaborators/${c.login} -f permission=push`
|
|
4489
|
+
});
|
|
4490
|
+
}
|
|
4491
|
+
}
|
|
4492
|
+
return findings;
|
|
4493
|
+
}
|
|
4494
|
+
async function auditTrainBranch(repo, branch, owners, deps, projectAdmins = /* @__PURE__ */ new Set()) {
|
|
4495
|
+
let restrictions = null;
|
|
4496
|
+
try {
|
|
4497
|
+
restrictions = safeJson((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection/restrictions`])).stdout, null);
|
|
4498
|
+
} catch {
|
|
4499
|
+
restrictions = null;
|
|
4500
|
+
}
|
|
4501
|
+
if (!restrictions) {
|
|
4502
|
+
return [{
|
|
4503
|
+
repo,
|
|
4504
|
+
branch,
|
|
4505
|
+
kind: "unprotected-branch",
|
|
4506
|
+
severity: "medium",
|
|
4507
|
+
detail: `${branch} has no push restrictions (branch unprotected, or protection without a user/app allowlist)`,
|
|
4508
|
+
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}"]}`
|
|
4509
|
+
}];
|
|
4510
|
+
}
|
|
4511
|
+
const findings = [];
|
|
4512
|
+
const users = (restrictions.users ?? []).map((u) => u.login);
|
|
4513
|
+
for (const login of users) {
|
|
4514
|
+
if (!owners.has(login) && !projectAdmins.has(login)) {
|
|
4515
|
+
findings.push({
|
|
4516
|
+
repo,
|
|
4517
|
+
branch,
|
|
4518
|
+
kind: "train-allowlist-extra",
|
|
4519
|
+
severity: "medium",
|
|
4520
|
+
actor: login,
|
|
4521
|
+
detail: `@${login} is on the ${branch} push allowlist \u2014 legitimate only if an intended full-write project-admin; confirm`,
|
|
4522
|
+
remediation: `# if NOT an intended full-write member: gh api -X DELETE repos/${repo}/branches/${branch}/protection/restrictions/users --input - <<< '["${login}"]'`
|
|
4523
|
+
});
|
|
4524
|
+
}
|
|
4525
|
+
}
|
|
4526
|
+
for (const owner of owners) {
|
|
4527
|
+
if (!users.includes(owner)) {
|
|
4528
|
+
findings.push({
|
|
4529
|
+
repo,
|
|
4530
|
+
branch,
|
|
4531
|
+
kind: "train-allowlist-missing",
|
|
4532
|
+
severity: "medium",
|
|
4533
|
+
actor: owner,
|
|
4534
|
+
detail: `org owner @${owner} is missing from the ${branch} push allowlist`,
|
|
4535
|
+
remediation: `gh api -X POST repos/${repo}/branches/${branch}/protection/restrictions/users --input - <<< '["${owner}"]'`
|
|
4536
|
+
});
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
const apps = (restrictions.apps ?? []).map((a) => a.slug);
|
|
4540
|
+
if (!apps.includes(LOCKED_APP)) {
|
|
4541
|
+
findings.push({
|
|
4542
|
+
repo,
|
|
4543
|
+
branch,
|
|
4544
|
+
kind: "app-bypass-missing",
|
|
4545
|
+
severity: "high",
|
|
4546
|
+
detail: `the ${LOCKED_APP} App is missing from the ${branch} allowlist \u2014 fanout/promotions will break`,
|
|
4547
|
+
remediation: `gh api -X POST repos/${repo}/branches/${branch}/protection/restrictions/apps --input - <<< '["${LOCKED_APP}"]'`
|
|
4548
|
+
});
|
|
4549
|
+
}
|
|
4550
|
+
return findings;
|
|
4551
|
+
}
|
|
4552
|
+
async function auditRepoAccess(repo, repoClass, owners, deps, projectAdmins = /* @__PURE__ */ new Set()) {
|
|
4553
|
+
const findings = [];
|
|
4554
|
+
findings.push(...await auditRepoCollaborators(repo, owners, deps));
|
|
4555
|
+
for (const branch of lockedBranches(repoClass)) {
|
|
4556
|
+
findings.push(...await auditTrainBranch(repo, branch, owners, deps, projectAdmins));
|
|
4557
|
+
}
|
|
4558
|
+
return { repo, class: repoClass, ok: !findings.some((f) => f.severity === "high"), findings };
|
|
4559
|
+
}
|
|
4560
|
+
async function auditOrgAccess(targets, deps, matrix = {}) {
|
|
4561
|
+
const owners = new Set(await resolveOwners(deps));
|
|
4562
|
+
const repos = [];
|
|
4563
|
+
for (const target of targets) {
|
|
4564
|
+
repos.push(await auditRepoAccess(target.repo, target.class, owners, deps, new Set(matrix[target.repo] ?? [])));
|
|
4565
|
+
}
|
|
4566
|
+
return { ok: repos.every((r) => r.ok), owners: [...owners], repos };
|
|
4567
|
+
}
|
|
4568
|
+
function loadAccessTargets(projectsJson, fanoutJson) {
|
|
4569
|
+
const projects = safeJson(projectsJson, {}).projects ?? [];
|
|
4570
|
+
const fanout = fanoutJson ? safeJson(fanoutJson, {}).repos ?? [] : [];
|
|
4571
|
+
const contentNames = new Set(fanout.filter((r) => r.class === "content").map((r) => r.repo));
|
|
4572
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4573
|
+
const targets = [];
|
|
4574
|
+
for (const project of projects) {
|
|
4575
|
+
for (const repo of project.repos ?? []) {
|
|
4576
|
+
if (seen.has(repo)) continue;
|
|
4577
|
+
seen.add(repo);
|
|
4578
|
+
targets.push({ repo, class: contentNames.has(repo.split("/")[1]) ? "content" : "deployable" });
|
|
4579
|
+
}
|
|
4580
|
+
}
|
|
4581
|
+
return targets;
|
|
4582
|
+
}
|
|
4583
|
+
function loadAccessMatrix(matrixJson) {
|
|
4584
|
+
if (!matrixJson) return {};
|
|
4585
|
+
return safeJson(matrixJson, {}).projectAdmins ?? {};
|
|
4586
|
+
}
|
|
4587
|
+
function renderAccessReport(report) {
|
|
4588
|
+
const lines = [`mmi-cli access audit: ${report.ok ? "OK" : "CHECK"} (owners: ${report.owners.map((o) => "@" + o).join(", ") || "none"})`];
|
|
4589
|
+
for (const repo of report.repos) {
|
|
4590
|
+
lines.push(`${repo.ok ? "OK" : "FLAG"} ${repo.repo} (${repo.class})`);
|
|
4591
|
+
for (const finding of repo.findings) {
|
|
4592
|
+
lines.push(` [${finding.severity}] ${finding.kind}${finding.branch ? ` @${finding.branch}` : ""}: ${finding.detail}`);
|
|
4593
|
+
if (finding.remediation) lines.push(` ${finding.remediation}`);
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
return lines.join("\n");
|
|
4597
|
+
}
|
|
4598
|
+
|
|
4066
4599
|
// src/bootstrap-verify.ts
|
|
4067
4600
|
var requiredDocs = ["README.md", "architecture.md", "AGENTS.md", "CLAUDE.md", ".claude/settings.json", ".mmi/config.json"];
|
|
4068
4601
|
var requiredIssueTemplates = [
|
|
@@ -4072,7 +4605,9 @@ var requiredIssueTemplates = [
|
|
|
4072
4605
|
".github/ISSUE_TEMPLATE/config.yml"
|
|
4073
4606
|
];
|
|
4074
4607
|
var requiredWorkflows = [".github/workflows/pr-to-board.yml"];
|
|
4075
|
-
var requiredLabels = ["bug", "feature", "task", "priority:high", "priority:medium", "priority:low"];
|
|
4608
|
+
var requiredLabels = ["bug", "feature", "task", "priority:urgent", "priority:high", "priority:medium", "priority:low"];
|
|
4609
|
+
var requiredPriorityOptions = ["Urgent", "High", "Medium", "Low"];
|
|
4610
|
+
var strayDefaultLabels = ["documentation", "duplicate", "enhancement", "good first issue", "help wanted", "invalid", "question", "wontfix"];
|
|
4076
4611
|
var requiredStatusOptions = ["Todo", "In Progress", "In Review", "Done"];
|
|
4077
4612
|
var requiredProjectWorkflows = [
|
|
4078
4613
|
"Auto-add sub-issues to project",
|
|
@@ -4087,16 +4622,16 @@ var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
|
|
|
4087
4622
|
function expectedBranches(repoClass) {
|
|
4088
4623
|
return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
|
|
4089
4624
|
}
|
|
4090
|
-
function
|
|
4625
|
+
function safeJson2(text, fallback) {
|
|
4091
4626
|
try {
|
|
4092
4627
|
return JSON.parse(text);
|
|
4093
4628
|
} catch {
|
|
4094
4629
|
return fallback;
|
|
4095
4630
|
}
|
|
4096
4631
|
}
|
|
4097
|
-
async function
|
|
4632
|
+
async function ghJson2(deps, args, fallback) {
|
|
4098
4633
|
try {
|
|
4099
|
-
return
|
|
4634
|
+
return safeJson2((await deps.gh(args)).stdout, fallback);
|
|
4100
4635
|
} catch {
|
|
4101
4636
|
return fallback;
|
|
4102
4637
|
}
|
|
@@ -4113,7 +4648,7 @@ async function contentExists(deps, repo, branch, path) {
|
|
|
4113
4648
|
async function contentText(deps, repo, branch, path) {
|
|
4114
4649
|
try {
|
|
4115
4650
|
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
4116
|
-
const response =
|
|
4651
|
+
const response = safeJson2(
|
|
4117
4652
|
(await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`])).stdout,
|
|
4118
4653
|
{}
|
|
4119
4654
|
);
|
|
@@ -4124,36 +4659,51 @@ async function contentText(deps, repo, branch, path) {
|
|
|
4124
4659
|
return null;
|
|
4125
4660
|
}
|
|
4126
4661
|
}
|
|
4127
|
-
async function
|
|
4662
|
+
async function getProtection(deps, repo, branch) {
|
|
4128
4663
|
try {
|
|
4129
|
-
await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`]);
|
|
4130
|
-
return true;
|
|
4664
|
+
return safeJson2((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`])).stdout, {});
|
|
4131
4665
|
} catch {
|
|
4132
|
-
return
|
|
4666
|
+
return null;
|
|
4133
4667
|
}
|
|
4134
4668
|
}
|
|
4669
|
+
function hasPushAllowlist(p) {
|
|
4670
|
+
return Array.isArray(p?.restrictions?.users) && p.restrictions.users.length > 0;
|
|
4671
|
+
}
|
|
4135
4672
|
function optionDetail(missing) {
|
|
4136
4673
|
return missing.length === 0 ? void 0 : `missing: ${missing.join(", ")}`;
|
|
4137
4674
|
}
|
|
4138
4675
|
function localRegistryCheck(deps, path, predicate) {
|
|
4139
4676
|
const text = deps.readLocalFile?.(path);
|
|
4140
4677
|
if (text == null) return null;
|
|
4141
|
-
return predicate(
|
|
4678
|
+
return predicate(safeJson2(text, null));
|
|
4142
4679
|
}
|
|
4143
4680
|
async function verifyBootstrap(repo, repoClass, deps) {
|
|
4144
4681
|
const branchesWanted = expectedBranches(repoClass);
|
|
4145
4682
|
const baseBranch = repoClass === "content" ? "main" : "development";
|
|
4146
4683
|
const checks = [];
|
|
4147
|
-
const repoInfo = await
|
|
4684
|
+
const repoInfo = await ghJson2(deps, ["api", `repos/${repo}`], {});
|
|
4148
4685
|
checks.push({ ok: Boolean(repoInfo.default_branch), label: "repo exists" });
|
|
4149
4686
|
checks.push({ ok: repoInfo.default_branch === baseBranch, label: `default branch is ${baseBranch}`, detail: repoInfo.default_branch || "missing" });
|
|
4150
4687
|
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
|
|
4688
|
+
const branchList = await ghJson2(deps, ["api", `repos/${repo}/branches`, "--paginate"], []);
|
|
4152
4689
|
const branchNames = new Set(branchList.map((b) => b.name));
|
|
4153
4690
|
for (const branch of branchesWanted) {
|
|
4154
4691
|
checks.push({ ok: branchNames.has(branch), label: `branch exists: ${branch}` });
|
|
4155
|
-
|
|
4692
|
+
const protection = await getProtection(deps, repo, branch);
|
|
4693
|
+
checks.push({ ok: protection != null, label: `branch protection exists: ${branch}` });
|
|
4694
|
+
checks.push({
|
|
4695
|
+
ok: hasPushAllowlist(protection),
|
|
4696
|
+
label: `push allowlist configured: ${branch}`,
|
|
4697
|
+
detail: hasPushAllowlist(protection) ? void 0 : "restrictions.users is empty or unset"
|
|
4698
|
+
});
|
|
4156
4699
|
}
|
|
4700
|
+
const owners = new Set(await resolveOwners(deps));
|
|
4701
|
+
const overgrants = await auditRepoCollaborators(repo, owners, deps);
|
|
4702
|
+
checks.push({
|
|
4703
|
+
ok: overgrants.length === 0,
|
|
4704
|
+
label: "collaborator roles are master-only (no admin/maintain over-grant)",
|
|
4705
|
+
detail: overgrants.length ? `over-granted: ${overgrants.map((f) => f.actor).join(", ")}` : void 0
|
|
4706
|
+
});
|
|
4157
4707
|
for (const path of requiredDocs) {
|
|
4158
4708
|
checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `bootstrap artifact exists: ${path}` });
|
|
4159
4709
|
}
|
|
@@ -4163,31 +4713,37 @@ async function verifyBootstrap(repo, repoClass, deps) {
|
|
|
4163
4713
|
for (const path of requiredWorkflows) {
|
|
4164
4714
|
checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `automation workflow exists: ${path}` });
|
|
4165
4715
|
}
|
|
4716
|
+
if (repoClass === "deployable") {
|
|
4717
|
+
const trainScript = "scripts/next-version.mjs";
|
|
4718
|
+
checks.push({ ok: await contentExists(deps, repo, baseBranch, trainScript), label: `train tooling script exists: ${trainScript}` });
|
|
4719
|
+
}
|
|
4166
4720
|
checks.push({ ok: await contentExists(deps, repo, baseBranch, ".cursor/environment.json"), label: "Cursor environment committed" });
|
|
4167
|
-
const labels = await
|
|
4721
|
+
const labels = await ghJson2(deps, ["label", "list", "--repo", repo, "--limit", "200", "--json", "name"], []);
|
|
4168
4722
|
const labelNames = new Set(labels.map((l) => l.name));
|
|
4169
4723
|
for (const label of requiredLabels) {
|
|
4170
4724
|
checks.push({ ok: labelNames.has(label), label: `label exists: ${label}` });
|
|
4171
4725
|
}
|
|
4172
|
-
const
|
|
4726
|
+
const strays = strayDefaultLabels.filter((l) => labelNames.has(l));
|
|
4727
|
+
checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: optionDetail(strays) });
|
|
4728
|
+
const actions = await ghJson2(deps, ["api", `repos/${repo}/actions/permissions`], {});
|
|
4173
4729
|
checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
|
|
4174
|
-
const variables = await
|
|
4730
|
+
const variables = await ghJson2(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
|
|
4175
4731
|
const variableNames = new Set(variables.map((v) => v.name));
|
|
4176
4732
|
for (const variable of requiredActionsVariables) {
|
|
4177
4733
|
checks.push({ ok: variableNames.has(variable), label: `Actions variable exists: ${variable}` });
|
|
4178
4734
|
}
|
|
4179
|
-
const
|
|
4180
|
-
const secretNames = new Set(
|
|
4735
|
+
const secrets2 = await ghJson2(deps, ["secret", "list", "--repo", repo, "--json", "name"], []);
|
|
4736
|
+
const secretNames = new Set(secrets2.map((s) => s.name));
|
|
4181
4737
|
for (const secret of requiredActionsSecrets) {
|
|
4182
4738
|
checks.push({ ok: secretNames.has(secret), label: `Actions secret exists: ${secret}` });
|
|
4183
4739
|
}
|
|
4184
|
-
const config =
|
|
4740
|
+
const config = safeJson2(await contentText(deps, repo, baseBranch, ".mmi/config.json") || "", null);
|
|
4185
4741
|
checks.push({
|
|
4186
4742
|
ok: Boolean(config?.projectOwner && config?.projectNumber),
|
|
4187
4743
|
label: ".mmi project board config exists"
|
|
4188
4744
|
});
|
|
4189
4745
|
if (config?.projectOwner && config.projectNumber != null) {
|
|
4190
|
-
const project = await
|
|
4746
|
+
const project = await ghJson2(
|
|
4191
4747
|
deps,
|
|
4192
4748
|
["project", "field-list", String(config.projectNumber), "--owner", config.projectOwner, "--format", "json"],
|
|
4193
4749
|
{}
|
|
@@ -4223,8 +4779,33 @@ async function verifyBootstrap(repo, repoClass, deps) {
|
|
|
4223
4779
|
});
|
|
4224
4780
|
}
|
|
4225
4781
|
}
|
|
4782
|
+
const priorityField = fields.find((field) => field.name === "Priority" && (field.options?.length ?? 0) > 0);
|
|
4783
|
+
checks.push({
|
|
4784
|
+
ok: Boolean(priorityField),
|
|
4785
|
+
label: `Project Priority field exists (API-writable): ${config.projectOwner}#${config.projectNumber}`
|
|
4786
|
+
});
|
|
4787
|
+
if (priorityField != null) {
|
|
4788
|
+
const priorityNames = new Set((priorityField.options || []).map((option) => option.name));
|
|
4789
|
+
const missingPriority = requiredPriorityOptions.filter((option) => !priorityNames.has(option));
|
|
4790
|
+
checks.push({
|
|
4791
|
+
ok: missingPriority.length === 0,
|
|
4792
|
+
label: "Project Priority options configured",
|
|
4793
|
+
detail: optionDetail(missingPriority)
|
|
4794
|
+
});
|
|
4795
|
+
checks.push({
|
|
4796
|
+
ok: config.priorityFieldId === priorityField.id,
|
|
4797
|
+
label: ".mmi priorityFieldId matches project"
|
|
4798
|
+
});
|
|
4799
|
+
for (const optionName of requiredPriorityOptions) {
|
|
4800
|
+
const projectOption = priorityField.options?.find((option) => option.name === optionName);
|
|
4801
|
+
checks.push({
|
|
4802
|
+
ok: Boolean(projectOption?.id && config.priorityOptions?.[optionName] === projectOption.id),
|
|
4803
|
+
label: `.mmi priority option matches project: ${optionName}`
|
|
4804
|
+
});
|
|
4805
|
+
}
|
|
4806
|
+
}
|
|
4226
4807
|
const workflowQuery = "query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { workflows(first: 30) { nodes { name enabled } } } } }";
|
|
4227
|
-
const workflowResponse = await
|
|
4808
|
+
const workflowResponse = await ghJson2(
|
|
4228
4809
|
deps,
|
|
4229
4810
|
["api", "graphql", "-f", `query=${workflowQuery}`, "-f", `login=${config.projectOwner}`, "-F", `number=${config.projectNumber}`],
|
|
4230
4811
|
{}
|
|
@@ -4241,15 +4822,139 @@ async function verifyBootstrap(repo, repoClass, deps) {
|
|
|
4241
4822
|
if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
|
|
4242
4823
|
const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
|
|
4243
4824
|
if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
|
|
4244
|
-
|
|
4825
|
+
const rulesets = await ghJson2(
|
|
4826
|
+
deps,
|
|
4827
|
+
["api", `repos/${repo}/rulesets?includes_parents=true`],
|
|
4828
|
+
[]
|
|
4829
|
+
);
|
|
4830
|
+
const orgRuleset = rulesets.some(
|
|
4831
|
+
(r) => r.source_type === "Organization" && r.target === "branch" && r.enforcement === "active"
|
|
4832
|
+
);
|
|
4833
|
+
checks.push({
|
|
4834
|
+
ok: orgRuleset,
|
|
4835
|
+
label: "covered by an active org ruleset",
|
|
4836
|
+
detail: orgRuleset ? void 0 : "no active Organization-sourced branch ruleset targets this repo"
|
|
4837
|
+
});
|
|
4838
|
+
const waived = applyWaivers(checks, config?.verifyWaivers ?? []);
|
|
4839
|
+
return { ok: waived.every((c) => c.ok || c.waived), repo, class: repoClass, baseBranch, checks: waived };
|
|
4840
|
+
}
|
|
4841
|
+
function applyWaivers(checks, waivers) {
|
|
4842
|
+
if (!waivers?.length) return checks;
|
|
4843
|
+
const set = new Set(waivers);
|
|
4844
|
+
return checks.map((c) => !c.ok && set.has(c.label) ? { ...c, waived: true } : c);
|
|
4245
4845
|
}
|
|
4246
4846
|
function renderBootstrapVerifyReport(report) {
|
|
4247
4847
|
const lines = [`mmi-cli bootstrap verify: ${report.ok ? "OK" : "CHECK"} ${report.repo} (${report.class}, ${report.baseBranch})`];
|
|
4248
4848
|
for (const check of report.checks) {
|
|
4249
|
-
|
|
4849
|
+
const status = check.ok ? "OK" : check.waived ? "WAIVE" : "FAIL";
|
|
4850
|
+
lines.push(`${status} ${check.label}${check.detail ? ` - ${check.detail}` : ""}`);
|
|
4851
|
+
}
|
|
4852
|
+
return lines.join("\n");
|
|
4853
|
+
}
|
|
4854
|
+
|
|
4855
|
+
// src/bootstrap-seeds.ts
|
|
4856
|
+
var PLACEHOLDER_RE = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
4857
|
+
function loadBootstrapSeeds(manifestJson) {
|
|
4858
|
+
let parsed;
|
|
4859
|
+
try {
|
|
4860
|
+
parsed = JSON.parse(manifestJson);
|
|
4861
|
+
} catch {
|
|
4862
|
+
throw new Error("bootstrap seed manifest is not valid JSON");
|
|
4863
|
+
}
|
|
4864
|
+
const obj = parsed ?? {};
|
|
4865
|
+
const seeds = obj.seeds ?? [];
|
|
4866
|
+
for (const s of seeds) {
|
|
4867
|
+
if (!s || !s.target || !s.source || !Array.isArray(s.classes)) {
|
|
4868
|
+
throw new Error(`invalid seed entry (needs target, source, classes): ${JSON.stringify(s)}`);
|
|
4869
|
+
}
|
|
4870
|
+
if (s.ownership !== "org" && s.ownership !== "repo") {
|
|
4871
|
+
throw new Error(`invalid seed ownership '${s.ownership}' for ${s.target} (must be 'org' or 'repo')`);
|
|
4872
|
+
}
|
|
4873
|
+
}
|
|
4874
|
+
return {
|
|
4875
|
+
seeds,
|
|
4876
|
+
labels: obj.labels ?? [],
|
|
4877
|
+
placeholders: obj.placeholders ?? []
|
|
4878
|
+
};
|
|
4879
|
+
}
|
|
4880
|
+
function renderSeed(template, vars) {
|
|
4881
|
+
return template.replace(PLACEHOLDER_RE, (match, key) => key in vars ? vars[key] : match);
|
|
4882
|
+
}
|
|
4883
|
+
function missingPlaceholders(rendered) {
|
|
4884
|
+
const out = /* @__PURE__ */ new Set();
|
|
4885
|
+
for (const m of rendered.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
|
|
4886
|
+
return [...out];
|
|
4887
|
+
}
|
|
4888
|
+
|
|
4889
|
+
// src/bootstrap-apply.ts
|
|
4890
|
+
function planSeedAction(seed, exists) {
|
|
4891
|
+
if (seed.source === "fanout") {
|
|
4892
|
+
return { target: seed.target, action: "skip", ownership: "fanout", reason: "delivered by the fanout pipeline" };
|
|
4893
|
+
}
|
|
4894
|
+
if (seed.ownership === "repo") {
|
|
4895
|
+
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
4896
|
}
|
|
4897
|
+
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" };
|
|
4898
|
+
}
|
|
4899
|
+
function renderSeedPlan(actions) {
|
|
4900
|
+
const lines = ["bootstrap apply \u2014 seed plan (dry-run; no mutations):"];
|
|
4901
|
+
for (const a of actions) {
|
|
4902
|
+
lines.push(` ${a.action.toUpperCase().padEnd(6)} ${a.target} (${a.ownership}: ${a.reason})`);
|
|
4903
|
+
}
|
|
4904
|
+
const order = ["create", "update", "skip"];
|
|
4905
|
+
lines.push(` \u2014 ${order.map((k) => `${actions.filter((a) => a.action === k).length} ${k}`).join(", ")}`);
|
|
4251
4906
|
return lines.join("\n");
|
|
4252
4907
|
}
|
|
4908
|
+
function resolveSeedContent(seed, vars, readFile2) {
|
|
4909
|
+
if (seed.source === "self") return readFile2(seed.target);
|
|
4910
|
+
if (seed.source.startsWith("seed:")) {
|
|
4911
|
+
const tmpl = readFile2(`skills/bootstrap/seeds/${seed.source.slice("seed:".length)}`);
|
|
4912
|
+
return tmpl == null ? null : renderSeed(tmpl, vars);
|
|
4913
|
+
}
|
|
4914
|
+
return null;
|
|
4915
|
+
}
|
|
4916
|
+
function contentPutArgs(repo, path, content, branch, sha) {
|
|
4917
|
+
const args = [
|
|
4918
|
+
"api",
|
|
4919
|
+
"-X",
|
|
4920
|
+
"PUT",
|
|
4921
|
+
`repos/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}`,
|
|
4922
|
+
"-f",
|
|
4923
|
+
`message=bootstrap: seed ${path}`,
|
|
4924
|
+
"-f",
|
|
4925
|
+
`content=${Buffer.from(content, "utf8").toString("base64")}`,
|
|
4926
|
+
"-f",
|
|
4927
|
+
`branch=${branch}`
|
|
4928
|
+
];
|
|
4929
|
+
if (sha) args.push("-f", `sha=${sha}`);
|
|
4930
|
+
return args;
|
|
4931
|
+
}
|
|
4932
|
+
|
|
4933
|
+
// src/kb.ts
|
|
4934
|
+
var DEFAULT_KB = { owner: "mutmutco", repo: "MM-KB", ref: "main" };
|
|
4935
|
+
function resolveKbSource(rawBase) {
|
|
4936
|
+
if (!rawBase) return DEFAULT_KB;
|
|
4937
|
+
const m = rawBase.match(/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/?#]+)/);
|
|
4938
|
+
if (!m) return DEFAULT_KB;
|
|
4939
|
+
return { owner: m[1], repo: m[2], ref: m[3] };
|
|
4940
|
+
}
|
|
4941
|
+
function buildKbGetArgs(src, path) {
|
|
4942
|
+
const clean = path.replace(/^\/+/, "");
|
|
4943
|
+
return ["api", `repos/${src.owner}/${src.repo}/contents/${clean}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
|
|
4944
|
+
}
|
|
4945
|
+
function buildKbTreeArgs(src) {
|
|
4946
|
+
return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
|
|
4947
|
+
}
|
|
4948
|
+
function parseKbTree(stdout, prefix) {
|
|
4949
|
+
let tree;
|
|
4950
|
+
try {
|
|
4951
|
+
tree = JSON.parse(stdout)?.tree ?? [];
|
|
4952
|
+
} catch {
|
|
4953
|
+
return [];
|
|
4954
|
+
}
|
|
4955
|
+
const pre = prefix ? prefix.replace(/^\/+/, "") : void 0;
|
|
4956
|
+
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();
|
|
4957
|
+
}
|
|
4253
4958
|
|
|
4254
4959
|
// src/plan.ts
|
|
4255
4960
|
var import_node_path3 = require("node:path");
|
|
@@ -4391,8 +5096,178 @@ async function planDelete(deps, slug, opts = {}) {
|
|
|
4391
5096
|
deps.log(`deleted ${slug}`);
|
|
4392
5097
|
}
|
|
4393
5098
|
|
|
5099
|
+
// src/secrets.ts
|
|
5100
|
+
var OWNER2 = "mutmutco";
|
|
5101
|
+
var SSM_ROOT = "/mmi-future";
|
|
5102
|
+
var PROJECT_TIER_SEGMENT = "dev";
|
|
5103
|
+
var KEY_RE = /^(?:[a-z][a-z0-9-]*\/)?[A-Za-z][A-Za-z0-9_]*$/;
|
|
5104
|
+
function isValidSecretKey(key) {
|
|
5105
|
+
if (!key || key.length > 256) return false;
|
|
5106
|
+
if (key.includes("..") || key.startsWith("/") || key.includes("*")) return false;
|
|
5107
|
+
return KEY_RE.test(key);
|
|
5108
|
+
}
|
|
5109
|
+
function classifyTier(_slug, key) {
|
|
5110
|
+
const slash = key.indexOf("/");
|
|
5111
|
+
if (slash === -1) return "project";
|
|
5112
|
+
return key.slice(0, slash) === PROJECT_TIER_SEGMENT ? "project" : "org";
|
|
5113
|
+
}
|
|
5114
|
+
function secretParamName(slug, key) {
|
|
5115
|
+
const rel = key.includes("/") ? key : `${PROJECT_TIER_SEGMENT}/${key}`;
|
|
5116
|
+
return `${SSM_ROOT}/${slug}/${rel}`;
|
|
5117
|
+
}
|
|
5118
|
+
function formatSecretList(items) {
|
|
5119
|
+
if (!items.length) return "no secrets";
|
|
5120
|
+
const width = Math.max(...items.map((i) => i.key.length));
|
|
5121
|
+
return items.map((i) => `${i.canManage ? "*" : " "} ${i.key.padEnd(width)} ${i.tier}`).join("\n").concat("\n\n* = you can manage (write/rotate) this secret. Values are never shown \u2014 `secrets get <KEY>` prints one.");
|
|
5122
|
+
}
|
|
5123
|
+
var TIMEOUT_MS2 = 8e3;
|
|
5124
|
+
var repoOf = (slug) => `${OWNER2}/${slug}`;
|
|
5125
|
+
async function targetRepo(deps, opts) {
|
|
5126
|
+
return opts.repo ?? repoOf(await deps.slug());
|
|
5127
|
+
}
|
|
5128
|
+
async function readErr(res) {
|
|
5129
|
+
try {
|
|
5130
|
+
const j = await res.json();
|
|
5131
|
+
return j?.error ? `: ${j.error}` : "";
|
|
5132
|
+
} catch {
|
|
5133
|
+
return "";
|
|
5134
|
+
}
|
|
5135
|
+
}
|
|
5136
|
+
async function secretsList(deps, opts) {
|
|
5137
|
+
const repo = await targetRepo(deps, opts);
|
|
5138
|
+
const qs = new URLSearchParams({ repo }).toString();
|
|
5139
|
+
let res;
|
|
5140
|
+
try {
|
|
5141
|
+
res = await deps.fetch(`${deps.apiUrl}/secrets/list?${qs}`, {
|
|
5142
|
+
method: "GET",
|
|
5143
|
+
headers: await deps.headers(),
|
|
5144
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5145
|
+
});
|
|
5146
|
+
} catch (e) {
|
|
5147
|
+
deps.err(`secrets list: ${e.message}`);
|
|
5148
|
+
return;
|
|
5149
|
+
}
|
|
5150
|
+
if (!res.ok) {
|
|
5151
|
+
deps.err(`secrets list failed: HTTP ${res.status}${await readErr(res)}`);
|
|
5152
|
+
return;
|
|
5153
|
+
}
|
|
5154
|
+
const { secrets: secrets2 } = await res.json();
|
|
5155
|
+
deps.log(formatSecretList(secrets2 ?? []));
|
|
5156
|
+
}
|
|
5157
|
+
async function secretsGet(deps, key, opts) {
|
|
5158
|
+
if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
5159
|
+
const repo = await targetRepo(deps, opts);
|
|
5160
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/get`, {
|
|
5161
|
+
method: "POST",
|
|
5162
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5163
|
+
body: JSON.stringify({ repo, key }),
|
|
5164
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5165
|
+
});
|
|
5166
|
+
if (!res.ok) {
|
|
5167
|
+
deps.err(
|
|
5168
|
+
res.status === 403 ? `secrets get: not authorized for ${key} (HTTP 403)${await readErr(res)}` : `secrets get failed: HTTP ${res.status}${await readErr(res)}`
|
|
5169
|
+
);
|
|
5170
|
+
return;
|
|
5171
|
+
}
|
|
5172
|
+
const { value } = await res.json();
|
|
5173
|
+
deps.log(value ?? "");
|
|
5174
|
+
}
|
|
5175
|
+
async function secretsSet(deps, key, opts) {
|
|
5176
|
+
if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
5177
|
+
const repo = await targetRepo(deps, opts);
|
|
5178
|
+
const value = await deps.readSecretValue(`value for ${key} (input hidden; will not be echoed): `);
|
|
5179
|
+
if (!value) {
|
|
5180
|
+
deps.err("secrets set: empty value \u2014 aborted (nothing written)");
|
|
5181
|
+
return;
|
|
5182
|
+
}
|
|
5183
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/set`, {
|
|
5184
|
+
method: "POST",
|
|
5185
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5186
|
+
body: JSON.stringify({ repo, key, value }),
|
|
5187
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5188
|
+
});
|
|
5189
|
+
if (!res.ok) {
|
|
5190
|
+
deps.err(
|
|
5191
|
+
res.status === 403 ? `secrets set: not authorized to write ${key} (HTTP 403)${await readErr(res)}` : `secrets set failed: HTTP ${res.status}${await readErr(res)}`
|
|
5192
|
+
);
|
|
5193
|
+
return;
|
|
5194
|
+
}
|
|
5195
|
+
deps.log(`set ${key} (${classifyTier(await deps.slug(), key)} tier)`);
|
|
5196
|
+
}
|
|
5197
|
+
async function secretsEdit(deps, key, opts) {
|
|
5198
|
+
return secretsSet(deps, key, opts);
|
|
5199
|
+
}
|
|
5200
|
+
async function secretsRemove(deps, key, opts) {
|
|
5201
|
+
if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
5202
|
+
const repo = await targetRepo(deps, opts);
|
|
5203
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/rm`, {
|
|
5204
|
+
method: "POST",
|
|
5205
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5206
|
+
body: JSON.stringify({ repo, key }),
|
|
5207
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5208
|
+
});
|
|
5209
|
+
if (!res.ok) {
|
|
5210
|
+
deps.err(
|
|
5211
|
+
res.status === 403 ? `secrets rm: not authorized to remove ${key} (HTTP 403)${await readErr(res)}` : `secrets rm failed: HTTP ${res.status}${await readErr(res)}`
|
|
5212
|
+
);
|
|
5213
|
+
return;
|
|
5214
|
+
}
|
|
5215
|
+
deps.log(`removed ${key}`);
|
|
5216
|
+
}
|
|
5217
|
+
async function secretsGrant(deps, repo, login, key, _opts) {
|
|
5218
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/grant`, {
|
|
5219
|
+
method: "POST",
|
|
5220
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5221
|
+
body: JSON.stringify({ repo, login, key }),
|
|
5222
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5223
|
+
});
|
|
5224
|
+
if (!res.ok) {
|
|
5225
|
+
deps.err(
|
|
5226
|
+
res.status === 403 ? `secrets grant: master-admin only (HTTP 403)${await readErr(res)}` : `secrets grant failed: HTTP ${res.status}${await readErr(res)}`
|
|
5227
|
+
);
|
|
5228
|
+
return;
|
|
5229
|
+
}
|
|
5230
|
+
deps.log(`granted @${login} access to ${key} in ${repo}`);
|
|
5231
|
+
}
|
|
5232
|
+
async function secretsRevoke(deps, repo, login, key, _opts) {
|
|
5233
|
+
const res = await deps.fetch(`${deps.apiUrl}/secrets/revoke`, {
|
|
5234
|
+
method: "POST",
|
|
5235
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5236
|
+
body: JSON.stringify({ repo, login, key }),
|
|
5237
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5238
|
+
});
|
|
5239
|
+
if (!res.ok) {
|
|
5240
|
+
deps.err(
|
|
5241
|
+
res.status === 403 ? `secrets revoke: master-admin only (HTTP 403)${await readErr(res)}` : `secrets revoke failed: HTTP ${res.status}${await readErr(res)}`
|
|
5242
|
+
);
|
|
5243
|
+
return;
|
|
5244
|
+
}
|
|
5245
|
+
deps.log(`revoked @${login}'s access to ${key} in ${repo}`);
|
|
5246
|
+
}
|
|
5247
|
+
async function secretsUse(deps, key, _opts) {
|
|
5248
|
+
const slug = await deps.slug();
|
|
5249
|
+
const tier = classifyTier(slug, key);
|
|
5250
|
+
const path = secretParamName(slug, key);
|
|
5251
|
+
deps.log(
|
|
5252
|
+
[
|
|
5253
|
+
`${key} \u2192 ${path} (${tier} tier)`,
|
|
5254
|
+
"",
|
|
5255
|
+
"Consume it WITHOUT committing it:",
|
|
5256
|
+
` \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.`,
|
|
5257
|
+
` \u2022 CI (GitHub Actions): the workflow assumes its OIDC role and runs \`aws ssm get-parameter --with-decryption --name ${path}\` \u2014 no GitHub secret.`,
|
|
5258
|
+
" \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.",
|
|
5259
|
+
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`."
|
|
5260
|
+
].join("\n")
|
|
5261
|
+
);
|
|
5262
|
+
}
|
|
5263
|
+
|
|
4394
5264
|
// src/index.ts
|
|
4395
|
-
var
|
|
5265
|
+
var rawExecFileP2 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
|
|
5266
|
+
var execFileP3 = (file, args, options = {}) => (
|
|
5267
|
+
// encoding 'utf8' guarantees string stdout/stderr at runtime; the cast pins the type because
|
|
5268
|
+
// promisify(execFile)'s overloads widen to string|Buffer when options is spread in.
|
|
5269
|
+
rawExecFileP2(file, args, { encoding: "utf8", windowsHide: true, ...options })
|
|
5270
|
+
);
|
|
4396
5271
|
var GIT_TIMEOUT_MS = 1e4;
|
|
4397
5272
|
var GC_GH_TIMEOUT_MS = 2e4;
|
|
4398
5273
|
async function githubToken() {
|
|
@@ -4425,7 +5300,6 @@ async function loadConfig() {
|
|
|
4425
5300
|
}
|
|
4426
5301
|
}
|
|
4427
5302
|
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
5303
|
var SESSION_FILE = ".mmi/.session";
|
|
4430
5304
|
var gitOut = async (args) => {
|
|
4431
5305
|
try {
|
|
@@ -4439,7 +5313,7 @@ function sessionDeps() {
|
|
|
4439
5313
|
env: process.env,
|
|
4440
5314
|
readPersisted: () => {
|
|
4441
5315
|
try {
|
|
4442
|
-
return (0,
|
|
5316
|
+
return (0, import_node_fs4.readFileSync)(SESSION_FILE, "utf8");
|
|
4443
5317
|
} catch {
|
|
4444
5318
|
return null;
|
|
4445
5319
|
}
|
|
@@ -4452,8 +5326,8 @@ function sessionDeps() {
|
|
|
4452
5326
|
var resolveSessionId = () => resolveSession(sessionDeps());
|
|
4453
5327
|
function persistSession(id) {
|
|
4454
5328
|
try {
|
|
4455
|
-
(0,
|
|
4456
|
-
(0,
|
|
5329
|
+
(0, import_node_fs4.mkdirSync)(".mmi", { recursive: true });
|
|
5330
|
+
(0, import_node_fs4.writeFileSync)(SESSION_FILE, id, "utf8");
|
|
4457
5331
|
} catch {
|
|
4458
5332
|
}
|
|
4459
5333
|
}
|
|
@@ -4473,7 +5347,11 @@ async function postCapture(capture, quiet = false) {
|
|
|
4473
5347
|
method: "POST",
|
|
4474
5348
|
headers: await sagaHeaders({ "content-type": "application/json" }),
|
|
4475
5349
|
body: JSON.stringify({ ...capture, ...await sagaKey(cfg) }),
|
|
4476
|
-
|
|
5350
|
+
// Capture latency is high + variable (server-side HEAD render); 8s dropped larger notes. Match the
|
|
5351
|
+
// head-write timeout (20s) so a continuity note isn't lost to a slow/cold backend. No client retry:
|
|
5352
|
+
// the capture isn't guaranteed idempotent, so a retry after a server-side-completed write could
|
|
5353
|
+
// duplicate the note. Backend capture-latency root cause tracked in #255.
|
|
5354
|
+
signal: AbortSignal.timeout(2e4)
|
|
4477
5355
|
});
|
|
4478
5356
|
if (!quiet) console.log(res.ok ? "noted" : `saga: HTTP ${res.status}`);
|
|
4479
5357
|
} catch (e) {
|
|
@@ -4537,14 +5415,35 @@ async function applyGcPlan(plan2, remote) {
|
|
|
4537
5415
|
await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
|
|
4538
5416
|
}
|
|
4539
5417
|
}
|
|
5418
|
+
async function cleanupLocalBranch(branch) {
|
|
5419
|
+
const result = { branchDeleted: false };
|
|
5420
|
+
if (!branch) return result;
|
|
5421
|
+
const { stdout } = await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
|
|
5422
|
+
const wt = parseWorktreePorcelain(stdout).find((w) => w.branch === branch);
|
|
5423
|
+
if (wt) {
|
|
5424
|
+
await execFileP3("git", ["worktree", "remove", "--force", wt.path], { timeout: GIT_TIMEOUT_MS }).catch(() => {
|
|
5425
|
+
});
|
|
5426
|
+
result.worktreeRemoved = wt.path;
|
|
5427
|
+
}
|
|
5428
|
+
const current = await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "";
|
|
5429
|
+
if (branch !== current) {
|
|
5430
|
+
await execFileP3("git", ["branch", "-D", branch], { timeout: GIT_TIMEOUT_MS }).then(() => {
|
|
5431
|
+
result.branchDeleted = true;
|
|
5432
|
+
}).catch(() => {
|
|
5433
|
+
});
|
|
5434
|
+
}
|
|
5435
|
+
if (wt) await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS }).catch(() => {
|
|
5436
|
+
});
|
|
5437
|
+
return result;
|
|
5438
|
+
}
|
|
4540
5439
|
function resolveVersion() {
|
|
4541
5440
|
try {
|
|
4542
5441
|
const manifest = (0, import_node_path4.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
|
|
4543
|
-
return JSON.parse((0,
|
|
5442
|
+
return JSON.parse((0, import_node_fs4.readFileSync)(manifest, "utf8")).version || "0.0.0";
|
|
4544
5443
|
} catch {
|
|
4545
5444
|
try {
|
|
4546
5445
|
const pkg = (0, import_node_path4.join)(__dirname, "..", "package.json");
|
|
4547
|
-
return JSON.parse((0,
|
|
5446
|
+
return JSON.parse((0, import_node_fs4.readFileSync)(pkg, "utf8")).version || "0.0.0";
|
|
4548
5447
|
} catch {
|
|
4549
5448
|
return "0.0.0";
|
|
4550
5449
|
}
|
|
@@ -4552,7 +5451,7 @@ function resolveVersion() {
|
|
|
4552
5451
|
}
|
|
4553
5452
|
function readRepoVersion() {
|
|
4554
5453
|
try {
|
|
4555
|
-
return JSON.parse((0,
|
|
5454
|
+
return JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path4.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
|
|
4556
5455
|
} catch {
|
|
4557
5456
|
return void 0;
|
|
4558
5457
|
}
|
|
@@ -4568,6 +5467,31 @@ async function fetchReleasedVersion() {
|
|
|
4568
5467
|
return void 0;
|
|
4569
5468
|
}
|
|
4570
5469
|
}
|
|
5470
|
+
var NPM_UPDATE_TIMEOUT_MS = 12e4;
|
|
5471
|
+
var PLUGIN_PULL_TIMEOUT_MS = 3e4;
|
|
5472
|
+
async function applyVersionAutoUpdate(report, log) {
|
|
5473
|
+
const action = versionAutoUpdateAction(report, Boolean(process.env.CLAUDE_PLUGIN_ROOT));
|
|
5474
|
+
if (action === "none") return report;
|
|
5475
|
+
const target = report.releasedVersion ?? "latest";
|
|
5476
|
+
if (action === "plugin-pull") {
|
|
5477
|
+
try {
|
|
5478
|
+
const root = (await execFileP3("git", ["-C", process.env.CLAUDE_PLUGIN_ROOT, "rev-parse", "--show-toplevel"], { timeout: PLUGIN_PULL_TIMEOUT_MS })).stdout.trim();
|
|
5479
|
+
log(` \u21BB refreshing MMI plugin ${report.currentVersion} \u2192 ${target} (effective next session)\u2026`);
|
|
5480
|
+
await execFileP3("git", ["-C", root, "pull", "--ff-only"], { timeout: PLUGIN_PULL_TIMEOUT_MS });
|
|
5481
|
+
return { ...report, ok: true };
|
|
5482
|
+
} catch {
|
|
5483
|
+
return report;
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
try {
|
|
5487
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
5488
|
+
log(` \u21BB updating mmi-cli ${report.currentVersion} \u2192 ${target}\u2026`);
|
|
5489
|
+
await execFileP3(npm, ["install", "-g", "@mutmutco/cli@latest"], { timeout: NPM_UPDATE_TIMEOUT_MS });
|
|
5490
|
+
return { ...report, ok: true };
|
|
5491
|
+
} catch {
|
|
5492
|
+
return report;
|
|
5493
|
+
}
|
|
5494
|
+
}
|
|
4571
5495
|
var program2 = new Command();
|
|
4572
5496
|
program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveVersion());
|
|
4573
5497
|
var rules = program2.command("rules").description("org rules delivery");
|
|
@@ -4591,10 +5515,10 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
|
|
|
4591
5515
|
if (!opts.quiet) console.error(`mmi-cli rules: could not fetch ${file} (${e.message}); left it untouched`);
|
|
4592
5516
|
continue;
|
|
4593
5517
|
}
|
|
4594
|
-
const current = (0,
|
|
5518
|
+
const current = (0, import_node_fs4.existsSync)(file) ? await (0, import_promises.readFile)(file, "utf8") : null;
|
|
4595
5519
|
if (needsUpdate(source, current)) {
|
|
4596
5520
|
const slash = file.lastIndexOf("/");
|
|
4597
|
-
if (slash > 0) (0,
|
|
5521
|
+
if (slash > 0) (0, import_node_fs4.mkdirSync)(file.slice(0, slash), { recursive: true });
|
|
4598
5522
|
await (0, import_promises.writeFile)(file, normalizeEol(source), "utf8");
|
|
4599
5523
|
changed++;
|
|
4600
5524
|
if (!opts.quiet) console.log(`mmi-cli rules: updated ${file}`);
|
|
@@ -4602,6 +5526,29 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
|
|
|
4602
5526
|
}
|
|
4603
5527
|
if (!opts.quiet && changed === 0) console.log("mmi-cli rules: up to date");
|
|
4604
5528
|
});
|
|
5529
|
+
var docs = program2.command("docs").description("repo-owned authoritative docs");
|
|
5530
|
+
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) => {
|
|
5531
|
+
const ref = await gitOut(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
|
|
5532
|
+
const def = (ref.startsWith("origin/") ? ref.slice("origin/".length) : ref) || "development";
|
|
5533
|
+
await gitOut(["fetch", "origin", def, "--quiet"]);
|
|
5534
|
+
const result = await syncDocs({
|
|
5535
|
+
isDirty: async (f) => await gitOut(["status", "--porcelain", "--", f]) !== "",
|
|
5536
|
+
originContent: async (f) => {
|
|
5537
|
+
try {
|
|
5538
|
+
return (await execFileP3("git", ["show", `origin/${def}:${f}`], { maxBuffer: 10 * 1024 * 1024 })).stdout;
|
|
5539
|
+
} catch {
|
|
5540
|
+
return null;
|
|
5541
|
+
}
|
|
5542
|
+
},
|
|
5543
|
+
localContent: async (f) => (0, import_node_fs4.existsSync)(f) ? await (0, import_promises.readFile)(f, "utf8") : null,
|
|
5544
|
+
writeDoc: async (f, c) => {
|
|
5545
|
+
await (0, import_promises.writeFile)(f, c, "utf8");
|
|
5546
|
+
}
|
|
5547
|
+
});
|
|
5548
|
+
for (const f of result.updated) console.log(`mmi-cli docs: updated ${f} (from origin/${def})`);
|
|
5549
|
+
if (!opts.quiet && result.skippedDirty.length) console.log(`mmi-cli docs: kept local edits in ${result.skippedDirty.join(", ")}`);
|
|
5550
|
+
if (!opts.quiet && result.updated.length === 0 && result.skippedDirty.length === 0) console.log("mmi-cli docs: up to date");
|
|
5551
|
+
});
|
|
4605
5552
|
var saga = program2.command("saga").description("per-session continuity");
|
|
4606
5553
|
async function runNote(summary, o) {
|
|
4607
5554
|
const [sha, key] = await Promise.all([gitOut(["rev-parse", "--short", "HEAD"]), sagaKey(await loadConfig())]);
|
|
@@ -4719,11 +5666,28 @@ program2.command("gc").description("dry-run cleanup for merged/closed PR branche
|
|
|
4719
5666
|
fail(`gc: ${e.message}`);
|
|
4720
5667
|
}
|
|
4721
5668
|
});
|
|
4722
|
-
program2.command("kb").description("org knowledgebase (read-only)")
|
|
4723
|
-
|
|
4724
|
-
const
|
|
4725
|
-
|
|
4726
|
-
|
|
5669
|
+
var kb = program2.command("kb").description("org knowledgebase (read-only)");
|
|
5670
|
+
kb.command("get <path>").description("print a KB document by path").action(async (path) => {
|
|
5671
|
+
const src = resolveKbSource((await loadConfig()).kbSource);
|
|
5672
|
+
try {
|
|
5673
|
+
const { stdout } = await execFileP3("gh", buildKbGetArgs(src, path), { timeout: 1e4 });
|
|
5674
|
+
process.stdout.write(stdout);
|
|
5675
|
+
} catch (e) {
|
|
5676
|
+
const err = e;
|
|
5677
|
+
fail(`kb get failed: ${(err.stderr || err.message || String(e)).trim()}`);
|
|
5678
|
+
}
|
|
5679
|
+
});
|
|
5680
|
+
kb.command("list [prefix]").description("list KB document paths (optionally under a prefix)").action(async (prefix) => {
|
|
5681
|
+
const src = resolveKbSource((await loadConfig()).kbSource);
|
|
5682
|
+
try {
|
|
5683
|
+
const { stdout } = await execFileP3("gh", buildKbTreeArgs(src), { timeout: 1e4 });
|
|
5684
|
+
const paths = parseKbTree(stdout, prefix);
|
|
5685
|
+
if (!paths.length) return fail(`kb list: no documents${prefix ? ` under ${prefix}` : ""}`);
|
|
5686
|
+
console.log(paths.join("\n"));
|
|
5687
|
+
} catch (e) {
|
|
5688
|
+
const err = e;
|
|
5689
|
+
fail(`kb list failed: ${(err.stderr || err.message || String(e)).trim()}`);
|
|
5690
|
+
}
|
|
4727
5691
|
});
|
|
4728
5692
|
async function ghCreate(args) {
|
|
4729
5693
|
try {
|
|
@@ -4734,7 +5698,7 @@ async function ghCreate(args) {
|
|
|
4734
5698
|
fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}`);
|
|
4735
5699
|
}
|
|
4736
5700
|
}
|
|
4737
|
-
async function
|
|
5701
|
+
async function ghJson3(args, timeout = 1e4) {
|
|
4738
5702
|
const { stdout } = await execFileP3("gh", args, { timeout });
|
|
4739
5703
|
return JSON.parse(stdout);
|
|
4740
5704
|
}
|
|
@@ -4747,6 +5711,47 @@ async function resolveRepo(repo) {
|
|
|
4747
5711
|
return void 0;
|
|
4748
5712
|
}
|
|
4749
5713
|
}
|
|
5714
|
+
async function attachToProject(issueNumber, repo, priority) {
|
|
5715
|
+
const cfg = await loadConfig();
|
|
5716
|
+
if (!cfg.projectId) return void 0;
|
|
5717
|
+
if (repo) {
|
|
5718
|
+
const skip = boardAttachSkipReason(await resolveRepo(), repo);
|
|
5719
|
+
if (skip) {
|
|
5720
|
+
process.stderr.write(`warning: issue #${issueNumber} NOT added to this board \u2014 ${skip}
|
|
5721
|
+
`);
|
|
5722
|
+
return void 0;
|
|
5723
|
+
}
|
|
5724
|
+
}
|
|
5725
|
+
try {
|
|
5726
|
+
const viewArgs = ["issue", "view", String(issueNumber), "--json", "id", "--jq", ".id"];
|
|
5727
|
+
if (repo) viewArgs.push("--repo", repo);
|
|
5728
|
+
const { stdout: idOut } = await execFileP3("gh", viewArgs, { timeout: 1e4 });
|
|
5729
|
+
const contentId = idOut.trim();
|
|
5730
|
+
if (!contentId) throw new Error("could not resolve issue node id");
|
|
5731
|
+
const { stdout } = await execFileP3("gh", buildAddToProjectArgs(cfg.projectId, contentId), { timeout: 1e4 });
|
|
5732
|
+
const projectItemId = parseAddedItemId(stdout);
|
|
5733
|
+
if (projectItemId && priority) {
|
|
5734
|
+
try {
|
|
5735
|
+
await setBoardItemPriority(
|
|
5736
|
+
async (args) => execFileP3("gh", args, { timeout: 1e4 }),
|
|
5737
|
+
cfg,
|
|
5738
|
+
projectItemId,
|
|
5739
|
+
priority
|
|
5740
|
+
);
|
|
5741
|
+
} catch (e) {
|
|
5742
|
+
const err = e;
|
|
5743
|
+
process.stderr.write(`warning: issue #${issueNumber} board Priority not set: ${(err.stderr || err.message || String(e)).trim()}
|
|
5744
|
+
`);
|
|
5745
|
+
}
|
|
5746
|
+
}
|
|
5747
|
+
return projectItemId;
|
|
5748
|
+
} catch (e) {
|
|
5749
|
+
const err = e;
|
|
5750
|
+
process.stderr.write(`warning: issue #${issueNumber} created but NOT added to the project board: ${(err.stderr || err.message || String(e)).trim()}
|
|
5751
|
+
`);
|
|
5752
|
+
return void 0;
|
|
5753
|
+
}
|
|
5754
|
+
}
|
|
4750
5755
|
function scheduleRelatedDiscovery(o) {
|
|
4751
5756
|
try {
|
|
4752
5757
|
const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
|
|
@@ -4761,7 +5766,7 @@ function scheduleRelatedDiscovery(o) {
|
|
|
4761
5766
|
}
|
|
4762
5767
|
}
|
|
4763
5768
|
function makePlanDeps(cfg) {
|
|
4764
|
-
const ensureDir = () => (0,
|
|
5769
|
+
const ensureDir = () => (0, import_node_fs4.mkdirSync)(PLANS_DIR, { recursive: true });
|
|
4765
5770
|
return {
|
|
4766
5771
|
apiUrl: cfg.sagaApiUrl,
|
|
4767
5772
|
fetch: (url, init) => fetch(url, init),
|
|
@@ -4769,31 +5774,31 @@ function makePlanDeps(cfg) {
|
|
|
4769
5774
|
project: async () => (await sagaKey(cfg)).project,
|
|
4770
5775
|
readLocal: (slug) => {
|
|
4771
5776
|
try {
|
|
4772
|
-
return (0,
|
|
5777
|
+
return (0, import_node_fs4.readFileSync)(planPath(slug), "utf8");
|
|
4773
5778
|
} catch {
|
|
4774
5779
|
return null;
|
|
4775
5780
|
}
|
|
4776
5781
|
},
|
|
4777
5782
|
writeLocal: (slug, content) => {
|
|
4778
5783
|
ensureDir();
|
|
4779
|
-
(0,
|
|
5784
|
+
(0, import_node_fs4.writeFileSync)(planPath(slug), content, "utf8");
|
|
4780
5785
|
},
|
|
4781
5786
|
removeLocal: (slug) => {
|
|
4782
5787
|
try {
|
|
4783
|
-
(0,
|
|
5788
|
+
(0, import_node_fs4.rmSync)(planPath(slug));
|
|
4784
5789
|
} catch {
|
|
4785
5790
|
}
|
|
4786
5791
|
},
|
|
4787
5792
|
readMetaRaw: () => {
|
|
4788
5793
|
try {
|
|
4789
|
-
return (0,
|
|
5794
|
+
return (0, import_node_fs4.readFileSync)(META_FILE, "utf8");
|
|
4790
5795
|
} catch {
|
|
4791
5796
|
return null;
|
|
4792
5797
|
}
|
|
4793
5798
|
},
|
|
4794
5799
|
writeMetaRaw: (raw) => {
|
|
4795
5800
|
ensureDir();
|
|
4796
|
-
(0,
|
|
5801
|
+
(0, import_node_fs4.writeFileSync)(META_FILE, raw, "utf8");
|
|
4797
5802
|
},
|
|
4798
5803
|
log: (m) => console.log(m),
|
|
4799
5804
|
err: (m) => console.error(m),
|
|
@@ -4831,17 +5836,65 @@ plan.command("open <slug>").description("pull if needed, then open plans/<slug>.
|
|
|
4831
5836
|
})
|
|
4832
5837
|
);
|
|
4833
5838
|
plan.command("delete <slug>").description("delete a 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)));
|
|
5839
|
+
async function readSecretStdin() {
|
|
5840
|
+
if (process.stdin.isTTY) {
|
|
5841
|
+
process.stderr.write(
|
|
5842
|
+
'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'
|
|
5843
|
+
);
|
|
5844
|
+
return "";
|
|
5845
|
+
}
|
|
5846
|
+
const chunks = [];
|
|
5847
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
5848
|
+
return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
|
|
5849
|
+
}
|
|
5850
|
+
function makeSecretsDeps(cfg) {
|
|
5851
|
+
return {
|
|
5852
|
+
apiUrl: cfg.sagaApiUrl,
|
|
5853
|
+
fetch: (url, init) => fetch(url, init),
|
|
5854
|
+
headers: (extra) => sagaHeaders(extra),
|
|
5855
|
+
slug: async () => (await sagaKey(cfg)).project,
|
|
5856
|
+
readSecretValue: () => readSecretStdin(),
|
|
5857
|
+
log: (m) => console.log(m),
|
|
5858
|
+
err: (m) => console.error(m)
|
|
5859
|
+
};
|
|
5860
|
+
}
|
|
5861
|
+
async function withSecrets(run) {
|
|
5862
|
+
const cfg = await loadConfig();
|
|
5863
|
+
if (!cfg.sagaApiUrl) {
|
|
5864
|
+
fail("secrets: sagaApiUrl not configured in .mmi/config.json (this repo is not bootstrapped)");
|
|
5865
|
+
return;
|
|
5866
|
+
}
|
|
5867
|
+
await run(makeSecretsDeps(cfg));
|
|
5868
|
+
}
|
|
5869
|
+
var secrets = program2.command("secrets").description("two-tier project secrets \u2014 self-serve your repo dev/* tier; org tier is master-gated");
|
|
5870
|
+
secrets.command("list").description("list secret NAMES + tier for this repo (never values)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((o) => withSecrets((d) => secretsList(d, o)));
|
|
5871
|
+
secrets.command("get <key>").description("print one secret value over TLS (prints once, raw \u2014 do not log/paste it)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsGet(d, key, o)));
|
|
5872
|
+
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)));
|
|
5873
|
+
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)));
|
|
5874
|
+
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)));
|
|
5875
|
+
secrets.command("use <key>").description("print guidance on consuming a secret without committing it (no value)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsUse(d, key, o)));
|
|
5876
|
+
secrets.command("grant <repo> <login> <key>").description("MASTER-ONLY: grant a project-admin standing access to a specific org-tier secret").action((repo, login, key) => withSecrets((d) => secretsGrant(d, repo, login, key, {})));
|
|
5877
|
+
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, {})));
|
|
4834
5878
|
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 (
|
|
5879
|
+
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
5880
|
let args;
|
|
4837
5881
|
try {
|
|
4838
|
-
args = buildIssueArgs({ type: o.type, title: o.title, body: o.body, priority: o.priority, repo: o.repo });
|
|
5882
|
+
args = buildIssueArgs({ type: o.type, title: o.title, body: o.body, priority: o.priority, repo: o.repo, labels: o.label });
|
|
4839
5883
|
} catch (e) {
|
|
4840
5884
|
return fail(`issue create: ${e.message}`);
|
|
4841
5885
|
}
|
|
5886
|
+
for (const label of o.label ?? []) {
|
|
5887
|
+
const la = ["label", "create", label, "--color", "ededed"];
|
|
5888
|
+
if (o.repo) la.push("--repo", o.repo);
|
|
5889
|
+
try {
|
|
5890
|
+
await execFileP3("gh", la, { timeout: 1e4 });
|
|
5891
|
+
} catch {
|
|
5892
|
+
}
|
|
5893
|
+
}
|
|
4842
5894
|
const created = await ghCreate(args);
|
|
4843
|
-
|
|
4844
|
-
|
|
5895
|
+
const projectItemId = await attachToProject(created.number, o.repo, o.priority);
|
|
5896
|
+
if (o.related !== false) scheduleRelatedDiscovery({ repo: o.repo, number: created.number, title: o.title, body: o.body });
|
|
5897
|
+
console.log(JSON.stringify({ ...created, label: o.type, priority: o.priority, projectItemId }));
|
|
4845
5898
|
});
|
|
4846
5899
|
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
5900
|
const number = Number(o.number);
|
|
@@ -4849,7 +5902,7 @@ issue.command("discover-related").description("find related issues for an existi
|
|
|
4849
5902
|
const repo = await resolveRepo(o.repo);
|
|
4850
5903
|
if (!repo) return fail("issue discover-related: could not resolve repo");
|
|
4851
5904
|
try {
|
|
4852
|
-
const issues = await
|
|
5905
|
+
const issues = await ghJson3([
|
|
4853
5906
|
"issue",
|
|
4854
5907
|
"list",
|
|
4855
5908
|
"--repo",
|
|
@@ -4864,7 +5917,7 @@ issue.command("discover-related").description("find related issues for an existi
|
|
|
4864
5917
|
const candidates = findRelatedIssues({ number, title: o.title, body: o.body }, issues);
|
|
4865
5918
|
if (o.json) return console.log(JSON.stringify({ number, repo, candidates }, null, 2));
|
|
4866
5919
|
if (!candidates.length) return;
|
|
4867
|
-
const viewed = await
|
|
5920
|
+
const viewed = await ghJson3([
|
|
4868
5921
|
"issue",
|
|
4869
5922
|
"view",
|
|
4870
5923
|
String(number),
|
|
@@ -4883,6 +5936,16 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
|
|
|
4883
5936
|
const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
|
|
4884
5937
|
console.log(JSON.stringify(created));
|
|
4885
5938
|
});
|
|
5939
|
+
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) => {
|
|
5940
|
+
const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
|
|
5941
|
+
const repoArgs = o.repo ? ["--repo", o.repo] : [];
|
|
5942
|
+
const headRef = (await execFileP3("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
|
|
5943
|
+
await execFileP3("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
|
|
5944
|
+
if (!/used by worktree|cannot delete branch|already been merged/i.test(String(e.message || ""))) throw e;
|
|
5945
|
+
});
|
|
5946
|
+
const cleaned = repoArgs.length ? { branchDeleted: false } : await cleanupLocalBranch(headRef);
|
|
5947
|
+
console.log(JSON.stringify({ merged: number, branch: headRef, method: method.slice(2), ...cleaned }));
|
|
5948
|
+
});
|
|
4886
5949
|
async function runBoardRead(o) {
|
|
4887
5950
|
try {
|
|
4888
5951
|
const report = await readBoard({
|
|
@@ -4896,17 +5959,69 @@ async function runBoardRead(o) {
|
|
|
4896
5959
|
fail(`board read failed: ${e.message}`);
|
|
4897
5960
|
}
|
|
4898
5961
|
}
|
|
4899
|
-
var board = program2.command("board").description("read and
|
|
5962
|
+
var board = program2.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
|
|
4900
5963
|
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
|
|
5964
|
+
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
5965
|
try {
|
|
4903
|
-
const result = await claimBoardIssue({
|
|
5966
|
+
const result = await claimBoardIssue({
|
|
5967
|
+
config: await loadConfig(),
|
|
5968
|
+
selector: issueRef,
|
|
5969
|
+
repo: o.repo,
|
|
5970
|
+
assignee: o.for,
|
|
5971
|
+
allowPartial: o.allowPartial
|
|
5972
|
+
});
|
|
4904
5973
|
if (o.json) return console.log(JSON.stringify(result));
|
|
4905
5974
|
console.log(result.partial ? `Partially claimed ${result.item.ref}: ${result.warning}` : `Claimed ${result.item.ref} - In Progress`);
|
|
4906
5975
|
} catch (e) {
|
|
4907
5976
|
fail(`board claim failed: ${e.message}`);
|
|
4908
5977
|
}
|
|
4909
5978
|
});
|
|
5979
|
+
board.command("show <issue>").alias("open").description("print one board item (status, assignees, type, url) with its body and comments").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--allow-partial", "return the item even if its body/comments fetch fails").action(async (issueRef, o) => {
|
|
5980
|
+
try {
|
|
5981
|
+
const item = await showBoardItem({ config: await loadConfig(), selector: issueRef, repo: o.repo, allowPartial: o.allowPartial });
|
|
5982
|
+
console.log(o.json ? JSON.stringify(item) : renderBoardItem(item));
|
|
5983
|
+
} catch (e) {
|
|
5984
|
+
fail(`board show failed: ${e.message}`);
|
|
5985
|
+
}
|
|
5986
|
+
});
|
|
5987
|
+
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) => {
|
|
5988
|
+
if (!BOARD_STATUSES.includes(status)) {
|
|
5989
|
+
return fail(`board move failed: unknown status '${status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
|
|
5990
|
+
}
|
|
5991
|
+
try {
|
|
5992
|
+
const result = await moveBoardItem({ config: await loadConfig(), selector: issueRef, status, repo: o.repo, allowPartial: o.allowPartial });
|
|
5993
|
+
if (o.json) return console.log(JSON.stringify(result));
|
|
5994
|
+
console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> ${result.status}`);
|
|
5995
|
+
} catch (e) {
|
|
5996
|
+
fail(`board move failed: ${e.message}`);
|
|
5997
|
+
}
|
|
5998
|
+
});
|
|
5999
|
+
board.command("backfill-priority").description("set board Priority from priority:* labels or issue timeline for items missing the field").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--dry-run", "report what would be set without writing").option("--concurrency <n>", "parallel timeline reads (default 8)", "8").action(async (o) => {
|
|
6000
|
+
try {
|
|
6001
|
+
const result = await backfillBoardPriorities({
|
|
6002
|
+
config: await loadConfig(),
|
|
6003
|
+
repo: o.repo,
|
|
6004
|
+
dryRun: o.dryRun,
|
|
6005
|
+
concurrency: Number(o.concurrency) || 8
|
|
6006
|
+
});
|
|
6007
|
+
if (o.json) return console.log(JSON.stringify(result));
|
|
6008
|
+
console.log(`backfill-priority: scanned ${result.scanned}, set ${result.set}, skipped ${result.skipped}, failed ${result.failed}`);
|
|
6009
|
+
for (const line of result.details.slice(0, 30)) console.log(` ${line}`);
|
|
6010
|
+
if (result.details.length > 30) console.log(` ... +${result.details.length - 30} more`);
|
|
6011
|
+
if (result.failed) process.exitCode = 1;
|
|
6012
|
+
} catch (e) {
|
|
6013
|
+
fail(`board backfill-priority failed: ${e.message}`);
|
|
6014
|
+
}
|
|
6015
|
+
});
|
|
6016
|
+
board.command("done <issue>").description("set a board item's Status to Done (does not close the GitHub issue; use `gh issue close`)").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--allow-partial", "return success JSON if the item resolves but the status move fails").action(async (issueRef, o) => {
|
|
6017
|
+
try {
|
|
6018
|
+
const result = await moveBoardItem({ config: await loadConfig(), selector: issueRef, status: "Done", repo: o.repo, allowPartial: o.allowPartial });
|
|
6019
|
+
if (o.json) return console.log(JSON.stringify(result));
|
|
6020
|
+
console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> Done`);
|
|
6021
|
+
} catch (e) {
|
|
6022
|
+
fail(`board done failed: ${e.message}`);
|
|
6023
|
+
}
|
|
6024
|
+
});
|
|
4910
6025
|
function renderSteps(title, steps) {
|
|
4911
6026
|
return [
|
|
4912
6027
|
title,
|
|
@@ -4921,12 +6036,17 @@ function rawValue(flag, fallback) {
|
|
|
4921
6036
|
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
|
4922
6037
|
}
|
|
4923
6038
|
function printLine(value) {
|
|
4924
|
-
(0,
|
|
6039
|
+
(0, import_node_fs4.writeSync)(1, `${value}
|
|
4925
6040
|
`);
|
|
4926
6041
|
}
|
|
4927
6042
|
function stageKeepAlive() {
|
|
4928
6043
|
return setTimeout(() => void 0, 5 * 60 * 1e3);
|
|
4929
6044
|
}
|
|
6045
|
+
program2.command("port-range <repo>").description("assign (idempotently) + print the repo's local stage port block from infra/port-ranges.json").option("--json", "machine-readable output").action((repo, o) => {
|
|
6046
|
+
const path = (0, import_node_path4.join)(process.cwd(), "infra", "port-ranges.json");
|
|
6047
|
+
const [start, end] = ensurePortRange(repo, path);
|
|
6048
|
+
printLine(o.json ? JSON.stringify({ repo, portRange: [start, end] }) : `${repo}: stage.portRange [${start}, ${end}]`);
|
|
6049
|
+
});
|
|
4930
6050
|
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
6051
|
const cfg = (await loadConfig()).stage;
|
|
4932
6052
|
if (o.apply) {
|
|
@@ -5028,11 +6148,101 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
|
|
|
5028
6148
|
if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
|
|
5029
6149
|
const report = await verifyBootstrap(repo, o.class, {
|
|
5030
6150
|
gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
|
|
5031
|
-
readLocalFile: (path) => (0,
|
|
6151
|
+
readLocalFile: (path) => (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : null
|
|
5032
6152
|
});
|
|
5033
6153
|
console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
|
|
5034
6154
|
if (!report.ok) process.exitCode = 1;
|
|
5035
6155
|
});
|
|
6156
|
+
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) => {
|
|
6157
|
+
const o = { class: rawValue("--class", "deployable"), execute: rawFlag("--execute"), json: rawFlag("--json") };
|
|
6158
|
+
if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap apply: --class must be deployable or content");
|
|
6159
|
+
const manifestPath = "skills/bootstrap/seeds/manifest.json";
|
|
6160
|
+
if (!(0, import_node_fs4.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; run from the MMI-Hub repo root`);
|
|
6161
|
+
const manifest = loadBootstrapSeeds((0, import_node_fs4.readFileSync)(manifestPath, "utf8"));
|
|
6162
|
+
const baseBranch = o.class === "content" ? "main" : "development";
|
|
6163
|
+
const slug = repo.split("/")[1].toLowerCase();
|
|
6164
|
+
const gh = async (args) => execFileP3("gh", args, { timeout: 2e4 });
|
|
6165
|
+
const readFile2 = (p) => (0, import_node_fs4.existsSync)(p) ? (0, import_node_fs4.readFileSync)(p, "utf8") : null;
|
|
6166
|
+
const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
|
|
6167
|
+
const vars = {};
|
|
6168
|
+
for (let i = 0; i < process.argv.length - 1; i++) {
|
|
6169
|
+
if (process.argv[i] === "--var") {
|
|
6170
|
+
const eq = process.argv[i + 1].indexOf("=");
|
|
6171
|
+
if (eq > 0) vars[process.argv[i + 1].slice(0, eq)] = process.argv[i + 1].slice(eq + 1);
|
|
6172
|
+
}
|
|
6173
|
+
}
|
|
6174
|
+
const actions = [];
|
|
6175
|
+
const applied = [];
|
|
6176
|
+
for (const seed of manifest.seeds) {
|
|
6177
|
+
if (!seed.classes.includes(o.class)) continue;
|
|
6178
|
+
const resolved = { ...seed, target: seed.target.replace("{{REPO_SLUG}}", slug) };
|
|
6179
|
+
let exists = false;
|
|
6180
|
+
let sha;
|
|
6181
|
+
if (resolved.source !== "fanout") {
|
|
6182
|
+
try {
|
|
6183
|
+
const r = await gh(["api", `repos/${repo}/contents/${enc(resolved.target)}?ref=${baseBranch}`]);
|
|
6184
|
+
exists = true;
|
|
6185
|
+
try {
|
|
6186
|
+
sha = JSON.parse(r.stdout).sha;
|
|
6187
|
+
} catch {
|
|
6188
|
+
}
|
|
6189
|
+
} catch {
|
|
6190
|
+
exists = false;
|
|
6191
|
+
}
|
|
6192
|
+
}
|
|
6193
|
+
const action = planSeedAction(resolved, exists);
|
|
6194
|
+
actions.push(action);
|
|
6195
|
+
if (o.execute && (action.action === "create" || action.action === "update")) {
|
|
6196
|
+
const content = resolveSeedContent(resolved, vars, readFile2);
|
|
6197
|
+
if (content == null) {
|
|
6198
|
+
applied.push(`skip ${resolved.target} (no resolvable content)`);
|
|
6199
|
+
continue;
|
|
6200
|
+
}
|
|
6201
|
+
const missing = missingPlaceholders(content);
|
|
6202
|
+
if (missing.length) {
|
|
6203
|
+
applied.push(`skip ${resolved.target} (unfilled: ${missing.join(", ")} \u2014 pass --var)`);
|
|
6204
|
+
continue;
|
|
6205
|
+
}
|
|
6206
|
+
await gh(contentPutArgs(repo, resolved.target, content, baseBranch, action.action === "update" ? sha : void 0));
|
|
6207
|
+
applied.push(`${action.action} ${resolved.target}`);
|
|
6208
|
+
}
|
|
6209
|
+
}
|
|
6210
|
+
if (o.execute) {
|
|
6211
|
+
for (const l of manifest.labels) {
|
|
6212
|
+
try {
|
|
6213
|
+
await gh(["label", "create", l.name, "--color", l.color, "--description", l.description, "--force", "-R", repo]);
|
|
6214
|
+
applied.push(`label ${l.name}`);
|
|
6215
|
+
} catch {
|
|
6216
|
+
applied.push(`label ${l.name} (failed)`);
|
|
6217
|
+
}
|
|
6218
|
+
}
|
|
6219
|
+
}
|
|
6220
|
+
if (o.json) console.log(JSON.stringify({ repo, class: o.class, execute: o.execute, actions, applied }, null, 2));
|
|
6221
|
+
else {
|
|
6222
|
+
console.log(renderSeedPlan(actions));
|
|
6223
|
+
if (o.execute) console.log(`
|
|
6224
|
+
LIVE apply to ${repo}:
|
|
6225
|
+
${applied.join("\n ")}`);
|
|
6226
|
+
}
|
|
6227
|
+
});
|
|
6228
|
+
var access = program2.command("access").description("org access audit (read-only)");
|
|
6229
|
+
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 () => {
|
|
6230
|
+
const o = { json: rawFlag("--json"), repo: rawValue("--repo", ""), class: rawValue("--class", "deployable") };
|
|
6231
|
+
const deps = { gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }) };
|
|
6232
|
+
let targets;
|
|
6233
|
+
if (o.repo) {
|
|
6234
|
+
if (o.class !== "deployable" && o.class !== "content") return fail("access audit: --class must be deployable or content");
|
|
6235
|
+
targets = [{ repo: o.repo, class: o.class }];
|
|
6236
|
+
} else {
|
|
6237
|
+
if (!(0, import_node_fs4.existsSync)("projects.json")) return fail("access audit: projects.json not found; run from the MMI-Hub repo root or pass --repo <owner/repo>");
|
|
6238
|
+
const fanoutJson = (0, import_node_fs4.existsSync)(".github/fanout-targets.json") ? (0, import_node_fs4.readFileSync)(".github/fanout-targets.json", "utf8") : null;
|
|
6239
|
+
targets = loadAccessTargets((0, import_node_fs4.readFileSync)("projects.json", "utf8"), fanoutJson);
|
|
6240
|
+
}
|
|
6241
|
+
const matrix = (0, import_node_fs4.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs4.readFileSync)("access-matrix.json", "utf8")) : {};
|
|
6242
|
+
const report = await auditOrgAccess(targets, deps, matrix);
|
|
6243
|
+
console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
|
|
6244
|
+
if (!report.ok) process.exitCode = 1;
|
|
6245
|
+
});
|
|
5036
6246
|
var isWin = process.platform === "win32";
|
|
5037
6247
|
program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--json", "machine-readable output").action(async (opts) => {
|
|
5038
6248
|
const checks = [];
|
|
@@ -5054,14 +6264,16 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
|
|
|
5054
6264
|
}
|
|
5055
6265
|
if (!onPath) {
|
|
5056
6266
|
const root = process.env.CLAUDE_PLUGIN_ROOT;
|
|
5057
|
-
if (root && (0,
|
|
6267
|
+
if (root && (0, import_node_fs4.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
|
|
5058
6268
|
}
|
|
5059
6269
|
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
|
-
|
|
6270
|
+
let versionReport = buildVersionLagReport({
|
|
5061
6271
|
currentVersion: resolveVersion(),
|
|
5062
6272
|
repoVersion: readRepoVersion(),
|
|
5063
6273
|
releasedVersion: await fetchReleasedVersion()
|
|
5064
|
-
})
|
|
6274
|
+
});
|
|
6275
|
+
if (!opts.json) versionReport = await applyVersionAutoUpdate(versionReport, (m) => console.error(m));
|
|
6276
|
+
checks.push(versionReport);
|
|
5065
6277
|
const cfg = await loadConfig();
|
|
5066
6278
|
checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "repo config (.mmi/config.json)", fix: "ask a master-admin to run /bootstrap on this repo" });
|
|
5067
6279
|
const REWRITE_KEY = "url.https://github.com/.insteadOf";
|