@mutmutco/cli 0.9.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 +1677 -116
- 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
|
|
@@ -3052,6 +3052,39 @@ function needsUpdate(source, current) {
|
|
|
3052
3052
|
function isRulesSource(orgRulesSource) {
|
|
3053
3053
|
return orgRulesSource === "self";
|
|
3054
3054
|
}
|
|
3055
|
+
function rulesSourceAuthHeaders(sourceUrl, token) {
|
|
3056
|
+
if (!token) return void 0;
|
|
3057
|
+
try {
|
|
3058
|
+
const host = new URL(sourceUrl).hostname.toLowerCase();
|
|
3059
|
+
if (host === "raw.githubusercontent.com" || host === "api.github.com") {
|
|
3060
|
+
return { Authorization: `Bearer ${token}` };
|
|
3061
|
+
}
|
|
3062
|
+
} catch {
|
|
3063
|
+
return void 0;
|
|
3064
|
+
}
|
|
3065
|
+
return void 0;
|
|
3066
|
+
}
|
|
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
|
+
}
|
|
3055
3088
|
|
|
3056
3089
|
// src/saga-capture.ts
|
|
3057
3090
|
function parseHookInput(stdin) {
|
|
@@ -3066,7 +3099,7 @@ function parseHookInput(stdin) {
|
|
|
3066
3099
|
// src/index.ts
|
|
3067
3100
|
var import_node_child_process4 = require("node:child_process");
|
|
3068
3101
|
var import_node_util3 = require("node:util");
|
|
3069
|
-
var
|
|
3102
|
+
var import_node_path4 = require("node:path");
|
|
3070
3103
|
|
|
3071
3104
|
// src/saga-head-maintainer.ts
|
|
3072
3105
|
var import_node_child_process = require("node:child_process");
|
|
@@ -3099,13 +3132,18 @@ function markHeadRun(path, now = Date.now()) {
|
|
|
3099
3132
|
}
|
|
3100
3133
|
function headPrompt(state) {
|
|
3101
3134
|
return [
|
|
3102
|
-
"You maintain
|
|
3103
|
-
"
|
|
3104
|
-
"
|
|
3105
|
-
"
|
|
3106
|
-
"
|
|
3135
|
+
"You maintain ONE durable slot of a work-session: PINNED (things worth remembering). Given the CURRENT",
|
|
3136
|
+
"HEAD and the recent TRANSCRIPT + DECISIONS, return an updated PINNED only. Keep it tight and concrete;",
|
|
3137
|
+
"keep anything the user pinned; never invent; preserve Turkish characters (\xE7 \u011F \u0131 \u0130 \xF6 \u015F \xFC) exactly. Do",
|
|
3138
|
+
"NOT manage next or the checklist \u2014 the note path owns those. The ANCHOR is the read-only North-Star \u2014",
|
|
3139
|
+
"NEVER change it. Never restate an unverified artifact-claim (a named file, PR, flag, or board state)",
|
|
3107
3140
|
"as settled fact \u2014 keep it as the belief it was recorded as.",
|
|
3108
|
-
|
|
3141
|
+
"You MAY also propose supersessions: each DECISION is shown with a stable 0-based index. Propose a",
|
|
3142
|
+
"supersession ONLY for a NEWER decision that directly contradicts/replaces an OLDER one where neither",
|
|
3143
|
+
"already carries a supersededBy. HIGH PRECISION \u2014 propose ONLY when you are confident the older claim",
|
|
3144
|
+
"is now false or obsolete; the newer decision's timestamp MUST be later than the older's (newer-supersedes-",
|
|
3145
|
+
"older only, never the reverse). When unsure, propose nothing. NEVER touch the anchor, next, or checklist.",
|
|
3146
|
+
'Output ONLY a JSON object: {"pinned":[string],"supersede":[{"older":int,"newer":int,"reason":string}]}.',
|
|
3109
3147
|
"",
|
|
3110
3148
|
"CURRENT HEAD:",
|
|
3111
3149
|
JSON.stringify(state.head ?? {}, null, 2),
|
|
@@ -3114,7 +3152,7 @@ function headPrompt(state) {
|
|
|
3114
3152
|
JSON.stringify(state.actionLog ?? []),
|
|
3115
3153
|
"",
|
|
3116
3154
|
"DECISIONS:",
|
|
3117
|
-
(state.decisions ?? []).map((d) =>
|
|
3155
|
+
(state.decisions ?? []).map((d, i) => `[${i}] ${d}`).join("\n") || "(none)"
|
|
3118
3156
|
].join("\n");
|
|
3119
3157
|
}
|
|
3120
3158
|
function parseHeadUpdate(raw) {
|
|
@@ -3128,8 +3166,15 @@ function parseHeadUpdate(raw) {
|
|
|
3128
3166
|
}
|
|
3129
3167
|
if (!obj || typeof obj !== "object") return null;
|
|
3130
3168
|
const u = {};
|
|
3131
|
-
if (typeof obj.goal === "string") u.goal = obj.goal;
|
|
3132
3169
|
if (Array.isArray(obj.pinned)) u.pinned = obj.pinned.filter((x) => typeof x === "string");
|
|
3170
|
+
if (Array.isArray(obj.supersede)) {
|
|
3171
|
+
const supersede = obj.supersede.filter((e) => {
|
|
3172
|
+
if (!e || typeof e !== "object") return false;
|
|
3173
|
+
const { older, newer, reason } = e;
|
|
3174
|
+
return Number.isInteger(older) && older >= 0 && Number.isInteger(newer) && newer >= 0 && older !== newer && typeof reason === "string" && reason.length > 0;
|
|
3175
|
+
}).map((e) => ({ older: e.older, newer: e.newer, reason: e.reason }));
|
|
3176
|
+
if (supersede.length) u.supersede = supersede;
|
|
3177
|
+
}
|
|
3133
3178
|
return Object.keys(u).length ? u : null;
|
|
3134
3179
|
}
|
|
3135
3180
|
async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
|
|
@@ -3170,7 +3215,7 @@ async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
|
|
|
3170
3215
|
|
|
3171
3216
|
// src/gh-create.ts
|
|
3172
3217
|
var ISSUE_TYPES = ["bug", "feature", "task"];
|
|
3173
|
-
var PRIORITIES = ["high", "medium", "low"];
|
|
3218
|
+
var PRIORITIES = ["urgent", "high", "medium", "low"];
|
|
3174
3219
|
function parseCreatedUrl(stdout) {
|
|
3175
3220
|
const re = /https:\/\/github\.com\/[^\s]+\/(?:issues|pull)\/(\d+)/g;
|
|
3176
3221
|
let match;
|
|
@@ -3182,7 +3227,7 @@ function parseCreatedUrl(stdout) {
|
|
|
3182
3227
|
${stdout.trim() || "(empty)"}`);
|
|
3183
3228
|
return last;
|
|
3184
3229
|
}
|
|
3185
|
-
function buildIssueArgs({ type, title, body, priority, repo }) {
|
|
3230
|
+
function buildIssueArgs({ type, title, body, priority, repo, labels }) {
|
|
3186
3231
|
if (!ISSUE_TYPES.includes(type)) throw new Error(`unknown issue type "${type}" \u2014 expected one of: ${ISSUE_TYPES.join(", ")}`);
|
|
3187
3232
|
if (!PRIORITIES.includes(priority)) {
|
|
3188
3233
|
throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${PRIORITIES.join(", ")}`);
|
|
@@ -3191,8 +3236,36 @@ function buildIssueArgs({ type, title, body, priority, repo }) {
|
|
|
3191
3236
|
if (repo) args.push("--repo", repo);
|
|
3192
3237
|
args.push("--title", title, "--body", body, "--label", type);
|
|
3193
3238
|
args.push("--label", `priority:${priority}`);
|
|
3239
|
+
for (const label of labels ?? []) args.push("--label", label);
|
|
3194
3240
|
return args;
|
|
3195
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
|
+
}
|
|
3196
3269
|
function buildPrArgs({ title, body, base, head, repo }) {
|
|
3197
3270
|
const args = ["pr", "create"];
|
|
3198
3271
|
if (repo) args.push("--repo", repo);
|
|
@@ -3246,11 +3319,12 @@ function buildNoteCapture(summary, o, id, evidence) {
|
|
|
3246
3319
|
const queueOp = o.queueAdd ? { op: "add", text: o.queueAdd } : o.queueDone != null ? { op: "done", index: Number(o.queueDone) } : void 0;
|
|
3247
3320
|
const state = o.diagnostic ? "diagnostic" : o.verified ? "verified" : "asserted";
|
|
3248
3321
|
const source = o.diagnostic ? "probe" : "note";
|
|
3249
|
-
const
|
|
3250
|
-
if (evidence.sha)
|
|
3251
|
-
if (evidence.branch)
|
|
3252
|
-
if (evidence.pr)
|
|
3253
|
-
if (evidence.file)
|
|
3322
|
+
const ev = {};
|
|
3323
|
+
if (evidence.sha) ev.sha = evidence.sha;
|
|
3324
|
+
if (evidence.branch) ev.branch = evidence.branch;
|
|
3325
|
+
if (evidence.pr) ev.pr = evidence.pr;
|
|
3326
|
+
if (evidence.file) ev.file = evidence.file;
|
|
3327
|
+
const anchor = o.anchor ? { intent: o.anchor, setAt: (/* @__PURE__ */ new Date()).toISOString() } : void 0;
|
|
3254
3328
|
return {
|
|
3255
3329
|
event: "note",
|
|
3256
3330
|
id,
|
|
@@ -3260,14 +3334,17 @@ function buildNoteCapture(summary, o, id, evidence) {
|
|
|
3260
3334
|
queueOp,
|
|
3261
3335
|
state,
|
|
3262
3336
|
source,
|
|
3263
|
-
evidence: Object.keys(
|
|
3264
|
-
surface: process.env.MMI_AGENT_SURFACE || "claude"
|
|
3337
|
+
evidence: Object.keys(ev).length ? ev : void 0,
|
|
3338
|
+
surface: process.env.MMI_AGENT_SURFACE || "claude",
|
|
3339
|
+
supersedes: o.supersedes,
|
|
3340
|
+
anchor,
|
|
3341
|
+
anchorForce: o.anchorForce || void 0
|
|
3265
3342
|
};
|
|
3266
3343
|
}
|
|
3267
3344
|
|
|
3268
3345
|
// src/version-lag.ts
|
|
3269
3346
|
var VERSION_LABEL = "installed plugin/adapter cache freshness";
|
|
3270
|
-
var VERSION_FIX = "
|
|
3347
|
+
var VERSION_FIX = "update the MMI plugin via /plugin; standalone npm CLI: npm install -g @mutmutco/cli@latest";
|
|
3271
3348
|
function parseVersion(v) {
|
|
3272
3349
|
return v.replace(/^v/, "").split(/[.-]/).slice(0, 3).map((part) => {
|
|
3273
3350
|
const n = Number.parseInt(part, 10);
|
|
@@ -3315,6 +3392,10 @@ function buildVersionLagReport(input) {
|
|
|
3315
3392
|
releasedVersion: input.releasedVersion
|
|
3316
3393
|
};
|
|
3317
3394
|
}
|
|
3395
|
+
function versionAutoUpdateAction(report, hasPluginRoot) {
|
|
3396
|
+
if (report.ok || report.staleAgainst !== "released") return "none";
|
|
3397
|
+
return hasPluginRoot ? "plugin-pull" : "npm";
|
|
3398
|
+
}
|
|
3318
3399
|
|
|
3319
3400
|
// src/issue-related.ts
|
|
3320
3401
|
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
@@ -3373,7 +3454,50 @@ ${lines.join("\n")}`;
|
|
|
3373
3454
|
// src/board.ts
|
|
3374
3455
|
var import_node_child_process2 = require("node:child_process");
|
|
3375
3456
|
var import_node_util = require("node:util");
|
|
3376
|
-
|
|
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
|
+
);
|
|
3377
3501
|
var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
|
|
3378
3502
|
var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["Todo", "In Progress", "In Review"]);
|
|
3379
3503
|
var STATUS_ORDER = new Map(BOARD_STATUSES.map((s, i) => [s, i]));
|
|
@@ -3391,7 +3515,7 @@ var defaultGit = async (args) => {
|
|
|
3391
3515
|
}
|
|
3392
3516
|
};
|
|
3393
3517
|
var PROJECT_ITEMS_QUERY = `
|
|
3394
|
-
query($owner: String!, $number: Int!, $
|
|
3518
|
+
query($owner: String!, $number: Int!, $after: String) {
|
|
3395
3519
|
viewer { login }
|
|
3396
3520
|
organization(login: $owner) {
|
|
3397
3521
|
projectV2(number: $number) {
|
|
@@ -3401,8 +3525,14 @@ query($owner: String!, $number: Int!, $statusField: String!, $after: String) {
|
|
|
3401
3525
|
pageInfo { hasNextPage endCursor }
|
|
3402
3526
|
nodes {
|
|
3403
3527
|
id
|
|
3404
|
-
|
|
3405
|
-
|
|
3528
|
+
fieldValues(first: 8) {
|
|
3529
|
+
nodes {
|
|
3530
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
3531
|
+
name
|
|
3532
|
+
optionId
|
|
3533
|
+
field { ... on ProjectV2SingleSelectField { name } }
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3406
3536
|
}
|
|
3407
3537
|
content {
|
|
3408
3538
|
__typename
|
|
@@ -3448,7 +3578,9 @@ function resolveBoardConfig(cfg) {
|
|
|
3448
3578
|
projectNumber: cfg.projectNumber,
|
|
3449
3579
|
projectId: cfg.projectId,
|
|
3450
3580
|
statusFieldId: cfg.statusFieldId,
|
|
3451
|
-
statusOptions: cfg.statusOptions
|
|
3581
|
+
statusOptions: cfg.statusOptions,
|
|
3582
|
+
priorityFieldId: cfg.priorityFieldId,
|
|
3583
|
+
priorityOptions: cfg.priorityOptions
|
|
3452
3584
|
};
|
|
3453
3585
|
}
|
|
3454
3586
|
function repoFromGitRemote(remote) {
|
|
@@ -3521,6 +3653,22 @@ function findClaimableItem(report, selector) {
|
|
|
3521
3653
|
}
|
|
3522
3654
|
throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
|
|
3523
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
|
+
}
|
|
3524
3672
|
function renderBoardReport(report) {
|
|
3525
3673
|
const lines = [`Board \xB7 ${report.project.title} \xB7 @${report.viewer}`];
|
|
3526
3674
|
renderScope(lines, "PRIMARY", report.repo, report.primary);
|
|
@@ -3531,8 +3679,7 @@ function renderBoardReport(report) {
|
|
|
3531
3679
|
}
|
|
3532
3680
|
return lines.join("\n");
|
|
3533
3681
|
}
|
|
3534
|
-
async function
|
|
3535
|
-
const cfg = resolveBoardConfig(options.config);
|
|
3682
|
+
async function collectBoardItems(cfg, options, deps) {
|
|
3536
3683
|
const gh = deps.gh ?? defaultGh;
|
|
3537
3684
|
const git = deps.git ?? defaultGit;
|
|
3538
3685
|
const currentRepo = options.repo ?? repoFromGitRemote(await git(["remote", "get-url", "origin"])) ?? "";
|
|
@@ -3562,21 +3709,93 @@ async function readBoard(options, deps = {}) {
|
|
|
3562
3709
|
after = void 0;
|
|
3563
3710
|
}
|
|
3564
3711
|
} while (after);
|
|
3565
|
-
|
|
3566
|
-
|
|
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);
|
|
3567
3719
|
const report = {
|
|
3568
|
-
project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: projectId, title: projectTitle || String(cfg.projectNumber) },
|
|
3569
|
-
viewer,
|
|
3570
|
-
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,
|
|
3571
3723
|
...groups,
|
|
3572
|
-
warnings,
|
|
3573
|
-
partial
|
|
3724
|
+
warnings: collected.warnings,
|
|
3725
|
+
partial: collected.partial
|
|
3574
3726
|
};
|
|
3575
3727
|
if (options.includeBundleDetails) {
|
|
3576
3728
|
await attachBundleDetails(report, gh, options.allowPartial ?? false);
|
|
3577
3729
|
}
|
|
3578
3730
|
return report;
|
|
3579
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
|
+
}
|
|
3580
3799
|
async function claimBoardIssue(options, deps = {}) {
|
|
3581
3800
|
const cfg = resolveBoardConfig(options.config);
|
|
3582
3801
|
const gh = deps.gh ?? defaultGh;
|
|
@@ -3584,8 +3803,10 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
3584
3803
|
const selector = parseIssueSelector(options.selector, report.repo);
|
|
3585
3804
|
const item = findClaimableItem(report, selector);
|
|
3586
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(/^@/, "");
|
|
3587
3808
|
try {
|
|
3588
|
-
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]);
|
|
3589
3810
|
} catch (e) {
|
|
3590
3811
|
throw new Error(`claim failed before board status changed: ${ghError(e)}`);
|
|
3591
3812
|
}
|
|
@@ -3603,11 +3824,117 @@ async function claimBoardIssue(options, deps = {}) {
|
|
|
3603
3824
|
cfg.statusOptions["In Progress"]
|
|
3604
3825
|
]);
|
|
3605
3826
|
} catch (e) {
|
|
3606
|
-
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)})`;
|
|
3607
3828
|
if (!options.allowPartial) throw new Error(warning);
|
|
3608
3829
|
return { item, viewer: report.viewer, repo: report.repo, status: "Todo", partial: true, warning };
|
|
3609
3830
|
}
|
|
3610
|
-
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
|
+
});
|
|
3611
3938
|
}
|
|
3612
3939
|
async function fetchProjectPage(gh, cfg, after) {
|
|
3613
3940
|
const args = [
|
|
@@ -3618,9 +3945,7 @@ async function fetchProjectPage(gh, cfg, after) {
|
|
|
3618
3945
|
"-f",
|
|
3619
3946
|
`owner=${cfg.projectOwner}`,
|
|
3620
3947
|
"-F",
|
|
3621
|
-
`number=${cfg.projectNumber}
|
|
3622
|
-
"-f",
|
|
3623
|
-
"statusField=Status"
|
|
3948
|
+
`number=${cfg.projectNumber}`
|
|
3624
3949
|
];
|
|
3625
3950
|
if (after) args.push("-f", `after=${after}`);
|
|
3626
3951
|
const { stdout } = await gh(args);
|
|
@@ -3637,10 +3962,27 @@ function nodesToItems(nodes, warnings) {
|
|
|
3637
3962
|
}
|
|
3638
3963
|
return items;
|
|
3639
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
|
+
}
|
|
3640
3981
|
function nodeToItem(node) {
|
|
3641
3982
|
const content = node.content;
|
|
3642
3983
|
if (!node.id || !isSupportedContent(content)) return void 0;
|
|
3643
|
-
const
|
|
3984
|
+
const fields = parseSingleSelectFields(node.fieldValues?.nodes);
|
|
3985
|
+
const status = fields.status?.name;
|
|
3644
3986
|
const repository = content.repository?.nameWithOwner;
|
|
3645
3987
|
if (!status || !content.id || !content.number || !content.title || !content.url || !repository) return void 0;
|
|
3646
3988
|
const labels = (content.labels?.nodes ?? []).map((l) => l.name).filter((name) => Boolean(name));
|
|
@@ -3656,7 +3998,9 @@ function nodeToItem(node) {
|
|
|
3656
3998
|
title: content.title,
|
|
3657
3999
|
state: content.state ?? "",
|
|
3658
4000
|
status,
|
|
3659
|
-
statusOptionId:
|
|
4001
|
+
statusOptionId: fields.status?.optionId,
|
|
4002
|
+
priority: fields.priority?.name,
|
|
4003
|
+
priorityOptionId: fields.priority?.optionId,
|
|
3660
4004
|
assignees,
|
|
3661
4005
|
labels,
|
|
3662
4006
|
type: labels.find((label) => TYPE_LABELS.includes(label)) ?? labels[0]
|
|
@@ -3715,7 +4059,8 @@ function renderTaken(lines, items) {
|
|
|
3715
4059
|
for (const item of items) lines.push(` ${item.ref} \xB7 ${item.status} \xB7 @${item.assignees.join(", @")}`);
|
|
3716
4060
|
}
|
|
3717
4061
|
function renderTitledItem(item) {
|
|
3718
|
-
|
|
4062
|
+
const pri = item.priority ? ` \xB7 ${item.priority}` : "";
|
|
4063
|
+
return `${item.ref} - [${item.type ?? "item"}]${pri} ${item.title}`;
|
|
3719
4064
|
}
|
|
3720
4065
|
function hasItems(buckets) {
|
|
3721
4066
|
return buckets.userOwned.length > 0 || buckets.claimable.length > 0 || buckets.taken.length > 0;
|
|
@@ -3824,31 +4169,31 @@ function parseWorktreePorcelain(stdout) {
|
|
|
3824
4169
|
}
|
|
3825
4170
|
return out;
|
|
3826
4171
|
}
|
|
3827
|
-
function formatGcPlan(
|
|
4172
|
+
function formatGcPlan(plan2, apply) {
|
|
3828
4173
|
const lines = [`mmi-cli gc: ${apply ? "apply" : "dry-run"}`];
|
|
3829
|
-
if (!
|
|
3830
|
-
if (
|
|
4174
|
+
if (!plan2.branches.length && !plan2.trackingRefs.length) lines.push("nothing to clean");
|
|
4175
|
+
if (plan2.branches.length) {
|
|
3831
4176
|
lines.push("local branches:");
|
|
3832
|
-
for (const b of
|
|
4177
|
+
for (const b of plan2.branches) {
|
|
3833
4178
|
const prs = b.prNumbers.length ? ` #${b.prNumbers.join(",#")}` : "";
|
|
3834
4179
|
const wt = b.worktreePath ? ` (worktree: ${b.worktreePath})` : "";
|
|
3835
4180
|
lines.push(` - ${b.branch} (${b.prState}${prs})${wt}`);
|
|
3836
4181
|
}
|
|
3837
4182
|
}
|
|
3838
|
-
if (
|
|
4183
|
+
if (plan2.trackingRefs.length) {
|
|
3839
4184
|
lines.push("stale tracking refs:");
|
|
3840
|
-
for (const r of
|
|
4185
|
+
for (const r of plan2.trackingRefs) {
|
|
3841
4186
|
const prs = r.prNumbers.length ? ` #${r.prNumbers.join(",#")}` : "";
|
|
3842
4187
|
lines.push(` - ${r.ref} (${r.prState}${prs})`);
|
|
3843
4188
|
}
|
|
3844
4189
|
}
|
|
3845
|
-
if (
|
|
4190
|
+
if (plan2.skipped.length) {
|
|
3846
4191
|
lines.push("skipped:");
|
|
3847
|
-
for (const s of
|
|
4192
|
+
for (const s of plan2.skipped) {
|
|
3848
4193
|
lines.push(` - ${s.branch}: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`);
|
|
3849
4194
|
}
|
|
3850
4195
|
}
|
|
3851
|
-
if (!apply && (
|
|
4196
|
+
if (!apply && (plan2.branches.length || plan2.trackingRefs.length)) lines.push("rerun with --apply to delete only the listed items");
|
|
3852
4197
|
return lines.join("\n");
|
|
3853
4198
|
}
|
|
3854
4199
|
|
|
@@ -3914,6 +4259,7 @@ function buildGithubAuthCheck(input) {
|
|
|
3914
4259
|
var import_node_child_process3 = require("node:child_process");
|
|
3915
4260
|
var import_node_fs2 = require("node:fs");
|
|
3916
4261
|
var import_node_path2 = require("node:path");
|
|
4262
|
+
var import_node_net = require("node:net");
|
|
3917
4263
|
var import_node_util2 = require("node:util");
|
|
3918
4264
|
var execFileP2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
|
|
3919
4265
|
function stageStatePath(cwd = process.cwd()) {
|
|
@@ -3926,8 +4272,29 @@ function validateStageConfig(config = {}, action) {
|
|
|
3926
4272
|
if (config.healthUrl != null && config.healthUrl.trim() && !/^https?:\/\//.test(config.healthUrl.trim())) {
|
|
3927
4273
|
problems.push("stage.healthUrl must be an http(s) URL");
|
|
3928
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
|
+
}
|
|
3929
4280
|
return problems;
|
|
3930
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
|
+
}
|
|
3931
4298
|
async function shell(command, cwd, timeoutMs) {
|
|
3932
4299
|
await execFileP2(command, [], {
|
|
3933
4300
|
cwd,
|
|
@@ -4002,24 +4369,34 @@ async function startStage(config = {}, opts = {}) {
|
|
|
4002
4369
|
const statePath = opts.statePath ?? stageStatePath(cwd);
|
|
4003
4370
|
const dir = statePath.slice(0, Math.max(statePath.lastIndexOf("/"), statePath.lastIndexOf("\\")));
|
|
4004
4371
|
(0, import_node_fs2.mkdirSync)(dir, { recursive: true });
|
|
4005
|
-
|
|
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());
|
|
4006
4381
|
const child = (0, import_node_child_process3.spawn)(up, {
|
|
4007
4382
|
cwd,
|
|
4008
4383
|
shell: true,
|
|
4009
4384
|
detached: true,
|
|
4010
4385
|
windowsHide: true,
|
|
4011
|
-
stdio: "ignore"
|
|
4386
|
+
stdio: "ignore",
|
|
4387
|
+
env: stagePort != null ? { ...process.env, STAGE_PORT: String(stagePort) } : process.env
|
|
4012
4388
|
});
|
|
4013
4389
|
const state = {
|
|
4014
4390
|
pid: child.pid ?? 0,
|
|
4015
4391
|
command: up,
|
|
4016
4392
|
cwd,
|
|
4017
4393
|
startedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
|
|
4018
|
-
healthUrl: config.healthUrl?.trim() || void 0
|
|
4394
|
+
healthUrl: sub(config.healthUrl?.trim()) || void 0,
|
|
4395
|
+
port: stagePort
|
|
4019
4396
|
};
|
|
4020
4397
|
(0, import_node_fs2.writeFileSync)(statePath, JSON.stringify(state, null, 2), "utf8");
|
|
4021
4398
|
if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4);
|
|
4022
|
-
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}` : ""}` };
|
|
4023
4400
|
opts.onReady?.(result);
|
|
4024
4401
|
child.unref();
|
|
4025
4402
|
return result;
|
|
@@ -4035,10 +4412,43 @@ async function runStage(config = {}, opts = {}) {
|
|
|
4035
4412
|
return { ...started, action: "run", message: `built and ${started.message}` };
|
|
4036
4413
|
}
|
|
4037
4414
|
|
|
4038
|
-
// src/
|
|
4039
|
-
var
|
|
4040
|
-
var
|
|
4041
|
-
|
|
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) {
|
|
4042
4452
|
return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
|
|
4043
4453
|
}
|
|
4044
4454
|
function safeJson(text, fallback) {
|
|
@@ -4055,6 +4465,177 @@ async function ghJson(deps, args, fallback) {
|
|
|
4055
4465
|
return fallback;
|
|
4056
4466
|
}
|
|
4057
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
|
+
|
|
4599
|
+
// src/bootstrap-verify.ts
|
|
4600
|
+
var requiredDocs = ["README.md", "architecture.md", "AGENTS.md", "CLAUDE.md", ".claude/settings.json", ".mmi/config.json"];
|
|
4601
|
+
var requiredIssueTemplates = [
|
|
4602
|
+
".github/ISSUE_TEMPLATE/bug.yml",
|
|
4603
|
+
".github/ISSUE_TEMPLATE/feature.yml",
|
|
4604
|
+
".github/ISSUE_TEMPLATE/task.yml",
|
|
4605
|
+
".github/ISSUE_TEMPLATE/config.yml"
|
|
4606
|
+
];
|
|
4607
|
+
var requiredWorkflows = [".github/workflows/pr-to-board.yml"];
|
|
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"];
|
|
4611
|
+
var requiredStatusOptions = ["Todo", "In Progress", "In Review", "Done"];
|
|
4612
|
+
var requiredProjectWorkflows = [
|
|
4613
|
+
"Auto-add sub-issues to project",
|
|
4614
|
+
"Auto-close issue",
|
|
4615
|
+
"Item added to project",
|
|
4616
|
+
"Item closed",
|
|
4617
|
+
"Pull request linked to issue",
|
|
4618
|
+
"Pull request merged"
|
|
4619
|
+
];
|
|
4620
|
+
var requiredActionsVariables = ["MMI_APP_ID"];
|
|
4621
|
+
var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
|
|
4622
|
+
function expectedBranches(repoClass) {
|
|
4623
|
+
return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
|
|
4624
|
+
}
|
|
4625
|
+
function safeJson2(text, fallback) {
|
|
4626
|
+
try {
|
|
4627
|
+
return JSON.parse(text);
|
|
4628
|
+
} catch {
|
|
4629
|
+
return fallback;
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
async function ghJson2(deps, args, fallback) {
|
|
4633
|
+
try {
|
|
4634
|
+
return safeJson2((await deps.gh(args)).stdout, fallback);
|
|
4635
|
+
} catch {
|
|
4636
|
+
return fallback;
|
|
4637
|
+
}
|
|
4638
|
+
}
|
|
4058
4639
|
async function contentExists(deps, repo, branch, path) {
|
|
4059
4640
|
try {
|
|
4060
4641
|
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
@@ -4064,60 +4645,629 @@ async function contentExists(deps, repo, branch, path) {
|
|
|
4064
4645
|
return false;
|
|
4065
4646
|
}
|
|
4066
4647
|
}
|
|
4067
|
-
async function
|
|
4648
|
+
async function contentText(deps, repo, branch, path) {
|
|
4068
4649
|
try {
|
|
4069
|
-
|
|
4070
|
-
|
|
4650
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
4651
|
+
const response = safeJson2(
|
|
4652
|
+
(await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`])).stdout,
|
|
4653
|
+
{}
|
|
4654
|
+
);
|
|
4655
|
+
if (response.content == null) return null;
|
|
4656
|
+
if (response.encoding != null && response.encoding !== "base64") return null;
|
|
4657
|
+
return Buffer.from(response.content.replace(/\s/g, ""), "base64").toString("utf8");
|
|
4071
4658
|
} catch {
|
|
4072
|
-
return
|
|
4659
|
+
return null;
|
|
4073
4660
|
}
|
|
4074
4661
|
}
|
|
4662
|
+
async function getProtection(deps, repo, branch) {
|
|
4663
|
+
try {
|
|
4664
|
+
return safeJson2((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`])).stdout, {});
|
|
4665
|
+
} catch {
|
|
4666
|
+
return null;
|
|
4667
|
+
}
|
|
4668
|
+
}
|
|
4669
|
+
function hasPushAllowlist(p) {
|
|
4670
|
+
return Array.isArray(p?.restrictions?.users) && p.restrictions.users.length > 0;
|
|
4671
|
+
}
|
|
4672
|
+
function optionDetail(missing) {
|
|
4673
|
+
return missing.length === 0 ? void 0 : `missing: ${missing.join(", ")}`;
|
|
4674
|
+
}
|
|
4075
4675
|
function localRegistryCheck(deps, path, predicate) {
|
|
4076
4676
|
const text = deps.readLocalFile?.(path);
|
|
4077
4677
|
if (text == null) return null;
|
|
4078
|
-
return predicate(
|
|
4678
|
+
return predicate(safeJson2(text, null));
|
|
4079
4679
|
}
|
|
4080
4680
|
async function verifyBootstrap(repo, repoClass, deps) {
|
|
4081
4681
|
const branchesWanted = expectedBranches(repoClass);
|
|
4082
4682
|
const baseBranch = repoClass === "content" ? "main" : "development";
|
|
4083
4683
|
const checks = [];
|
|
4084
|
-
const repoInfo = await
|
|
4684
|
+
const repoInfo = await ghJson2(deps, ["api", `repos/${repo}`], {});
|
|
4085
4685
|
checks.push({ ok: Boolean(repoInfo.default_branch), label: "repo exists" });
|
|
4086
4686
|
checks.push({ ok: repoInfo.default_branch === baseBranch, label: `default branch is ${baseBranch}`, detail: repoInfo.default_branch || "missing" });
|
|
4087
4687
|
checks.push({ ok: repoInfo.has_wiki === true, label: "wiki enabled", detail: repoInfo.has_wiki === true ? void 0 : "has_wiki is false or unavailable" });
|
|
4088
|
-
const branchList = await
|
|
4688
|
+
const branchList = await ghJson2(deps, ["api", `repos/${repo}/branches`, "--paginate"], []);
|
|
4089
4689
|
const branchNames = new Set(branchList.map((b) => b.name));
|
|
4090
4690
|
for (const branch of branchesWanted) {
|
|
4091
4691
|
checks.push({ ok: branchNames.has(branch), label: `branch exists: ${branch}` });
|
|
4092
|
-
|
|
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
|
+
});
|
|
4093
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
|
+
});
|
|
4094
4707
|
for (const path of requiredDocs) {
|
|
4095
4708
|
checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `bootstrap artifact exists: ${path}` });
|
|
4096
4709
|
}
|
|
4710
|
+
for (const path of requiredIssueTemplates) {
|
|
4711
|
+
checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `issue template exists: ${path}` });
|
|
4712
|
+
}
|
|
4713
|
+
for (const path of requiredWorkflows) {
|
|
4714
|
+
checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `automation workflow exists: ${path}` });
|
|
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
|
+
}
|
|
4097
4720
|
checks.push({ ok: await contentExists(deps, repo, baseBranch, ".cursor/environment.json"), label: "Cursor environment committed" });
|
|
4098
|
-
const labels = await
|
|
4721
|
+
const labels = await ghJson2(deps, ["label", "list", "--repo", repo, "--limit", "200", "--json", "name"], []);
|
|
4099
4722
|
const labelNames = new Set(labels.map((l) => l.name));
|
|
4100
4723
|
for (const label of requiredLabels) {
|
|
4101
4724
|
checks.push({ ok: labelNames.has(label), label: `label exists: ${label}` });
|
|
4102
4725
|
}
|
|
4103
|
-
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`], {});
|
|
4104
4729
|
checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
|
|
4730
|
+
const variables = await ghJson2(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
|
|
4731
|
+
const variableNames = new Set(variables.map((v) => v.name));
|
|
4732
|
+
for (const variable of requiredActionsVariables) {
|
|
4733
|
+
checks.push({ ok: variableNames.has(variable), label: `Actions variable exists: ${variable}` });
|
|
4734
|
+
}
|
|
4735
|
+
const secrets2 = await ghJson2(deps, ["secret", "list", "--repo", repo, "--json", "name"], []);
|
|
4736
|
+
const secretNames = new Set(secrets2.map((s) => s.name));
|
|
4737
|
+
for (const secret of requiredActionsSecrets) {
|
|
4738
|
+
checks.push({ ok: secretNames.has(secret), label: `Actions secret exists: ${secret}` });
|
|
4739
|
+
}
|
|
4740
|
+
const config = safeJson2(await contentText(deps, repo, baseBranch, ".mmi/config.json") || "", null);
|
|
4741
|
+
checks.push({
|
|
4742
|
+
ok: Boolean(config?.projectOwner && config?.projectNumber),
|
|
4743
|
+
label: ".mmi project board config exists"
|
|
4744
|
+
});
|
|
4745
|
+
if (config?.projectOwner && config.projectNumber != null) {
|
|
4746
|
+
const project = await ghJson2(
|
|
4747
|
+
deps,
|
|
4748
|
+
["project", "field-list", String(config.projectNumber), "--owner", config.projectOwner, "--format", "json"],
|
|
4749
|
+
{}
|
|
4750
|
+
);
|
|
4751
|
+
const fields = project.fields || [];
|
|
4752
|
+
const statusField = fields.find((field) => field.name === "Status");
|
|
4753
|
+
const labelField = fields.find((field) => field.name === "Labels");
|
|
4754
|
+
checks.push({
|
|
4755
|
+
ok: Boolean(statusField),
|
|
4756
|
+
label: `Project Status field exists: ${config.projectOwner}#${config.projectNumber}`
|
|
4757
|
+
});
|
|
4758
|
+
checks.push({
|
|
4759
|
+
ok: Boolean(labelField),
|
|
4760
|
+
label: `Project Labels field exists: ${config.projectOwner}#${config.projectNumber}`
|
|
4761
|
+
});
|
|
4762
|
+
if (statusField != null) {
|
|
4763
|
+
const optionNames = new Set((statusField.options || []).map((option) => option.name));
|
|
4764
|
+
const missingOptions = requiredStatusOptions.filter((option) => !optionNames.has(option));
|
|
4765
|
+
checks.push({
|
|
4766
|
+
ok: missingOptions.length === 0,
|
|
4767
|
+
label: "Project Status lanes configured",
|
|
4768
|
+
detail: optionDetail(missingOptions)
|
|
4769
|
+
});
|
|
4770
|
+
checks.push({
|
|
4771
|
+
ok: config.statusFieldId === statusField.id,
|
|
4772
|
+
label: ".mmi statusFieldId matches project"
|
|
4773
|
+
});
|
|
4774
|
+
for (const optionName of requiredStatusOptions) {
|
|
4775
|
+
const projectOption = statusField.options?.find((option) => option.name === optionName);
|
|
4776
|
+
checks.push({
|
|
4777
|
+
ok: Boolean(projectOption?.id && config.statusOptions?.[optionName] === projectOption.id),
|
|
4778
|
+
label: `.mmi status option matches project: ${optionName}`
|
|
4779
|
+
});
|
|
4780
|
+
}
|
|
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
|
+
}
|
|
4807
|
+
const workflowQuery = "query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { workflows(first: 30) { nodes { name enabled } } } } }";
|
|
4808
|
+
const workflowResponse = await ghJson2(
|
|
4809
|
+
deps,
|
|
4810
|
+
["api", "graphql", "-f", `query=${workflowQuery}`, "-f", `login=${config.projectOwner}`, "-F", `number=${config.projectNumber}`],
|
|
4811
|
+
{}
|
|
4812
|
+
);
|
|
4813
|
+
const workflows = workflowResponse.data?.organization?.projectV2?.workflows?.nodes || [];
|
|
4814
|
+
for (const workflowName of requiredProjectWorkflows) {
|
|
4815
|
+
checks.push({
|
|
4816
|
+
ok: workflows.some((workflow) => workflow.name === workflowName && workflow.enabled === true),
|
|
4817
|
+
label: `Project workflow enabled: ${workflowName}`
|
|
4818
|
+
});
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4105
4821
|
const fanout = repo === "mutmutco/MMI-Hub" ? true : localRegistryCheck(deps, ".github/fanout-targets.json", (json) => Array.isArray(json?.repos) && json.repos.some((r) => r.repo === repo.split("/")[1] && r.branch === baseBranch));
|
|
4106
4822
|
if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
|
|
4107
4823
|
const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
|
|
4108
4824
|
if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
|
|
4109
|
-
|
|
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);
|
|
4110
4845
|
}
|
|
4111
4846
|
function renderBootstrapVerifyReport(report) {
|
|
4112
4847
|
const lines = [`mmi-cli bootstrap verify: ${report.ok ? "OK" : "CHECK"} ${report.repo} (${report.class}, ${report.baseBranch})`];
|
|
4113
4848
|
for (const check of report.checks) {
|
|
4114
|
-
|
|
4849
|
+
const status = check.ok ? "OK" : check.waived ? "WAIVE" : "FAIL";
|
|
4850
|
+
lines.push(`${status} ${check.label}${check.detail ? ` - ${check.detail}` : ""}`);
|
|
4115
4851
|
}
|
|
4116
4852
|
return lines.join("\n");
|
|
4117
4853
|
}
|
|
4118
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" };
|
|
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(", ")}`);
|
|
4906
|
+
return lines.join("\n");
|
|
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
|
+
}
|
|
4958
|
+
|
|
4959
|
+
// src/plan.ts
|
|
4960
|
+
var import_node_path3 = require("node:path");
|
|
4961
|
+
var PLANS_DIR = "plans";
|
|
4962
|
+
var META_FILE = (0, import_node_path3.join)(PLANS_DIR, ".plan-meta.json");
|
|
4963
|
+
var planPath = (slug) => (0, import_node_path3.join)(PLANS_DIR, `${slug}.md`);
|
|
4964
|
+
var metaKey = (project, slug) => `${project}/${slug}`;
|
|
4965
|
+
function parseMeta(raw) {
|
|
4966
|
+
if (!raw) return {};
|
|
4967
|
+
try {
|
|
4968
|
+
const o = JSON.parse(raw);
|
|
4969
|
+
return o && typeof o === "object" ? o : {};
|
|
4970
|
+
} catch {
|
|
4971
|
+
return {};
|
|
4972
|
+
}
|
|
4973
|
+
}
|
|
4974
|
+
function serializeMeta(meta) {
|
|
4975
|
+
return JSON.stringify(meta, null, 2) + "\n";
|
|
4976
|
+
}
|
|
4977
|
+
function hashContent(s) {
|
|
4978
|
+
let h = 2166136261;
|
|
4979
|
+
for (let i = 0; i < s.length; i++) {
|
|
4980
|
+
h ^= s.charCodeAt(i);
|
|
4981
|
+
h = Math.imul(h, 16777619);
|
|
4982
|
+
}
|
|
4983
|
+
return (h >>> 0).toString(16);
|
|
4984
|
+
}
|
|
4985
|
+
function staleHint(slug) {
|
|
4986
|
+
return `remote "${slug}" is newer \u2014 run \`mmi-cli plan pull ${slug}\` first (your local is based on an older version), or re-push with \`--force\` to overwrite`;
|
|
4987
|
+
}
|
|
4988
|
+
function formatPlanList(plans) {
|
|
4989
|
+
return plans.map((p) => `${p.slug} \xB7 ${p.updatedAt ?? "-"} \xB7 ${p.project}`).join("\n");
|
|
4990
|
+
}
|
|
4991
|
+
var TIMEOUT_MS = 8e3;
|
|
4992
|
+
async function planPush(deps, slug, opts = {}) {
|
|
4993
|
+
const raw = deps.readLocal(slug);
|
|
4994
|
+
if (raw == null) {
|
|
4995
|
+
deps.err(`no local ${planPath(slug)} to push`);
|
|
4996
|
+
return;
|
|
4997
|
+
}
|
|
4998
|
+
const content = normalizeEol(raw);
|
|
4999
|
+
const project = opts.project ?? await deps.project();
|
|
5000
|
+
const meta = parseMeta(deps.readMetaRaw());
|
|
5001
|
+
const entry = meta[metaKey(project, slug)];
|
|
5002
|
+
const body = { project, slug, content };
|
|
5003
|
+
if (opts.force) body.force = true;
|
|
5004
|
+
else if (entry?.etag) body.baseEtag = entry.etag;
|
|
5005
|
+
const res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
|
|
5006
|
+
method: "POST",
|
|
5007
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5008
|
+
body: JSON.stringify(body),
|
|
5009
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
5010
|
+
});
|
|
5011
|
+
if (res.ok) {
|
|
5012
|
+
const out = await res.json();
|
|
5013
|
+
meta[metaKey(project, slug)] = { etag: out.etag, hash: hashContent(content), syncedAt: deps.now() };
|
|
5014
|
+
deps.writeMetaRaw(serializeMeta(meta));
|
|
5015
|
+
deps.log(`pushed ${slug}`);
|
|
5016
|
+
} else if (res.status === 409) {
|
|
5017
|
+
deps.err(staleHint(slug));
|
|
5018
|
+
} else {
|
|
5019
|
+
deps.err(`plan push failed: HTTP ${res.status}`);
|
|
5020
|
+
}
|
|
5021
|
+
}
|
|
5022
|
+
async function planPull(deps, slug, opts = {}) {
|
|
5023
|
+
const project = opts.project ?? await deps.project();
|
|
5024
|
+
const meta = parseMeta(deps.readMetaRaw());
|
|
5025
|
+
const entry = meta[metaKey(project, slug)];
|
|
5026
|
+
const local = deps.readLocal(slug);
|
|
5027
|
+
if (local != null && entry && !opts.force && hashContent(normalizeEol(local)) !== entry.hash) {
|
|
5028
|
+
deps.err(`local ${planPath(slug)} has unpushed edits \u2014 push it, or pull with --force to overwrite`);
|
|
5029
|
+
return;
|
|
5030
|
+
}
|
|
5031
|
+
const qs = new URLSearchParams({ project, slug }).toString();
|
|
5032
|
+
const res = await deps.fetch(`${deps.apiUrl}/plan/get?${qs}`, {
|
|
5033
|
+
method: "GET",
|
|
5034
|
+
headers: await deps.headers(),
|
|
5035
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
5036
|
+
});
|
|
5037
|
+
if (res.status === 404) {
|
|
5038
|
+
deps.err(`no plan "${slug}" found on the server`);
|
|
5039
|
+
return;
|
|
5040
|
+
}
|
|
5041
|
+
if (!res.ok) {
|
|
5042
|
+
deps.err(`plan pull failed: HTTP ${res.status}`);
|
|
5043
|
+
return;
|
|
5044
|
+
}
|
|
5045
|
+
const doc = await res.json();
|
|
5046
|
+
const content = normalizeEol(doc.content ?? "");
|
|
5047
|
+
deps.writeLocal(slug, content);
|
|
5048
|
+
meta[metaKey(project, slug)] = { etag: doc.etag, hash: hashContent(content), syncedAt: deps.now() };
|
|
5049
|
+
deps.writeMetaRaw(serializeMeta(meta));
|
|
5050
|
+
deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
|
|
5051
|
+
}
|
|
5052
|
+
async function planList(deps, opts = {}) {
|
|
5053
|
+
const qs = opts.project ? `?${new URLSearchParams({ project: opts.project }).toString()}` : "";
|
|
5054
|
+
let res;
|
|
5055
|
+
try {
|
|
5056
|
+
res = await deps.fetch(`${deps.apiUrl}/plan/list${qs}`, {
|
|
5057
|
+
method: "GET",
|
|
5058
|
+
headers: await deps.headers(),
|
|
5059
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
5060
|
+
});
|
|
5061
|
+
} catch (e) {
|
|
5062
|
+
if (!opts.quiet) deps.err(`plan list: ${e.message}`);
|
|
5063
|
+
return;
|
|
5064
|
+
}
|
|
5065
|
+
if (!res.ok) {
|
|
5066
|
+
if (!opts.quiet) deps.err(`plan list failed: HTTP ${res.status}`);
|
|
5067
|
+
return;
|
|
5068
|
+
}
|
|
5069
|
+
const { plans } = await res.json();
|
|
5070
|
+
if (opts.json) {
|
|
5071
|
+
deps.log(JSON.stringify(plans));
|
|
5072
|
+
return;
|
|
5073
|
+
}
|
|
5074
|
+
if (!plans.length) {
|
|
5075
|
+
if (!opts.quiet) deps.log("no plans");
|
|
5076
|
+
return;
|
|
5077
|
+
}
|
|
5078
|
+
deps.log(formatPlanList(plans));
|
|
5079
|
+
}
|
|
5080
|
+
async function planDelete(deps, slug, opts = {}) {
|
|
5081
|
+
const project = opts.project ?? await deps.project();
|
|
5082
|
+
const res = await deps.fetch(`${deps.apiUrl}/plan/delete`, {
|
|
5083
|
+
method: "POST",
|
|
5084
|
+
headers: await deps.headers({ "content-type": "application/json" }),
|
|
5085
|
+
body: JSON.stringify({ project, slug }),
|
|
5086
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
5087
|
+
});
|
|
5088
|
+
if (!res.ok) {
|
|
5089
|
+
deps.err(`plan delete failed: HTTP ${res.status}`);
|
|
5090
|
+
return;
|
|
5091
|
+
}
|
|
5092
|
+
deps.removeLocal(slug);
|
|
5093
|
+
const meta = parseMeta(deps.readMetaRaw());
|
|
5094
|
+
delete meta[metaKey(project, slug)];
|
|
5095
|
+
deps.writeMetaRaw(serializeMeta(meta));
|
|
5096
|
+
deps.log(`deleted ${slug}`);
|
|
5097
|
+
}
|
|
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
|
+
|
|
4119
5264
|
// src/index.ts
|
|
4120
|
-
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
|
+
);
|
|
4121
5271
|
var GIT_TIMEOUT_MS = 1e4;
|
|
4122
5272
|
var GC_GH_TIMEOUT_MS = 2e4;
|
|
4123
5273
|
async function githubToken() {
|
|
@@ -4150,7 +5300,6 @@ async function loadConfig() {
|
|
|
4150
5300
|
}
|
|
4151
5301
|
}
|
|
4152
5302
|
var DEFAULT_RULES_SOURCE = "https://raw.githubusercontent.com/mutmutco/MMI-Hub/development";
|
|
4153
|
-
var DEFAULT_KB_SOURCE = "https://raw.githubusercontent.com/mutmutco/MM-KB/main";
|
|
4154
5303
|
var SESSION_FILE = ".mmi/.session";
|
|
4155
5304
|
var gitOut = async (args) => {
|
|
4156
5305
|
try {
|
|
@@ -4164,7 +5313,7 @@ function sessionDeps() {
|
|
|
4164
5313
|
env: process.env,
|
|
4165
5314
|
readPersisted: () => {
|
|
4166
5315
|
try {
|
|
4167
|
-
return (0,
|
|
5316
|
+
return (0, import_node_fs4.readFileSync)(SESSION_FILE, "utf8");
|
|
4168
5317
|
} catch {
|
|
4169
5318
|
return null;
|
|
4170
5319
|
}
|
|
@@ -4177,8 +5326,8 @@ function sessionDeps() {
|
|
|
4177
5326
|
var resolveSessionId = () => resolveSession(sessionDeps());
|
|
4178
5327
|
function persistSession(id) {
|
|
4179
5328
|
try {
|
|
4180
|
-
(0,
|
|
4181
|
-
(0,
|
|
5329
|
+
(0, import_node_fs4.mkdirSync)(".mmi", { recursive: true });
|
|
5330
|
+
(0, import_node_fs4.writeFileSync)(SESSION_FILE, id, "utf8");
|
|
4182
5331
|
} catch {
|
|
4183
5332
|
}
|
|
4184
5333
|
}
|
|
@@ -4198,7 +5347,11 @@ async function postCapture(capture, quiet = false) {
|
|
|
4198
5347
|
method: "POST",
|
|
4199
5348
|
headers: await sagaHeaders({ "content-type": "application/json" }),
|
|
4200
5349
|
body: JSON.stringify({ ...capture, ...await sagaKey(cfg) }),
|
|
4201
|
-
|
|
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)
|
|
4202
5355
|
});
|
|
4203
5356
|
if (!quiet) console.log(res.ok ? "noted" : `saga: HTTP ${res.status}`);
|
|
4204
5357
|
} catch (e) {
|
|
@@ -4250,26 +5403,47 @@ async function gcPlan(remote, limit) {
|
|
|
4250
5403
|
remote
|
|
4251
5404
|
});
|
|
4252
5405
|
}
|
|
4253
|
-
async function applyGcPlan(
|
|
4254
|
-
for (const branch of
|
|
5406
|
+
async function applyGcPlan(plan2, remote) {
|
|
5407
|
+
for (const branch of plan2.branches) {
|
|
4255
5408
|
if (branch.worktreePath) await execFileP3("git", ["worktree", "remove", branch.worktreePath], { timeout: GIT_TIMEOUT_MS });
|
|
4256
5409
|
await execFileP3("git", ["branch", "-D", branch.branch], { timeout: GIT_TIMEOUT_MS });
|
|
4257
5410
|
}
|
|
4258
|
-
for (const ref of
|
|
5411
|
+
for (const ref of plan2.trackingRefs) {
|
|
4259
5412
|
await execFileP3("git", ["update-ref", "-d", `refs/remotes/${remote}/${ref.branch}`], { timeout: GIT_TIMEOUT_MS });
|
|
4260
5413
|
}
|
|
4261
|
-
if (
|
|
5414
|
+
if (plan2.branches.some((b) => b.worktreePath)) {
|
|
4262
5415
|
await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
|
|
4263
5416
|
}
|
|
4264
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
|
+
}
|
|
4265
5439
|
function resolveVersion() {
|
|
4266
5440
|
try {
|
|
4267
|
-
const manifest = (0,
|
|
4268
|
-
return JSON.parse((0,
|
|
5441
|
+
const manifest = (0, import_node_path4.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
|
|
5442
|
+
return JSON.parse((0, import_node_fs4.readFileSync)(manifest, "utf8")).version || "0.0.0";
|
|
4269
5443
|
} catch {
|
|
4270
5444
|
try {
|
|
4271
|
-
const pkg = (0,
|
|
4272
|
-
return JSON.parse((0,
|
|
5445
|
+
const pkg = (0, import_node_path4.join)(__dirname, "..", "package.json");
|
|
5446
|
+
return JSON.parse((0, import_node_fs4.readFileSync)(pkg, "utf8")).version || "0.0.0";
|
|
4273
5447
|
} catch {
|
|
4274
5448
|
return "0.0.0";
|
|
4275
5449
|
}
|
|
@@ -4277,7 +5451,7 @@ function resolveVersion() {
|
|
|
4277
5451
|
}
|
|
4278
5452
|
function readRepoVersion() {
|
|
4279
5453
|
try {
|
|
4280
|
-
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;
|
|
4281
5455
|
} catch {
|
|
4282
5456
|
return void 0;
|
|
4283
5457
|
}
|
|
@@ -4293,6 +5467,31 @@ async function fetchReleasedVersion() {
|
|
|
4293
5467
|
return void 0;
|
|
4294
5468
|
}
|
|
4295
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
|
+
}
|
|
4296
5495
|
var program2 = new Command();
|
|
4297
5496
|
program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveVersion());
|
|
4298
5497
|
var rules = program2.command("rules").description("org rules delivery");
|
|
@@ -4303,21 +5502,23 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
|
|
|
4303
5502
|
return;
|
|
4304
5503
|
}
|
|
4305
5504
|
const base = (cfg.orgRulesSource ?? DEFAULT_RULES_SOURCE).replace(/\/$/, "");
|
|
5505
|
+
const token = await githubToken();
|
|
4306
5506
|
let changed = 0;
|
|
4307
5507
|
for (const file of ["AGENTS.md", "CLAUDE.md", ".claude/settings.json"]) {
|
|
4308
5508
|
let source;
|
|
4309
5509
|
try {
|
|
4310
|
-
const
|
|
5510
|
+
const url = `${base}/${file}`;
|
|
5511
|
+
const res = await fetch(url, { headers: rulesSourceAuthHeaders(url, token) });
|
|
4311
5512
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
4312
5513
|
source = await res.text();
|
|
4313
5514
|
} catch (e) {
|
|
4314
5515
|
if (!opts.quiet) console.error(`mmi-cli rules: could not fetch ${file} (${e.message}); left it untouched`);
|
|
4315
5516
|
continue;
|
|
4316
5517
|
}
|
|
4317
|
-
const current = (0,
|
|
5518
|
+
const current = (0, import_node_fs4.existsSync)(file) ? await (0, import_promises.readFile)(file, "utf8") : null;
|
|
4318
5519
|
if (needsUpdate(source, current)) {
|
|
4319
5520
|
const slash = file.lastIndexOf("/");
|
|
4320
|
-
if (slash > 0) (0,
|
|
5521
|
+
if (slash > 0) (0, import_node_fs4.mkdirSync)(file.slice(0, slash), { recursive: true });
|
|
4321
5522
|
await (0, import_promises.writeFile)(file, normalizeEol(source), "utf8");
|
|
4322
5523
|
changed++;
|
|
4323
5524
|
if (!opts.quiet) console.log(`mmi-cli rules: updated ${file}`);
|
|
@@ -4325,13 +5526,36 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
|
|
|
4325
5526
|
}
|
|
4326
5527
|
if (!opts.quiet && changed === 0) console.log("mmi-cli rules: up to date");
|
|
4327
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
|
+
});
|
|
4328
5552
|
var saga = program2.command("saga").description("per-session continuity");
|
|
4329
5553
|
async function runNote(summary, o) {
|
|
4330
5554
|
const [sha, key] = await Promise.all([gitOut(["rev-parse", "--short", "HEAD"]), sagaKey(await loadConfig())]);
|
|
4331
5555
|
const capture = buildNoteCapture(summary, o, (0, import_node_crypto.randomUUID)(), { sha: sha || void 0, branch: key.branch });
|
|
4332
5556
|
await postCapture(capture);
|
|
4333
5557
|
}
|
|
4334
|
-
saga.command("note <summary>").description("record a one-line structured note into your saga (the per-turn capture)").option("--next <text>", 'set "where I left off" (NEXT)').option("--decision <text>", "append a verbatim decision").option("--queue-add <text>", "add a worklist item").option("--queue-done <n>", "mark worklist item N done").option("--verified", "mark this claim as checked against source (state: verified, else asserted)").option("--diagnostic", "isolate a probe write (state: diagnostic, source: probe) \u2014 never resume/LAST 5").action((summary, o) => runNote(summary, o));
|
|
5558
|
+
saga.command("note <summary>").description("record a one-line structured note into your saga (the per-turn capture)").option("--next <text>", 'set "where I left off" (NEXT)').option("--decision <text>", "append a verbatim decision").option("--queue-add <text>", "add a worklist item").option("--queue-done <n>", "mark worklist item N done").option("--verified", "mark this claim as checked against source (state: verified, else asserted)").option("--diagnostic", "isolate a probe write (state: diagnostic, source: probe) \u2014 never resume/LAST 5").option("--supersedes <key>", "retire prior decisions matching an evidence key (pr:N | file:path)").option("--anchor <intent>", "set the sprint North-Star (write-protected; needs --anchor-force to change)").option("--anchor-force", "overwrite an existing anchor").action((summary, o) => runNote(summary, o));
|
|
4335
5559
|
saga.command("probe <summary>").description("record a diagnostic probe note (alias for `saga note --diagnostic`)").option("--next <text>", 'set "where I left off" (NEXT)').option("--decision <text>", "append a verbatim decision").option("--queue-add <text>", "add a worklist item").option("--queue-done <n>", "mark worklist item N done").action((summary, o) => runNote(summary, { ...o, diagnostic: true }));
|
|
4336
5560
|
saga.command("show").option("--quiet", "no-op silently when unconfigured/unreachable (SessionStart hook)").option("--latest-anywhere", "resume the newest saga across all repos (default: current repo)").description("print your resume block \u2014 current repo HEAD + project memory (where you left off)").action(async (opts) => {
|
|
4337
5561
|
const cfg = await loadConfig();
|
|
@@ -4383,7 +5607,8 @@ saga.command("head-update").option("--run", "detached worker: fetch state, run t
|
|
|
4383
5607
|
if (!res.ok) return;
|
|
4384
5608
|
const state = await res.json();
|
|
4385
5609
|
if (!state.actionLog?.length) return;
|
|
4386
|
-
const
|
|
5610
|
+
const decisionTexts = (state.decisions ?? []).map((d) => typeof d === "string" ? d : d.text);
|
|
5611
|
+
const update = parseHeadUpdate(await runHeadEngine(headPrompt({ ...state, decisions: decisionTexts })));
|
|
4387
5612
|
if (!update) return;
|
|
4388
5613
|
await fetch(`${cfg.sagaApiUrl}/saga/head`, {
|
|
4389
5614
|
method: "POST",
|
|
@@ -4433,19 +5658,36 @@ program2.command("gc").description("dry-run cleanup for merged/closed PR branche
|
|
|
4433
5658
|
const limit = Number.parseInt(o.limit, 10);
|
|
4434
5659
|
if (!Number.isFinite(limit) || limit < 1) return fail("gc: --limit must be a positive integer");
|
|
4435
5660
|
try {
|
|
4436
|
-
const
|
|
4437
|
-
if (o.apply) await applyGcPlan(
|
|
4438
|
-
if (o.json) return console.log(JSON.stringify({ dryRun: !o.apply, remote: o.remote, plan }, null, 2));
|
|
4439
|
-
console.log(formatGcPlan(
|
|
5661
|
+
const plan2 = await gcPlan(o.remote, limit);
|
|
5662
|
+
if (o.apply) await applyGcPlan(plan2, o.remote);
|
|
5663
|
+
if (o.json) return console.log(JSON.stringify({ dryRun: !o.apply, remote: o.remote, plan: plan2 }, null, 2));
|
|
5664
|
+
console.log(formatGcPlan(plan2, Boolean(o.apply)));
|
|
4440
5665
|
} catch (e) {
|
|
4441
5666
|
fail(`gc: ${e.message}`);
|
|
4442
5667
|
}
|
|
4443
5668
|
});
|
|
4444
|
-
program2.command("kb").description("org knowledgebase (read-only)")
|
|
4445
|
-
|
|
4446
|
-
const
|
|
4447
|
-
|
|
4448
|
-
|
|
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
|
+
}
|
|
4449
5691
|
});
|
|
4450
5692
|
async function ghCreate(args) {
|
|
4451
5693
|
try {
|
|
@@ -4456,7 +5698,7 @@ async function ghCreate(args) {
|
|
|
4456
5698
|
fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}`);
|
|
4457
5699
|
}
|
|
4458
5700
|
}
|
|
4459
|
-
async function
|
|
5701
|
+
async function ghJson3(args, timeout = 1e4) {
|
|
4460
5702
|
const { stdout } = await execFileP3("gh", args, { timeout });
|
|
4461
5703
|
return JSON.parse(stdout);
|
|
4462
5704
|
}
|
|
@@ -4469,6 +5711,47 @@ async function resolveRepo(repo) {
|
|
|
4469
5711
|
return void 0;
|
|
4470
5712
|
}
|
|
4471
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
|
+
}
|
|
4472
5755
|
function scheduleRelatedDiscovery(o) {
|
|
4473
5756
|
try {
|
|
4474
5757
|
const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
|
|
@@ -4482,17 +5765,136 @@ function scheduleRelatedDiscovery(o) {
|
|
|
4482
5765
|
} catch {
|
|
4483
5766
|
}
|
|
4484
5767
|
}
|
|
5768
|
+
function makePlanDeps(cfg) {
|
|
5769
|
+
const ensureDir = () => (0, import_node_fs4.mkdirSync)(PLANS_DIR, { recursive: true });
|
|
5770
|
+
return {
|
|
5771
|
+
apiUrl: cfg.sagaApiUrl,
|
|
5772
|
+
fetch: (url, init) => fetch(url, init),
|
|
5773
|
+
headers: (extra) => sagaHeaders(extra),
|
|
5774
|
+
project: async () => (await sagaKey(cfg)).project,
|
|
5775
|
+
readLocal: (slug) => {
|
|
5776
|
+
try {
|
|
5777
|
+
return (0, import_node_fs4.readFileSync)(planPath(slug), "utf8");
|
|
5778
|
+
} catch {
|
|
5779
|
+
return null;
|
|
5780
|
+
}
|
|
5781
|
+
},
|
|
5782
|
+
writeLocal: (slug, content) => {
|
|
5783
|
+
ensureDir();
|
|
5784
|
+
(0, import_node_fs4.writeFileSync)(planPath(slug), content, "utf8");
|
|
5785
|
+
},
|
|
5786
|
+
removeLocal: (slug) => {
|
|
5787
|
+
try {
|
|
5788
|
+
(0, import_node_fs4.rmSync)(planPath(slug));
|
|
5789
|
+
} catch {
|
|
5790
|
+
}
|
|
5791
|
+
},
|
|
5792
|
+
readMetaRaw: () => {
|
|
5793
|
+
try {
|
|
5794
|
+
return (0, import_node_fs4.readFileSync)(META_FILE, "utf8");
|
|
5795
|
+
} catch {
|
|
5796
|
+
return null;
|
|
5797
|
+
}
|
|
5798
|
+
},
|
|
5799
|
+
writeMetaRaw: (raw) => {
|
|
5800
|
+
ensureDir();
|
|
5801
|
+
(0, import_node_fs4.writeFileSync)(META_FILE, raw, "utf8");
|
|
5802
|
+
},
|
|
5803
|
+
log: (m) => console.log(m),
|
|
5804
|
+
err: (m) => console.error(m),
|
|
5805
|
+
now: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
5806
|
+
};
|
|
5807
|
+
}
|
|
5808
|
+
function openInEditor(path) {
|
|
5809
|
+
const editor = process.env.EDITOR || process.env.VISUAL;
|
|
5810
|
+
if (!editor) {
|
|
5811
|
+
console.log(`plan at ${path} (set $EDITOR to open it automatically)`);
|
|
5812
|
+
return;
|
|
5813
|
+
}
|
|
5814
|
+
try {
|
|
5815
|
+
(0, import_node_child_process4.spawn)(editor, [path], { stdio: "inherit" });
|
|
5816
|
+
} catch {
|
|
5817
|
+
console.log(`open ${path} manually`);
|
|
5818
|
+
}
|
|
5819
|
+
}
|
|
5820
|
+
async function withPlan(quiet, run) {
|
|
5821
|
+
const cfg = await loadConfig();
|
|
5822
|
+
if (!cfg.sagaApiUrl) {
|
|
5823
|
+
if (!quiet) fail("plan: sagaApiUrl not configured in .mmi/config.json");
|
|
5824
|
+
return;
|
|
5825
|
+
}
|
|
5826
|
+
await run(makePlanDeps(cfg));
|
|
5827
|
+
}
|
|
5828
|
+
var plan = program2.command("plan").description("your cross-device plans/SSOTs (S3-backed, git-clean)");
|
|
5829
|
+
plan.command("push <slug>").description("push a local plan (plans/<slug>.md) to the server").option("--project <name>", "override the project key").option("--force", "overwrite the remote even if it changed since your last sync").action((slug, o) => withPlan(false, (d) => planPush(d, slug, o)));
|
|
5830
|
+
plan.command("pull <slug>").description("pull a plan from the server into plans/<slug>.md").option("--project <name>", "override the project key").option("--force", "overwrite local even if it has unpushed edits").action((slug, o) => withPlan(false, (d) => planPull(d, slug, o)));
|
|
5831
|
+
plan.command("list").description("list your plans (cross-device)").option("--project <name>", "filter by project").option("--json", "machine-readable output").option("--quiet", "silent when unconfigured/empty/unreachable (SessionStart hook)").action((o) => withPlan(o.quiet ?? false, (d) => planList(d, o)));
|
|
5832
|
+
plan.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
|
|
5833
|
+
(slug, o) => withPlan(false, async (d) => {
|
|
5834
|
+
await planPull(d, slug, { project: o.project });
|
|
5835
|
+
openInEditor(planPath(slug));
|
|
5836
|
+
})
|
|
5837
|
+
);
|
|
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, {})));
|
|
4485
5878
|
var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
|
|
4486
|
-
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) => {
|
|
4487
5880
|
let args;
|
|
4488
5881
|
try {
|
|
4489
|
-
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 });
|
|
4490
5883
|
} catch (e) {
|
|
4491
5884
|
return fail(`issue create: ${e.message}`);
|
|
4492
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
|
+
}
|
|
4493
5894
|
const created = await ghCreate(args);
|
|
4494
|
-
|
|
4495
|
-
|
|
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 }));
|
|
4496
5898
|
});
|
|
4497
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) => {
|
|
4498
5900
|
const number = Number(o.number);
|
|
@@ -4500,7 +5902,7 @@ issue.command("discover-related").description("find related issues for an existi
|
|
|
4500
5902
|
const repo = await resolveRepo(o.repo);
|
|
4501
5903
|
if (!repo) return fail("issue discover-related: could not resolve repo");
|
|
4502
5904
|
try {
|
|
4503
|
-
const issues = await
|
|
5905
|
+
const issues = await ghJson3([
|
|
4504
5906
|
"issue",
|
|
4505
5907
|
"list",
|
|
4506
5908
|
"--repo",
|
|
@@ -4515,7 +5917,7 @@ issue.command("discover-related").description("find related issues for an existi
|
|
|
4515
5917
|
const candidates = findRelatedIssues({ number, title: o.title, body: o.body }, issues);
|
|
4516
5918
|
if (o.json) return console.log(JSON.stringify({ number, repo, candidates }, null, 2));
|
|
4517
5919
|
if (!candidates.length) return;
|
|
4518
|
-
const viewed = await
|
|
5920
|
+
const viewed = await ghJson3([
|
|
4519
5921
|
"issue",
|
|
4520
5922
|
"view",
|
|
4521
5923
|
String(number),
|
|
@@ -4534,6 +5936,16 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
|
|
|
4534
5936
|
const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
|
|
4535
5937
|
console.log(JSON.stringify(created));
|
|
4536
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
|
+
});
|
|
4537
5949
|
async function runBoardRead(o) {
|
|
4538
5950
|
try {
|
|
4539
5951
|
const report = await readBoard({
|
|
@@ -4547,17 +5959,69 @@ async function runBoardRead(o) {
|
|
|
4547
5959
|
fail(`board read failed: ${e.message}`);
|
|
4548
5960
|
}
|
|
4549
5961
|
}
|
|
4550
|
-
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");
|
|
4551
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));
|
|
4552
|
-
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) => {
|
|
4553
5965
|
try {
|
|
4554
|
-
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
|
+
});
|
|
4555
5973
|
if (o.json) return console.log(JSON.stringify(result));
|
|
4556
5974
|
console.log(result.partial ? `Partially claimed ${result.item.ref}: ${result.warning}` : `Claimed ${result.item.ref} - In Progress`);
|
|
4557
5975
|
} catch (e) {
|
|
4558
5976
|
fail(`board claim failed: ${e.message}`);
|
|
4559
5977
|
}
|
|
4560
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
|
+
});
|
|
4561
6025
|
function renderSteps(title, steps) {
|
|
4562
6026
|
return [
|
|
4563
6027
|
title,
|
|
@@ -4572,12 +6036,17 @@ function rawValue(flag, fallback) {
|
|
|
4572
6036
|
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
|
4573
6037
|
}
|
|
4574
6038
|
function printLine(value) {
|
|
4575
|
-
(0,
|
|
6039
|
+
(0, import_node_fs4.writeSync)(1, `${value}
|
|
4576
6040
|
`);
|
|
4577
6041
|
}
|
|
4578
6042
|
function stageKeepAlive() {
|
|
4579
6043
|
return setTimeout(() => void 0, 5 * 60 * 1e3);
|
|
4580
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
|
+
});
|
|
4581
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) => {
|
|
4582
6051
|
const cfg = (await loadConfig()).stage;
|
|
4583
6052
|
if (o.apply) {
|
|
@@ -4679,11 +6148,101 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
|
|
|
4679
6148
|
if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
|
|
4680
6149
|
const report = await verifyBootstrap(repo, o.class, {
|
|
4681
6150
|
gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
|
|
4682
|
-
readLocalFile: (path) => (0,
|
|
6151
|
+
readLocalFile: (path) => (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : null
|
|
4683
6152
|
});
|
|
4684
6153
|
console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
|
|
4685
6154
|
if (!report.ok) process.exitCode = 1;
|
|
4686
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
|
+
});
|
|
4687
6246
|
var isWin = process.platform === "win32";
|
|
4688
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) => {
|
|
4689
6248
|
const checks = [];
|
|
@@ -4705,14 +6264,16 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
|
|
|
4705
6264
|
}
|
|
4706
6265
|
if (!onPath) {
|
|
4707
6266
|
const root = process.env.CLAUDE_PLUGIN_ROOT;
|
|
4708
|
-
if (root && (0,
|
|
6267
|
+
if (root && (0, import_node_fs4.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
|
|
4709
6268
|
}
|
|
4710
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" });
|
|
4711
|
-
|
|
6270
|
+
let versionReport = buildVersionLagReport({
|
|
4712
6271
|
currentVersion: resolveVersion(),
|
|
4713
6272
|
repoVersion: readRepoVersion(),
|
|
4714
6273
|
releasedVersion: await fetchReleasedVersion()
|
|
4715
|
-
})
|
|
6274
|
+
});
|
|
6275
|
+
if (!opts.json) versionReport = await applyVersionAutoUpdate(versionReport, (m) => console.error(m));
|
|
6276
|
+
checks.push(versionReport);
|
|
4716
6277
|
const cfg = await loadConfig();
|
|
4717
6278
|
checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "repo config (.mmi/config.json)", fix: "ask a master-admin to run /bootstrap on this repo" });
|
|
4718
6279
|
const REWRITE_KEY = "url.https://github.com/.insteadOf";
|