@mutmutco/cli 2.11.0 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/index.cjs +1168 -243
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -3392,9 +3392,9 @@ function useColor() {
|
|
|
3392
3392
|
var program = new Command();
|
|
3393
3393
|
|
|
3394
3394
|
// src/index.ts
|
|
3395
|
-
var
|
|
3396
|
-
var
|
|
3397
|
-
var
|
|
3395
|
+
var import_promises2 = require("node:fs/promises");
|
|
3396
|
+
var import_node_fs6 = require("node:fs");
|
|
3397
|
+
var import_node_crypto3 = require("node:crypto");
|
|
3398
3398
|
|
|
3399
3399
|
// src/rules-sync.ts
|
|
3400
3400
|
function normalizeEol(s) {
|
|
@@ -3468,6 +3468,25 @@ async function runSessionStart(parallel, sequential, io) {
|
|
|
3468
3468
|
for (const lines of buffered) flush(lines, io);
|
|
3469
3469
|
for (const step of sequential) flush(await runBufferedStep(step), io);
|
|
3470
3470
|
}
|
|
3471
|
+
function buildSessionStartPlan(verbs) {
|
|
3472
|
+
return {
|
|
3473
|
+
parallel: [
|
|
3474
|
+
{ name: "rules sync", run: verbs.rulesSync },
|
|
3475
|
+
{ name: "saga show", run: verbs.sagaShow },
|
|
3476
|
+
{ name: "saga health", run: verbs.sagaHealth }
|
|
3477
|
+
],
|
|
3478
|
+
sequential: [{ name: "doctor", run: verbs.doctor }]
|
|
3479
|
+
};
|
|
3480
|
+
}
|
|
3481
|
+
function spawnDetachedSelf(args, deps) {
|
|
3482
|
+
try {
|
|
3483
|
+
deps.spawn(deps.execPath, [deps.scriptPath, ...args], { detached: true, stdio: "ignore", windowsHide: true }).unref();
|
|
3484
|
+
} catch {
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
function northstarPointer() {
|
|
3488
|
+
return "North Stars: run `mmi-cli northstar relevant` to load plans relevant to your task (`northstar list` for all).";
|
|
3489
|
+
}
|
|
3471
3490
|
|
|
3472
3491
|
// src/saga-capture.ts
|
|
3473
3492
|
function parseHookInput(stdin) {
|
|
@@ -3482,8 +3501,8 @@ function parseHookInput(stdin) {
|
|
|
3482
3501
|
// src/index.ts
|
|
3483
3502
|
var import_node_child_process6 = require("node:child_process");
|
|
3484
3503
|
var import_node_util6 = require("node:util");
|
|
3485
|
-
var
|
|
3486
|
-
var
|
|
3504
|
+
var import_node_path7 = require("node:path");
|
|
3505
|
+
var import_node_os3 = require("node:os");
|
|
3487
3506
|
|
|
3488
3507
|
// src/saga-head-maintainer.ts
|
|
3489
3508
|
var import_node_child_process2 = require("node:child_process");
|
|
@@ -3608,6 +3627,12 @@ async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
|
|
|
3608
3627
|
});
|
|
3609
3628
|
}
|
|
3610
3629
|
|
|
3630
|
+
// src/gh-create.ts
|
|
3631
|
+
var import_promises = require("node:fs/promises");
|
|
3632
|
+
var import_node_os = require("node:os");
|
|
3633
|
+
var import_node_path3 = require("node:path");
|
|
3634
|
+
var import_node_crypto = require("node:crypto");
|
|
3635
|
+
|
|
3611
3636
|
// src/board-priority.ts
|
|
3612
3637
|
var BOARD_PRIORITY_NAMES = ["Urgent", "High", "Medium", "Low"];
|
|
3613
3638
|
var CLI_PRIORITIES = ["urgent", "high", "medium", "low"];
|
|
@@ -3646,6 +3671,11 @@ function recoverPriorityFromEvents(events) {
|
|
|
3646
3671
|
|
|
3647
3672
|
// src/gh-create.ts
|
|
3648
3673
|
var ISSUE_TYPES = ["bug", "feature", "task"];
|
|
3674
|
+
var GH_MUTATION_TIMEOUT_MS = 12e4;
|
|
3675
|
+
function timeoutKillNote(err, timeoutMs) {
|
|
3676
|
+
if (typeof err !== "object" || err === null || !err.killed) return void 0;
|
|
3677
|
+
return `killed at the ${timeoutMs}ms timeout \u2014 the write may have completed server-side; verify before retrying`;
|
|
3678
|
+
}
|
|
3649
3679
|
function normalizePriority(priority) {
|
|
3650
3680
|
const p = priority.trim().toLowerCase();
|
|
3651
3681
|
if (!CLI_PRIORITIES.includes(p)) {
|
|
@@ -3673,6 +3703,24 @@ function buildIssueArgs({ type, title, body, priority, repo, labels }) {
|
|
|
3673
3703
|
for (const label of labels ?? []) args.push("--label", label);
|
|
3674
3704
|
return args;
|
|
3675
3705
|
}
|
|
3706
|
+
async function bodyArgsViaFile(args, deps = {}) {
|
|
3707
|
+
const i = args.indexOf("--body");
|
|
3708
|
+
if (i === -1 || i + 1 >= args.length) return { args, cleanup: async () => {
|
|
3709
|
+
} };
|
|
3710
|
+
const write = deps.write ?? import_promises.writeFile;
|
|
3711
|
+
const remove = deps.remove ?? import_promises.unlink;
|
|
3712
|
+
const file = (0, import_node_path3.join)(deps.dir ?? (0, import_node_os.tmpdir)(), `mmi-gh-body-${process.pid}-${(0, import_node_crypto.randomBytes)(4).toString("hex")}.md`);
|
|
3713
|
+
await write(file, args[i + 1], "utf8");
|
|
3714
|
+
return {
|
|
3715
|
+
args: [...args.slice(0, i), "--body-file", file, ...args.slice(i + 2)],
|
|
3716
|
+
cleanup: async () => {
|
|
3717
|
+
try {
|
|
3718
|
+
await remove(file);
|
|
3719
|
+
} catch {
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3676
3724
|
function buildAddToProjectArgs(projectId, contentId) {
|
|
3677
3725
|
if (!projectId) throw new Error("addToProject: projectId is required");
|
|
3678
3726
|
if (!contentId) throw new Error("addToProject: contentId is required");
|
|
@@ -3779,10 +3827,10 @@ function defaultHubUrl() {
|
|
|
3779
3827
|
function buildHealth(i) {
|
|
3780
3828
|
const problems = [];
|
|
3781
3829
|
if (!i.sagaApiUrl) problems.push("Hub API URL not configured");
|
|
3782
|
-
if (!i.identity) problems.push("no
|
|
3830
|
+
if (!i.identity) problems.push("no Hub session identity (run `gh auth login`, then retry)");
|
|
3783
3831
|
if (!i.reachable) problems.push("saga backend unreachable");
|
|
3784
3832
|
if (i.reachable && i.livenessStatus === 403 && i.livenessMessage === "Forbidden") {
|
|
3785
|
-
problems.push("saga API route-level 403 from
|
|
3833
|
+
problems.push("saga API route-level 403 from HubSessionAuthorizer/session policy");
|
|
3786
3834
|
}
|
|
3787
3835
|
if (i.reachable && i.authorized === false) problems.push("saga backend rejected authenticated state access");
|
|
3788
3836
|
if (!i.key.sessionId || i.key.sessionId === "-") problems.push("unsafe session id");
|
|
@@ -3811,9 +3859,35 @@ function resumeCue() {
|
|
|
3811
3859
|
return '> STATUS/RESUME CUE \u2014 For any status, resume, or "where do I stand" report: read THIS saga HEAD first (`mmi-cli saga show`), then reconcile its NEXT / LAST 5 / DECISIONS against the live board + git/gh before reporting. Do not rebuild the picture from board/issues/memory while skipping the HEAD. PRECEDENCE: the HEAD is prior-session belief and MAY BE SUPERSEDED \u2014 the current live user/master instruction WINS over any conflicting HEAD anchor, NEXT, or checklist; follow the live instruction and treat the stale HEAD item as superseded.';
|
|
3812
3860
|
}
|
|
3813
3861
|
|
|
3862
|
+
// src/fetch-retry.ts
|
|
3863
|
+
async function fetchWithRetry(fetchImpl, url, init, opts = {}) {
|
|
3864
|
+
const attempts = opts.attempts ?? 3;
|
|
3865
|
+
const baseDelayMs = opts.baseDelayMs ?? 250;
|
|
3866
|
+
const retryOn = opts.retryOn ?? ((res) => res.status >= 500);
|
|
3867
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
3868
|
+
let lastErr;
|
|
3869
|
+
for (let i = 0; i < attempts; i++) {
|
|
3870
|
+
const isLast = i === attempts - 1;
|
|
3871
|
+
const attemptInit = opts.timeoutMs ? { ...init, signal: AbortSignal.timeout(opts.timeoutMs) } : init;
|
|
3872
|
+
try {
|
|
3873
|
+
const res = await fetchImpl(url, attemptInit);
|
|
3874
|
+
if (!isLast && retryOn(res)) {
|
|
3875
|
+
await sleep(baseDelayMs * 2 ** i);
|
|
3876
|
+
continue;
|
|
3877
|
+
}
|
|
3878
|
+
return res;
|
|
3879
|
+
} catch (e) {
|
|
3880
|
+
lastErr = e;
|
|
3881
|
+
if (isLast) throw e;
|
|
3882
|
+
await sleep(baseDelayMs * 2 ** i);
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
throw lastErr;
|
|
3886
|
+
}
|
|
3887
|
+
|
|
3814
3888
|
// src/saga-note.ts
|
|
3815
3889
|
var AGENT_SURFACE_TOKENS = ["claude", "codex", "cursor", "gemini"];
|
|
3816
|
-
var ROUTE_LEVEL_403 = "saga API route-level 403 from
|
|
3890
|
+
var ROUTE_LEVEL_403 = "saga API route-level 403 from HubSessionAuthorizer/session policy";
|
|
3817
3891
|
function agentSurface(env = process.env) {
|
|
3818
3892
|
const surface = env.MMI_AGENT_SURFACE?.trim() || (env.CODEX_THREAD_ID?.trim() && !env.CLAUDE_SESSION_ID?.trim() ? "codex" : "claude");
|
|
3819
3893
|
if (AGENT_SURFACE_TOKENS.includes(surface)) return surface;
|
|
@@ -3990,6 +4064,72 @@ Related work discovered by mmi-cli:
|
|
|
3990
4064
|
${lines.join("\n")}`;
|
|
3991
4065
|
}
|
|
3992
4066
|
|
|
4067
|
+
// src/sub-issue.ts
|
|
4068
|
+
function parseIssueRef(ref) {
|
|
4069
|
+
const trimmed = ref.trim();
|
|
4070
|
+
const url = trimmed.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)$/i);
|
|
4071
|
+
if (url) return { repo: url[1], number: Number(url[2]) };
|
|
4072
|
+
const qualified = trimmed.match(/^([^/\s#]+\/[^/\s#]+)#(\d+)$/);
|
|
4073
|
+
if (qualified) return { repo: qualified[1], number: Number(qualified[2]) };
|
|
4074
|
+
const bare = trimmed.match(/^#?(\d+)$/);
|
|
4075
|
+
if (bare) return { number: Number(bare[1]) };
|
|
4076
|
+
throw new Error(`invalid issue reference "${ref}" \u2014 expected #123, 123, owner/repo#123, or an issue URL`);
|
|
4077
|
+
}
|
|
4078
|
+
function buildResolveIdArgs(ref) {
|
|
4079
|
+
const args = ["issue", "view", String(ref.number), "--json", "id", "--jq", ".id"];
|
|
4080
|
+
if (ref.repo) args.push("--repo", ref.repo);
|
|
4081
|
+
return args;
|
|
4082
|
+
}
|
|
4083
|
+
function buildAddSubIssueArgs(parentId, subIssueId) {
|
|
4084
|
+
if (!parentId) throw new Error("addSubIssue: parentId is required");
|
|
4085
|
+
if (!subIssueId) throw new Error("addSubIssue: subIssueId is required");
|
|
4086
|
+
return [
|
|
4087
|
+
"api",
|
|
4088
|
+
"graphql",
|
|
4089
|
+
"-f",
|
|
4090
|
+
"query=mutation($p:ID!,$c:ID!){addSubIssue(input:{issueId:$p,subIssueId:$c}){issue{number subIssues{totalCount}} subIssue{number}}}",
|
|
4091
|
+
"-f",
|
|
4092
|
+
`p=${parentId}`,
|
|
4093
|
+
"-f",
|
|
4094
|
+
`c=${subIssueId}`
|
|
4095
|
+
];
|
|
4096
|
+
}
|
|
4097
|
+
function parseAddSubIssueResult(stdout) {
|
|
4098
|
+
try {
|
|
4099
|
+
const issue2 = JSON.parse(stdout)?.data?.addSubIssue;
|
|
4100
|
+
const parentNumber = issue2?.issue?.number;
|
|
4101
|
+
const subIssueNumber = issue2?.subIssue?.number;
|
|
4102
|
+
const totalCount = issue2?.issue?.subIssues?.totalCount;
|
|
4103
|
+
if (typeof parentNumber !== "number" || typeof subIssueNumber !== "number") return void 0;
|
|
4104
|
+
return { parentNumber, subIssueNumber, totalCount: typeof totalCount === "number" ? totalCount : 0 };
|
|
4105
|
+
} catch {
|
|
4106
|
+
return void 0;
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
var RESOLVE_ID_TIMEOUT_MS = 1e4;
|
|
4110
|
+
async function resolveIssueNodeId(runGh, ref, fallbackRepo) {
|
|
4111
|
+
const resolved = ref.repo ? ref : { ...ref, repo: fallbackRepo };
|
|
4112
|
+
const id = (await runGh(buildResolveIdArgs(resolved), RESOLVE_ID_TIMEOUT_MS)).trim();
|
|
4113
|
+
if (!id) throw new Error(`could not resolve node id for issue #${ref.number}${resolved.repo ? ` in ${resolved.repo}` : ""}`);
|
|
4114
|
+
return id;
|
|
4115
|
+
}
|
|
4116
|
+
async function linkSubIssue(runGh, parentRef, childRef, defaultRepo) {
|
|
4117
|
+
const parent = parseIssueRef(parentRef);
|
|
4118
|
+
const child = parseIssueRef(childRef);
|
|
4119
|
+
const parentId = await resolveIssueNodeId(runGh, parent, defaultRepo);
|
|
4120
|
+
const subIssueId = await resolveIssueNodeId(runGh, child, defaultRepo);
|
|
4121
|
+
const stdout = await runGh(buildAddSubIssueArgs(parentId, subIssueId), GH_MUTATION_TIMEOUT_MS);
|
|
4122
|
+
const result = parseAddSubIssueResult(stdout);
|
|
4123
|
+
if (!result) throw new Error(`addSubIssue returned an unexpected response:
|
|
4124
|
+
${stdout.trim() || "(empty)"}`);
|
|
4125
|
+
return result;
|
|
4126
|
+
}
|
|
4127
|
+
function parentLinkFields(result, error) {
|
|
4128
|
+
if (result) return { parent: result };
|
|
4129
|
+
if (error) return { parentLinkError: error };
|
|
4130
|
+
return {};
|
|
4131
|
+
}
|
|
4132
|
+
|
|
3993
4133
|
// src/report.ts
|
|
3994
4134
|
var HUB_REPO = "mutmutco/MMI-Hub";
|
|
3995
4135
|
var REPORT_LABEL = "report";
|
|
@@ -4923,6 +5063,99 @@ function parseWorktreePorcelain(stdout) {
|
|
|
4923
5063
|
function samePath(a, b) {
|
|
4924
5064
|
return a.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === b.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
4925
5065
|
}
|
|
5066
|
+
function normPath(p) {
|
|
5067
|
+
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
5068
|
+
}
|
|
5069
|
+
function parseComposeLs(stdout) {
|
|
5070
|
+
const text = stdout.trim();
|
|
5071
|
+
if (!text) return [];
|
|
5072
|
+
const rows = [];
|
|
5073
|
+
const pushIfObject = (v) => {
|
|
5074
|
+
if (v && typeof v === "object" && !Array.isArray(v)) rows.push(v);
|
|
5075
|
+
};
|
|
5076
|
+
try {
|
|
5077
|
+
const parsed = JSON.parse(text);
|
|
5078
|
+
if (Array.isArray(parsed)) parsed.forEach(pushIfObject);
|
|
5079
|
+
else pushIfObject(parsed);
|
|
5080
|
+
} catch {
|
|
5081
|
+
for (const line of text.split(/\r?\n/)) {
|
|
5082
|
+
const trimmed = line.trim();
|
|
5083
|
+
if (!trimmed) continue;
|
|
5084
|
+
try {
|
|
5085
|
+
pushIfObject(JSON.parse(trimmed));
|
|
5086
|
+
} catch {
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
return rows.map((row) => {
|
|
5091
|
+
const name = typeof row.Name === "string" ? row.Name.trim() : "";
|
|
5092
|
+
if (!name) return null;
|
|
5093
|
+
const raw = typeof row.ConfigFiles === "string" ? row.ConfigFiles : "";
|
|
5094
|
+
const configFiles = raw.split(",").map((f) => f.trim()).filter(Boolean);
|
|
5095
|
+
return { name, configFiles };
|
|
5096
|
+
}).filter((p) => Boolean(p));
|
|
5097
|
+
}
|
|
5098
|
+
function selectWorktreeComposeProjects(worktreePath, projects) {
|
|
5099
|
+
const root = normPath(worktreePath);
|
|
5100
|
+
if (!root) return [];
|
|
5101
|
+
const names = [];
|
|
5102
|
+
for (const project2 of projects) {
|
|
5103
|
+
const inside = project2.configFiles.some((file) => {
|
|
5104
|
+
const f = normPath(file);
|
|
5105
|
+
return f === root || f.startsWith(`${root}/`);
|
|
5106
|
+
});
|
|
5107
|
+
if (inside && !names.includes(project2.name)) names.push(project2.name);
|
|
5108
|
+
}
|
|
5109
|
+
return names;
|
|
5110
|
+
}
|
|
5111
|
+
function deriveComposeProjectName(worktreePath) {
|
|
5112
|
+
const norm = normPath(worktreePath);
|
|
5113
|
+
if (!norm) return void 0;
|
|
5114
|
+
const base = norm.slice(norm.lastIndexOf("/") + 1);
|
|
5115
|
+
const name = base.replace(/[^a-z0-9_-]+/g, "_").replace(/^[^a-z0-9]+/, "");
|
|
5116
|
+
return name || void 0;
|
|
5117
|
+
}
|
|
5118
|
+
function planWorktreeComposeTeardown(worktreePath, discovered, hasStageState) {
|
|
5119
|
+
const names = [...discovered];
|
|
5120
|
+
if (!hasStageState && discovered.length === 0) return names;
|
|
5121
|
+
const recovery = deriveComposeProjectName(worktreePath);
|
|
5122
|
+
if (recovery && !names.includes(recovery)) names.push(recovery);
|
|
5123
|
+
return names;
|
|
5124
|
+
}
|
|
5125
|
+
async function runWorktreeStageTeardown(worktreePath, deps) {
|
|
5126
|
+
const hasStageState = deps.hasStageState(worktreePath);
|
|
5127
|
+
const stoppedPid = hasStageState ? await deps.stopRecordedStage(worktreePath).catch(() => void 0) : void 0;
|
|
5128
|
+
let discovered;
|
|
5129
|
+
try {
|
|
5130
|
+
discovered = selectWorktreeComposeProjects(worktreePath, await deps.listComposeProjects());
|
|
5131
|
+
} catch {
|
|
5132
|
+
return { status: stoppedPid != null ? "torn-down" : "none", stoppedPid };
|
|
5133
|
+
}
|
|
5134
|
+
const projects = planWorktreeComposeTeardown(worktreePath, discovered, hasStageState);
|
|
5135
|
+
const brought = [];
|
|
5136
|
+
const failed = [];
|
|
5137
|
+
const errors = [];
|
|
5138
|
+
for (const project2 of projects) {
|
|
5139
|
+
try {
|
|
5140
|
+
await deps.composeDown(project2);
|
|
5141
|
+
brought.push(project2);
|
|
5142
|
+
} catch (e) {
|
|
5143
|
+
failed.push(project2);
|
|
5144
|
+
errors.push(`docker compose -p ${project2} down -v: ${errorMessage(e)}`);
|
|
5145
|
+
}
|
|
5146
|
+
}
|
|
5147
|
+
if (failed.length) {
|
|
5148
|
+
return {
|
|
5149
|
+
status: "failed",
|
|
5150
|
+
stoppedPid,
|
|
5151
|
+
composeProjects: brought.length ? brought : void 0,
|
|
5152
|
+
failedProjects: failed,
|
|
5153
|
+
error: errors.join("; ")
|
|
5154
|
+
};
|
|
5155
|
+
}
|
|
5156
|
+
if (stoppedPid == null && brought.length === 0) return { status: "none" };
|
|
5157
|
+
return { status: "torn-down", stoppedPid, composeProjects: brought.length ? brought : void 0 };
|
|
5158
|
+
}
|
|
4926
5159
|
function selectPrMergeCleanupWorktree(branch, before, after, startingPath) {
|
|
4927
5160
|
if (!branch) return void 0;
|
|
4928
5161
|
const current = after.find((w) => w.branch === branch)?.path;
|
|
@@ -4968,15 +5201,24 @@ async function cleanupPrMergeLocalBranch(branch, options) {
|
|
|
4968
5201
|
const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...beforeWorktrees], wtPath);
|
|
4969
5202
|
const git = (args) => safeCwd ? options.execGit(["-C", safeCwd, ...args]) : options.execGit(args);
|
|
4970
5203
|
if (wtPath) {
|
|
5204
|
+
let stageTeardown;
|
|
5205
|
+
if (options.teardownWorktreeStage) {
|
|
5206
|
+
try {
|
|
5207
|
+
stageTeardown = await options.teardownWorktreeStage(wtPath);
|
|
5208
|
+
} catch (e) {
|
|
5209
|
+
stageTeardown = { status: "failed", error: errorMessage(e) };
|
|
5210
|
+
}
|
|
5211
|
+
}
|
|
4971
5212
|
try {
|
|
4972
5213
|
await git(["worktree", "remove", "--force", wtPath]);
|
|
4973
|
-
report.worktree = { path: wtPath, status: "removed" };
|
|
5214
|
+
report.worktree = { path: wtPath, status: "removed", stageTeardown };
|
|
4974
5215
|
} catch (e) {
|
|
4975
5216
|
report.worktree = {
|
|
4976
5217
|
path: wtPath,
|
|
4977
5218
|
status: "failed",
|
|
4978
5219
|
error: errorMessage(e),
|
|
4979
|
-
safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath)
|
|
5220
|
+
safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath),
|
|
5221
|
+
stageTeardown
|
|
4980
5222
|
};
|
|
4981
5223
|
report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-removal-failed" };
|
|
4982
5224
|
return report;
|
|
@@ -5026,14 +5268,28 @@ function formatGcPlan(plan2, apply) {
|
|
|
5026
5268
|
}
|
|
5027
5269
|
|
|
5028
5270
|
// src/command-plans.ts
|
|
5029
|
-
function stagePlan(stage2 = {}) {
|
|
5271
|
+
function stagePlan(stage2 = {}, stops = true) {
|
|
5030
5272
|
return [
|
|
5031
|
-
{ label: "force-kill previous local stage", command: "mmi-cli stage stop --apply" },
|
|
5273
|
+
...stops ? [{ label: "force-kill previous local stage", command: "mmi-cli stage stop --apply" }] : [],
|
|
5032
5274
|
{ label: "run local build", command: stage2.build || "(no stage.build configured)" },
|
|
5033
5275
|
{ label: "start local stage", command: stage2.up || "(no stage.up configured)" },
|
|
5034
5276
|
{ label: "check health", command: stage2.healthUrl ? `curl --fail ${stage2.healthUrl}` : "(no stage.healthUrl configured)" }
|
|
5035
5277
|
];
|
|
5036
5278
|
}
|
|
5279
|
+
function derivedStagePlan(derived, shell2, stops = true) {
|
|
5280
|
+
const { port } = derived;
|
|
5281
|
+
const envOrder = ["MMI_STAGE", "MMI_PORT", "PORT", "COMPOSE_PROFILES"];
|
|
5282
|
+
const resolved = { MMI_STAGE: "development", MMI_PORT: String(port), PORT: String(port), COMPOSE_PROFILES: "local" };
|
|
5283
|
+
const ensureEnv = shell2 === "powershell" ? `if (-not (Test-Path -LiteralPath '.env')) { Copy-Item -LiteralPath '.env.example' -Destination '.env' }` : `[ -f .env ] || cp .env.example .env`;
|
|
5284
|
+
const up = shell2 === "powershell" ? `${envOrder.map((k) => `$env:${k}='${resolved[k]}'`).join("; ")}; docker compose up -d --build` : `${envOrder.map((k) => `${k}=${resolved[k]}`).join(" ")} docker compose up -d --build`;
|
|
5285
|
+
const health = shell2 === "powershell" ? `curl.exe --fail ${derived.url}` : `curl --fail ${derived.url}`;
|
|
5286
|
+
return [
|
|
5287
|
+
...stops ? [{ label: "force-kill previous local stage", command: "mmi-cli stage stop --apply" }] : [],
|
|
5288
|
+
{ label: "bootstrap .env from .env.example", command: ensureEnv },
|
|
5289
|
+
{ label: `start local stage on port ${port} (registry portRange)`, command: up },
|
|
5290
|
+
{ label: `check health at ${derived.url}`, command: health }
|
|
5291
|
+
];
|
|
5292
|
+
}
|
|
5037
5293
|
function stageLivePlan() {
|
|
5038
5294
|
return [
|
|
5039
5295
|
{ label: "stage-live is not an org command; /stage is local only", command: "mmi-cli stage run --apply" },
|
|
@@ -5049,7 +5305,8 @@ function trainPlan(command) {
|
|
|
5049
5305
|
{ label: "preflight required rc secret names", command: "mmi-cli secrets preflight --stage rc --repo <owner/repo>", gated: true },
|
|
5050
5306
|
{ label: "for Hub distribution changes, verify the release bump is already landed before touching rc", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
|
|
5051
5307
|
{ label: "merge development to rc", gated: true },
|
|
5052
|
-
{ label: "trigger the deploy path for this repo model", command: "tenant-container: gh workflow run tenant-deploy.yml
|
|
5308
|
+
{ label: "trigger the deploy path for this repo model, returning the Hub Actions run id/url (and, with --watch, its outcome)", command: "tenant-container: gh workflow run tenant-deploy.yml ... then gh run list/watch; hub-serverless: no manual dispatch, deploy.yml auto-fires on rc push", gated: true },
|
|
5309
|
+
{ label: "after a failed deploy, retry the existing rc ref (no re-tag/merge)", command: "mmi-cli tenant redeploy <owner/repo> rc --watch", gated: true }
|
|
5053
5310
|
];
|
|
5054
5311
|
}
|
|
5055
5312
|
if (command === "release") {
|
|
@@ -5061,7 +5318,7 @@ function trainPlan(command) {
|
|
|
5061
5318
|
{ label: "merge rc to main", gated: true },
|
|
5062
5319
|
{ label: "for Hub distribution changes, verify the promoted SHA carries the release bump", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
|
|
5063
5320
|
{ label: "tag release and publish GitHub Release", gated: true },
|
|
5064
|
-
{ label: "trigger the deploy path for this repo model", command: "tenant-container: gh workflow run tenant-deploy.yml
|
|
5321
|
+
{ label: "trigger the deploy path for this repo model, returning the Hub Actions run id/url (and, with --watch, its outcome)", command: "tenant-container: gh workflow run tenant-deploy.yml ... then gh run list/watch; hub-serverless: no manual dispatch, deploy.yml + publish.yml auto-fire on the release", gated: true },
|
|
5065
5322
|
{ label: "roll development forward", gated: true }
|
|
5066
5323
|
];
|
|
5067
5324
|
}
|
|
@@ -5086,6 +5343,114 @@ function bootstrapPlan(repo, repoClass) {
|
|
|
5086
5343
|
];
|
|
5087
5344
|
}
|
|
5088
5345
|
|
|
5346
|
+
// src/stage-default.ts
|
|
5347
|
+
function shellFor(platform = process.platform) {
|
|
5348
|
+
return platform === "win32" ? "powershell" : "bash";
|
|
5349
|
+
}
|
|
5350
|
+
function deriveStageGap(inputs) {
|
|
5351
|
+
const missing = [];
|
|
5352
|
+
if (inputs.deployModel !== "tenant-container") {
|
|
5353
|
+
return `local stage default applies to tenant-container repos only (registry deployModel = ${inputs.deployModel ?? "unset"})`;
|
|
5354
|
+
}
|
|
5355
|
+
if (!inputs.hasCompose) missing.push("docker-compose.yml");
|
|
5356
|
+
if (!inputs.hasEnvExample) missing.push(".env.example");
|
|
5357
|
+
if (!inputs.portRange) missing.push("Hub registry portRange");
|
|
5358
|
+
return missing.length ? `cannot derive a default local stage \u2014 missing: ${missing.join(", ")}` : null;
|
|
5359
|
+
}
|
|
5360
|
+
function deriveStage(inputs) {
|
|
5361
|
+
if (deriveStageGap(inputs) || !inputs.portRange) return null;
|
|
5362
|
+
const port = inputs.portRange[0];
|
|
5363
|
+
const config = {
|
|
5364
|
+
up: "docker compose up -d --build",
|
|
5365
|
+
healthUrl: "http://127.0.0.1:$STAGE_PORT/",
|
|
5366
|
+
// A generic compose app's landing route is unknown (e.g. MM-Website serves /tr/), so "the server
|
|
5367
|
+
// answered" — any HTTP status — is the health signal, not a 2xx on the bare root.
|
|
5368
|
+
healthAnyStatus: true,
|
|
5369
|
+
portRange: inputs.portRange,
|
|
5370
|
+
ensureEnv: { example: ".env.example", target: ".env" },
|
|
5371
|
+
env: {
|
|
5372
|
+
MMI_STAGE: "development",
|
|
5373
|
+
MMI_PORT: "$STAGE_PORT",
|
|
5374
|
+
PORT: "$STAGE_PORT",
|
|
5375
|
+
COMPOSE_PROFILES: "local"
|
|
5376
|
+
}
|
|
5377
|
+
};
|
|
5378
|
+
return { config, port, url: stageUrlForPort(port) };
|
|
5379
|
+
}
|
|
5380
|
+
function stageUrlForPort(port) {
|
|
5381
|
+
return `http://127.0.0.1:${port}/`;
|
|
5382
|
+
}
|
|
5383
|
+
var POSIX_ONLY = [
|
|
5384
|
+
/(^|[^&|])&&([^&]|$)/,
|
|
5385
|
+
// && chaining (cmd.exe uses it too, but PowerShell <7 / the typical agent shell does not)
|
|
5386
|
+
/\|\|/,
|
|
5387
|
+
// || chaining
|
|
5388
|
+
// `VAR=value cmd` LEADING env prefix only — anchored to a command boundary (start-of-string or after a
|
|
5389
|
+
// ; & | separator) so it never fires on a flag value mid-command (`-e DEBUG=true app`, `--env X=y svc`),
|
|
5390
|
+
// which are valid cross-shell. The value excludes separators so it can't span past the command boundary.
|
|
5391
|
+
/(?:^|[;&|])\s*[A-Za-z_][A-Za-z0-9_]*=[^\s;&|]+\s+\S/,
|
|
5392
|
+
/(^|[\s;&|])(cp|rm|mv|ln|cat|touch|export)\s/
|
|
5393
|
+
// bare POSIX file/env builtins
|
|
5394
|
+
];
|
|
5395
|
+
function looksPosixOnly(command) {
|
|
5396
|
+
if (!command?.trim()) return false;
|
|
5397
|
+
return POSIX_ONLY.some((re) => re.test(command));
|
|
5398
|
+
}
|
|
5399
|
+
function stalePosixFields(config, shell2) {
|
|
5400
|
+
if (shell2 !== "powershell") return [];
|
|
5401
|
+
const fields = [];
|
|
5402
|
+
if (looksPosixOnly(config?.build)) fields.push("build");
|
|
5403
|
+
if (looksPosixOnly(config?.up)) fields.push("up");
|
|
5404
|
+
return fields;
|
|
5405
|
+
}
|
|
5406
|
+
function sanitizeLocalStage(local, stale) {
|
|
5407
|
+
if (!stale.length) return local;
|
|
5408
|
+
const clean2 = { ...local };
|
|
5409
|
+
for (const field of stale) delete clean2[field];
|
|
5410
|
+
return clean2;
|
|
5411
|
+
}
|
|
5412
|
+
function staleNote(staleFields, outcome) {
|
|
5413
|
+
const list = staleFields.join(", ");
|
|
5414
|
+
const plural = staleFields.length > 1 ? "fields" : "field";
|
|
5415
|
+
return `local .mmi stage ${plural} ${list} ${staleFields.length > 1 ? "are" : "is"} POSIX-only and unusable on PowerShell \u2014 ${outcome}`;
|
|
5416
|
+
}
|
|
5417
|
+
function decideStage(inputs) {
|
|
5418
|
+
const { local, shell: shell2, registry: registry2, hasCompose, hasEnvExample } = inputs;
|
|
5419
|
+
const staleFields = stalePosixFields(local, shell2);
|
|
5420
|
+
const stale = staleFields.length > 0;
|
|
5421
|
+
const upStale = staleFields.includes("up");
|
|
5422
|
+
if (local?.up?.trim() && !upStale) {
|
|
5423
|
+
if (!stale) return { source: "local", config: local };
|
|
5424
|
+
return {
|
|
5425
|
+
source: "local",
|
|
5426
|
+
config: sanitizeLocalStage(local, staleFields),
|
|
5427
|
+
staleIgnored: true,
|
|
5428
|
+
staleFields,
|
|
5429
|
+
gap: staleNote(staleFields, `kept the cross-shell parts of the local recipe and ignored ${staleFields.join(", ")}`)
|
|
5430
|
+
};
|
|
5431
|
+
}
|
|
5432
|
+
const deriveInputs = {
|
|
5433
|
+
portRange: registry2.portRange,
|
|
5434
|
+
deployModel: registry2.deployModel,
|
|
5435
|
+
hasCompose,
|
|
5436
|
+
hasEnvExample
|
|
5437
|
+
};
|
|
5438
|
+
const derived = deriveStage(deriveInputs);
|
|
5439
|
+
if (derived) {
|
|
5440
|
+
return {
|
|
5441
|
+
source: "derived",
|
|
5442
|
+
derived,
|
|
5443
|
+
registryError: registry2.error,
|
|
5444
|
+
staleIgnored: stale || void 0,
|
|
5445
|
+
staleFields: stale ? staleFields : void 0
|
|
5446
|
+
};
|
|
5447
|
+
}
|
|
5448
|
+
const registryGap = registry2.error ? `Hub registry read failed (${registry2.error}) \u2014 cannot derive a default local stage` : null;
|
|
5449
|
+
const baseGap = registryGap ?? deriveStageGap(deriveInputs);
|
|
5450
|
+
const gap = stale ? `local .mmi stage recipe is POSIX-only (${staleFields.join(", ")}) and unusable on PowerShell; ${baseGap ?? "no registry-derived default available"}` : baseGap ?? "no stage.up configured and no registry-derived default available";
|
|
5451
|
+
return { source: "none", gap, staleIgnored: stale || void 0, staleFields: stale ? staleFields : void 0, registryError: registry2.error };
|
|
5452
|
+
}
|
|
5453
|
+
|
|
5089
5454
|
// src/bootstrap-seeds.ts
|
|
5090
5455
|
var PLACEHOLDER_RE = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
5091
5456
|
function loadBootstrapSeeds(manifestJson) {
|
|
@@ -5130,6 +5495,7 @@ var MANAGED_GITIGNORE_LINES = [
|
|
|
5130
5495
|
".claude/worktrees/",
|
|
5131
5496
|
".mmi/.session",
|
|
5132
5497
|
".mmi/head-ts/",
|
|
5498
|
+
".aws-sam/",
|
|
5133
5499
|
"/*.png"
|
|
5134
5500
|
];
|
|
5135
5501
|
function renderManagedGitignoreBlock() {
|
|
@@ -5163,6 +5529,23 @@ ${block}
|
|
|
5163
5529
|
}
|
|
5164
5530
|
return { content: next, changed: src !== next };
|
|
5165
5531
|
}
|
|
5532
|
+
function diffManagedGitignoreBlock(current) {
|
|
5533
|
+
const lines = (current ?? "").replace(/\r\n/g, "\n").split("\n");
|
|
5534
|
+
const beginAt = lines.findIndex((l) => l === GITIGNORE_MANAGED_BEGIN);
|
|
5535
|
+
const endAt = beginAt === -1 ? -1 : lines.findIndex((l, i) => i > beginAt && l === GITIGNORE_MANAGED_END);
|
|
5536
|
+
const hasMarker = beginAt !== -1 || lines.some((l) => l === GITIGNORE_MANAGED_END);
|
|
5537
|
+
if (!hasMarker) return { added: [], removed: [], seeded: true };
|
|
5538
|
+
const wellOrdered = beginAt !== -1 && endAt !== -1;
|
|
5539
|
+
if (!wellOrdered) return { added: [], removed: [], seeded: false };
|
|
5540
|
+
const isRule = (l) => l.trim() !== "" && !l.trim().startsWith("#");
|
|
5541
|
+
const oldBody = lines.slice(beginAt + 1, endAt).filter(isRule);
|
|
5542
|
+
const newBody = MANAGED_GITIGNORE_LINES.filter(isRule);
|
|
5543
|
+
return {
|
|
5544
|
+
added: newBody.filter((l) => !oldBody.includes(l)),
|
|
5545
|
+
removed: oldBody.filter((l) => !newBody.includes(l)),
|
|
5546
|
+
seeded: false
|
|
5547
|
+
};
|
|
5548
|
+
}
|
|
5166
5549
|
|
|
5167
5550
|
// src/doctor.ts
|
|
5168
5551
|
var GH_PROJECT_LOGIN_FIX = 'run: gh auth login --hostname github.com --git-protocol https --web --scopes "project"';
|
|
@@ -5193,7 +5576,7 @@ function buildAwsCrossAccountCheck(input) {
|
|
|
5193
5576
|
var MMI_PLUGIN_ID = "mmi@mmi";
|
|
5194
5577
|
var PLUGIN_LABEL = "plugin install record (mmi@mmi for this project)";
|
|
5195
5578
|
function pluginInstallManualFix(projectPath, surface = "claude-cli") {
|
|
5196
|
-
const register = surface === "codex" ? `\`codex plugin add ${MMI_PLUGIN_ID}\`` : surface === "shell" ? `enable the MMI plugin in your client` : surface === "claude-vscode" ? `\`claude plugin enable ${MMI_PLUGIN_ID}\`` : `\`/plugin install ${MMI_PLUGIN_ID}\``;
|
|
5579
|
+
const register = surface === "codex" ? `\`codex plugin add ${MMI_PLUGIN_ID}\`` : surface === "cursor" ? `import the MMI Team Marketplace in Cursor Dashboard \u2192 Settings \u2192 Plugins (or enable the MMI plugin from the marketplace panel)` : surface === "shell" ? `enable the MMI plugin in your client` : surface === "claude-vscode" ? `\`claude plugin enable ${MMI_PLUGIN_ID}\`` : `\`/plugin install ${MMI_PLUGIN_ID}\``;
|
|
5197
5580
|
return `run ${register} then ${reloadAction(surface)} to register the install record for ${projectPath}`;
|
|
5198
5581
|
}
|
|
5199
5582
|
function isMmiPluginEnabled(settings) {
|
|
@@ -5248,6 +5631,9 @@ function bestRecord(records) {
|
|
|
5248
5631
|
});
|
|
5249
5632
|
}
|
|
5250
5633
|
function pluginConfigDriftFix(pluginId, surface = "claude-cli") {
|
|
5634
|
+
if (surface === "cursor") {
|
|
5635
|
+
return `\`${pluginId}\` is registered through Cursor's Team Marketplace \u2014 refresh it in Dashboard \u2192 Settings \u2192 Plugins (Cursor manages its own plugin records; mmi-cli cannot rewrite them), then ${reloadAction(surface)}`;
|
|
5636
|
+
}
|
|
5251
5637
|
const file = surface === "codex" ? "~/.codex/plugins/installed_plugins.json" : "~/.claude/plugins/installed_plugins.json";
|
|
5252
5638
|
return `\`${pluginId}\` has duplicate install rows or stale gitCommitSha in ${file} \u2014 run \`mmi-cli doctor\` interactively to collapse them to one user-scope row (a .bak backup is written first), then ${reloadAction(surface)}`;
|
|
5253
5639
|
}
|
|
@@ -5289,7 +5675,8 @@ function buildGitignoreManagedBlockCheck(input) {
|
|
|
5289
5675
|
if (!input.isOrgRepo) return base;
|
|
5290
5676
|
const { content, changed } = upsertManagedGitignoreBlock(input.content);
|
|
5291
5677
|
if (!changed) return base;
|
|
5292
|
-
|
|
5678
|
+
const { added, removed, seeded } = diffManagedGitignoreBlock(input.content);
|
|
5679
|
+
return { ...base, ok: false, contentToWrite: content, added, removed, seeded };
|
|
5293
5680
|
}
|
|
5294
5681
|
var MMI_PLUGIN_CACHE_CLEANUP_LABEL = "stale MMI plugin cache dirs (Claude/Codex)";
|
|
5295
5682
|
var MMI_PLUGIN_CACHE_CLEANUP_FIX = "run `mmi-cli doctor` to quarantine stale MMI-only plugin cache dirs, then reload the affected agent surface";
|
|
@@ -5339,6 +5726,9 @@ function detectSurface(env) {
|
|
|
5339
5726
|
if (env.MMI_AGENT_SURFACE === "codex" || has("CODEX_HOME") || (env.CLAUDE_PLUGIN_ROOT ?? "").includes(".codex")) {
|
|
5340
5727
|
return "codex";
|
|
5341
5728
|
}
|
|
5729
|
+
if (env.MMI_AGENT_SURFACE === "cursor" || has("CURSOR_TRACE_ID") || has("CURSOR_USER") || has("CURSOR_SESSION_ID")) {
|
|
5730
|
+
return "cursor";
|
|
5731
|
+
}
|
|
5342
5732
|
const isClaude = has("CLAUDECODE") || has("CLAUDE_CODE_ENTRYPOINT") || has("CLAUDE_PLUGIN_ROOT") || env.MMI_AGENT_SURFACE === "claude";
|
|
5343
5733
|
const isVscode = env.TERM_PROGRAM === "vscode" || has("VSCODE_PID") || has("VSCODE_GIT_ASKPASS_NODE");
|
|
5344
5734
|
if (isClaude && isVscode) return "claude-vscode";
|
|
@@ -5351,6 +5741,8 @@ function reloadAction(surface) {
|
|
|
5351
5741
|
return "restart VS Code";
|
|
5352
5742
|
case "codex":
|
|
5353
5743
|
return "restart Codex";
|
|
5744
|
+
case "cursor":
|
|
5745
|
+
return "restart Cursor (or refresh the Team Marketplace from Dashboard \u2192 Settings \u2192 Plugins)";
|
|
5354
5746
|
case "claude-cli":
|
|
5355
5747
|
case "shell":
|
|
5356
5748
|
default:
|
|
@@ -5365,6 +5757,8 @@ function pluginRecoveryFix(surface) {
|
|
|
5365
5757
|
return `${claude} # then ${reloadAction(surface)} to reload MMI commands`;
|
|
5366
5758
|
case "codex":
|
|
5367
5759
|
return "codex plugin marketplace upgrade mmi && codex plugin add mmi@mmi # then restart Codex";
|
|
5760
|
+
case "cursor":
|
|
5761
|
+
return `in Cursor Dashboard \u2192 Settings \u2192 Plugins, click Update next to the MMI Team Marketplace; then ${reloadAction(surface)} to reload MMI skills + hooks`;
|
|
5368
5762
|
case "shell":
|
|
5369
5763
|
default:
|
|
5370
5764
|
return "npm install -g @mutmutco/cli@latest";
|
|
@@ -5401,20 +5795,40 @@ function buildInstalledPluginVersionCheck(input) {
|
|
|
5401
5795
|
// src/stage-runner.ts
|
|
5402
5796
|
var import_node_child_process5 = require("node:child_process");
|
|
5403
5797
|
var import_node_fs3 = require("node:fs");
|
|
5404
|
-
var
|
|
5798
|
+
var import_node_path4 = require("node:path");
|
|
5405
5799
|
var import_node_net = require("node:net");
|
|
5406
5800
|
var import_node_util5 = require("node:util");
|
|
5407
5801
|
var execFileP3 = (0, import_node_util5.promisify)(import_node_child_process5.execFile);
|
|
5408
5802
|
function stageStatePath(cwd = process.cwd()) {
|
|
5409
|
-
return (0,
|
|
5803
|
+
return (0, import_node_path4.join)(cwd, "tmp", "stage", "state.json");
|
|
5804
|
+
}
|
|
5805
|
+
var POSIX_ONLY_VERBS = ["cp", "mv", "rm", "ln", "cat", "touch", "chmod", "export"];
|
|
5806
|
+
function posixOnlyShellProblems(command, field, platform = process.platform) {
|
|
5807
|
+
if (platform !== "win32" || !command?.trim()) return [];
|
|
5808
|
+
const problems = [];
|
|
5809
|
+
if (/(^|&&|\||;)\s*[A-Za-z_][A-Za-z0-9_]*=\S/.test(command)) {
|
|
5810
|
+
problems.push(
|
|
5811
|
+
`stage.${field} uses POSIX inline env assignment (VAR=value command) which fails in cmd.exe on Windows; use 'set VAR=value && command' or a cross-platform launcher`
|
|
5812
|
+
);
|
|
5813
|
+
}
|
|
5814
|
+
for (const verb of POSIX_ONLY_VERBS) {
|
|
5815
|
+
if (new RegExp(`(^|&&|\\||;|\\()\\s*${verb}\\b`).test(command)) {
|
|
5816
|
+
problems.push(
|
|
5817
|
+
`stage.${field} calls POSIX '${verb}' which does not exist in cmd.exe on Windows; use the cmd/PowerShell equivalent or a cross-platform script`
|
|
5818
|
+
);
|
|
5819
|
+
}
|
|
5820
|
+
}
|
|
5821
|
+
return problems;
|
|
5410
5822
|
}
|
|
5411
5823
|
function validateStageConfig(config = {}, action) {
|
|
5412
5824
|
const problems = [];
|
|
5413
|
-
if (action === "run" && !config.build?.trim()) problems.push("stage.build is required for stage run");
|
|
5825
|
+
if (action === "run" && !config.build?.trim() && !config.ensureEnv) problems.push("stage.build is required for stage run");
|
|
5414
5826
|
if (!config.up?.trim()) problems.push("stage.up is required to start the local stage");
|
|
5415
5827
|
if (config.healthUrl != null && config.healthUrl.trim() && !/^https?:\/\//.test(config.healthUrl.trim())) {
|
|
5416
5828
|
problems.push("stage.healthUrl must be an http(s) URL");
|
|
5417
5829
|
}
|
|
5830
|
+
if (action === "run") problems.push(...posixOnlyShellProblems(config.build, "build"));
|
|
5831
|
+
problems.push(...posixOnlyShellProblems(config.up, "up"));
|
|
5418
5832
|
if (config.portRange != null) {
|
|
5419
5833
|
const r = config.portRange;
|
|
5420
5834
|
const ok = Array.isArray(r) && r.length === 2 && r.every((n) => Number.isInteger(n) && n >= 1024 && n <= 65535) && r[0] <= r[1];
|
|
@@ -5479,13 +5893,13 @@ async function killTree(pid) {
|
|
|
5479
5893
|
}
|
|
5480
5894
|
}
|
|
5481
5895
|
}
|
|
5482
|
-
async function waitForHealth(url, timeoutMs) {
|
|
5896
|
+
async function waitForHealth(url, timeoutMs, anyStatus = false) {
|
|
5483
5897
|
const deadline = Date.now() + timeoutMs;
|
|
5484
5898
|
let last = "";
|
|
5485
5899
|
while (Date.now() < deadline) {
|
|
5486
5900
|
try {
|
|
5487
5901
|
const res = await fetch(url, { signal: AbortSignal.timeout(Math.min(5e3, timeoutMs)) });
|
|
5488
|
-
if (res.ok) return;
|
|
5902
|
+
if (anyStatus || res.ok) return;
|
|
5489
5903
|
last = `HTTP ${res.status}`;
|
|
5490
5904
|
} catch (e) {
|
|
5491
5905
|
last = e.message;
|
|
@@ -5520,6 +5934,13 @@ async function startStage(config = {}, opts = {}) {
|
|
|
5520
5934
|
stagePort = pickStagePort(config.portRange, (p) => free.has(p));
|
|
5521
5935
|
}
|
|
5522
5936
|
const sub = (s) => s != null && stagePort != null ? s.replace(/\$\{?STAGE_PORT\}?/g, String(stagePort)) : s;
|
|
5937
|
+
if (config.ensureEnv) {
|
|
5938
|
+
const target = (0, import_node_path4.join)(cwd, config.ensureEnv.target);
|
|
5939
|
+
const example = (0, import_node_path4.join)(cwd, config.ensureEnv.example);
|
|
5940
|
+
if (!(0, import_node_fs3.existsSync)(target) && (0, import_node_fs3.existsSync)(example)) (0, import_node_fs3.copyFileSync)(example, target);
|
|
5941
|
+
}
|
|
5942
|
+
const extraEnv = {};
|
|
5943
|
+
for (const [k, v] of Object.entries(config.env ?? {})) extraEnv[k] = sub(v) ?? v;
|
|
5523
5944
|
const up = sub(config.up.trim());
|
|
5524
5945
|
const child = (0, import_node_child_process5.spawn)(up, {
|
|
5525
5946
|
cwd,
|
|
@@ -5527,7 +5948,7 @@ async function startStage(config = {}, opts = {}) {
|
|
|
5527
5948
|
detached: true,
|
|
5528
5949
|
windowsHide: true,
|
|
5529
5950
|
stdio: "ignore",
|
|
5530
|
-
env: stagePort != null ? {
|
|
5951
|
+
env: { ...process.env, ...stagePort != null ? { STAGE_PORT: String(stagePort) } : {}, ...extraEnv }
|
|
5531
5952
|
});
|
|
5532
5953
|
const state = {
|
|
5533
5954
|
pid: child.pid ?? 0,
|
|
@@ -5539,13 +5960,13 @@ async function startStage(config = {}, opts = {}) {
|
|
|
5539
5960
|
};
|
|
5540
5961
|
(0, import_node_fs3.writeFileSync)(statePath, JSON.stringify(state, null, 2), "utf8");
|
|
5541
5962
|
try {
|
|
5542
|
-
if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4);
|
|
5963
|
+
if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4, config.healthAnyStatus);
|
|
5543
5964
|
} catch (e) {
|
|
5544
5965
|
await killTree(state.pid);
|
|
5545
5966
|
(0, import_node_fs3.rmSync)(statePath, { force: true });
|
|
5546
5967
|
throw e;
|
|
5547
5968
|
}
|
|
5548
|
-
const result = { ok: true, action: "start", statePath, pid: state.pid, message: `started stage pid ${state.pid}${stagePort != null ? ` on port ${stagePort}` : ""}` };
|
|
5969
|
+
const result = { ok: true, action: "start", statePath, pid: state.pid, port: stagePort, message: `started stage pid ${state.pid}${stagePort != null ? ` on port ${stagePort}` : ""}` };
|
|
5549
5970
|
opts.onReady?.(result);
|
|
5550
5971
|
child.unref();
|
|
5551
5972
|
return result;
|
|
@@ -5556,7 +5977,7 @@ async function runStage(config = {}, opts = {}) {
|
|
|
5556
5977
|
const cwd = opts.cwd ?? process.cwd();
|
|
5557
5978
|
const timeoutMs = opts.timeoutMs ?? 6e4;
|
|
5558
5979
|
await stopStage({ ...opts, cwd });
|
|
5559
|
-
await shell(config.build.trim(), cwd, timeoutMs);
|
|
5980
|
+
if (config.build?.trim()) await shell(config.build.trim(), cwd, timeoutMs);
|
|
5560
5981
|
const started = await startStage(config, { ...opts, cwd, timeoutMs });
|
|
5561
5982
|
return { ...started, action: "run", message: `built and ${started.message}` };
|
|
5562
5983
|
}
|
|
@@ -5652,14 +6073,56 @@ async function requireBranch(deps, branch) {
|
|
|
5652
6073
|
const current = clean(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
|
|
5653
6074
|
if (current !== branch) throw new Error(`must run from ${branch}, currently on ${current || "(unknown)"}`);
|
|
5654
6075
|
}
|
|
5655
|
-
|
|
6076
|
+
var HUB_REPO2 = "mutmutco/MMI-Hub";
|
|
6077
|
+
var CORRELATE_ATTEMPTS = 5;
|
|
6078
|
+
var CORRELATE_DELAY_MS = 1500;
|
|
6079
|
+
var CORRELATE_SKEW_SLACK_MS = 1e4;
|
|
6080
|
+
async function correlateTenantRun(deps, since) {
|
|
6081
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6082
|
+
const threshold = since - CORRELATE_SKEW_SLACK_MS;
|
|
6083
|
+
for (let attempt = 0; attempt < CORRELATE_ATTEMPTS; attempt++) {
|
|
6084
|
+
if (attempt > 0) await sleep(CORRELATE_DELAY_MS);
|
|
6085
|
+
let rows;
|
|
6086
|
+
try {
|
|
6087
|
+
const out = await deps.run("gh", [
|
|
6088
|
+
"run",
|
|
6089
|
+
"list",
|
|
6090
|
+
"--repo",
|
|
6091
|
+
HUB_REPO2,
|
|
6092
|
+
"--workflow",
|
|
6093
|
+
"tenant-deploy.yml",
|
|
6094
|
+
"--limit",
|
|
6095
|
+
"10",
|
|
6096
|
+
"--json",
|
|
6097
|
+
"databaseId,url,event,createdAt"
|
|
6098
|
+
]);
|
|
6099
|
+
rows = JSON.parse(out);
|
|
6100
|
+
} catch {
|
|
6101
|
+
continue;
|
|
6102
|
+
}
|
|
6103
|
+
const match = rows.filter((r) => r.event === "workflow_dispatch" && typeof r.databaseId === "number").map((r) => ({ row: r, created: Date.parse(r.createdAt ?? "") })).filter((c) => Number.isFinite(c.created) && c.created >= threshold).sort((a, b) => b.created - a.created)[0];
|
|
6104
|
+
if (match) return { runId: match.row.databaseId, runUrl: match.row.url };
|
|
6105
|
+
}
|
|
6106
|
+
return {};
|
|
6107
|
+
}
|
|
6108
|
+
async function watchTenantRun(deps, runId) {
|
|
6109
|
+
if (runId == null) return "pending";
|
|
6110
|
+
try {
|
|
6111
|
+
await deps.run("gh", ["run", "watch", String(runId), "--repo", HUB_REPO2, "--exit-status"]);
|
|
6112
|
+
return "success";
|
|
6113
|
+
} catch {
|
|
6114
|
+
return "failure";
|
|
6115
|
+
}
|
|
6116
|
+
}
|
|
6117
|
+
async function dispatchDeploy(deps, ctx, stage2, ref, model, watch) {
|
|
5656
6118
|
if (model === "tenant-container") {
|
|
6119
|
+
const since = (deps.now ?? Date.now)();
|
|
5657
6120
|
await deps.run("gh", [
|
|
5658
6121
|
"workflow",
|
|
5659
6122
|
"run",
|
|
5660
6123
|
"tenant-deploy.yml",
|
|
5661
6124
|
"--repo",
|
|
5662
|
-
|
|
6125
|
+
HUB_REPO2,
|
|
5663
6126
|
"-f",
|
|
5664
6127
|
`slug=${ctx.slug}`,
|
|
5665
6128
|
"-f",
|
|
@@ -5669,12 +6132,17 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model) {
|
|
|
5669
6132
|
"-f",
|
|
5670
6133
|
`stage=${stage2}`
|
|
5671
6134
|
]);
|
|
5672
|
-
|
|
6135
|
+
const { runId, runUrl } = await correlateTenantRun(deps, since);
|
|
6136
|
+
const deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
|
|
6137
|
+
return { note: `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`, runId, runUrl, deployStatus };
|
|
5673
6138
|
}
|
|
5674
6139
|
if (model === "hub-serverless") {
|
|
5675
|
-
return
|
|
6140
|
+
return {
|
|
6141
|
+
note: ref === "rc" ? "no manual dispatch: deploy.yml auto-fires on the rc push (rc stage)" : "no manual dispatch: deploy.yml + publish.yml auto-fire on the published Release (prod)",
|
|
6142
|
+
deployStatus: "pending"
|
|
6143
|
+
};
|
|
5676
6144
|
}
|
|
5677
|
-
return `no manual dispatch: ${model} repo deploys via its own push-triggered workflow
|
|
6145
|
+
return { note: `no manual dispatch: ${model} repo deploys via its own push-triggered workflow`, deployStatus: "pending" };
|
|
5678
6146
|
}
|
|
5679
6147
|
async function preflight(deps, ctx, stage2) {
|
|
5680
6148
|
let meta = null;
|
|
@@ -5693,7 +6161,8 @@ async function preflight(deps, ctx, stage2) {
|
|
|
5693
6161
|
await deps.runSelf(["secrets", "preflight", "--stage", stage2, "--repo", ctx.repo]);
|
|
5694
6162
|
return model;
|
|
5695
6163
|
}
|
|
5696
|
-
async function runTrainApply(command, deps) {
|
|
6164
|
+
async function runTrainApply(command, deps, options = {}) {
|
|
6165
|
+
const watch = options.watch ?? false;
|
|
5697
6166
|
const ctx = await buildTrainApplyContext(deps);
|
|
5698
6167
|
await requireCleanTree(deps);
|
|
5699
6168
|
await deps.run("git", ["fetch", "origin"]);
|
|
@@ -5713,8 +6182,8 @@ async function runTrainApply(command, deps) {
|
|
|
5713
6182
|
await deps.run("git", ["tag", tag2]);
|
|
5714
6183
|
await deps.run("git", ["push", "origin", "rc"]);
|
|
5715
6184
|
await deps.run("git", ["push", "origin", tag2]);
|
|
5716
|
-
const
|
|
5717
|
-
return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, dispatch:
|
|
6185
|
+
const d2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch);
|
|
6186
|
+
return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, deployStatus: d2.deployStatus };
|
|
5718
6187
|
}
|
|
5719
6188
|
await requireBranch(deps, "rc");
|
|
5720
6189
|
ensurePositiveCount(
|
|
@@ -5731,12 +6200,37 @@ async function runTrainApply(command, deps) {
|
|
|
5731
6200
|
await deps.run("git", ["push", "origin", "main"]);
|
|
5732
6201
|
await deps.run("git", ["push", "origin", tag]);
|
|
5733
6202
|
await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
|
|
5734
|
-
const
|
|
6203
|
+
const d = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch);
|
|
5735
6204
|
await deps.run("git", ["checkout", "development"]);
|
|
5736
6205
|
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
5737
6206
|
await deps.run("git", ["merge", "main", "--no-edit"]);
|
|
5738
6207
|
await deps.run("git", ["push", "origin", "development"]);
|
|
5739
|
-
return { ...ctx, command, stage: "main", ref: "main", tag, deployModel, dispatch };
|
|
6208
|
+
return { ...ctx, command, stage: "main", ref: "main", tag, deployModel, promoted: true, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, deployStatus: d.deployStatus };
|
|
6209
|
+
}
|
|
6210
|
+
async function runTenantRedeploy(deps, options) {
|
|
6211
|
+
const { stage: stage2 } = options;
|
|
6212
|
+
const ref = options.ref ?? stage2;
|
|
6213
|
+
const watch = options.watch ?? false;
|
|
6214
|
+
const repo = options.repo;
|
|
6215
|
+
const [owner, name] = repo.split("/");
|
|
6216
|
+
if (!owner || !name) throw new Error(`repo must be owner/name, got ${repo}`);
|
|
6217
|
+
const login = requireValue(clean(await deps.run("gh", ["api", "user", "--jq", ".login"])), "GitHub login");
|
|
6218
|
+
const verdict = await deps.trainAuthority(repo);
|
|
6219
|
+
if (!verdict.ok) throw new Error(`${commandAuthorityLabel(owner)}: train authority could not be verified (${verdict.error})`);
|
|
6220
|
+
if (!verdict.train) throw new Error(`${commandAuthorityLabel(owner)}: @${login} is ${verdict.role} \u2014 no train authority on ${repo}`);
|
|
6221
|
+
const ctx = { repo, owner, slug: name.toLowerCase(), login };
|
|
6222
|
+
let meta = null;
|
|
6223
|
+
try {
|
|
6224
|
+
meta = JSON.parse(await deps.runSelf(["project", "get", repo]));
|
|
6225
|
+
} catch {
|
|
6226
|
+
meta = null;
|
|
6227
|
+
}
|
|
6228
|
+
const deployModel = resolveDeployModel2(meta, repo);
|
|
6229
|
+
if (deployModel !== "tenant-container") {
|
|
6230
|
+
throw new Error(`${repo} is ${deployModel}, not tenant-container \u2014 there is no central tenant-deploy run to retry (its deploy fires from its own workflow)`);
|
|
6231
|
+
}
|
|
6232
|
+
const d = await dispatchDeploy(deps, ctx, stage2, ref, deployModel, watch);
|
|
6233
|
+
return { ...ctx, command: "tenant-redeploy", stage: stage2, ref, deployModel, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, deployStatus: d.deployStatus };
|
|
5740
6234
|
}
|
|
5741
6235
|
|
|
5742
6236
|
// src/port-registry.ts
|
|
@@ -6138,6 +6632,12 @@ async function rulesetDetails(deps, repo, list) {
|
|
|
6138
6632
|
}
|
|
6139
6633
|
return details;
|
|
6140
6634
|
}
|
|
6635
|
+
function repoRefsMatch(a, b) {
|
|
6636
|
+
return a.toLowerCase() === b.toLowerCase();
|
|
6637
|
+
}
|
|
6638
|
+
function projectRegistryIncludesRepo(projects, repo) {
|
|
6639
|
+
return projects.some((p) => (p.repos ?? []).some((r) => repoRefsMatch(r, repo)));
|
|
6640
|
+
}
|
|
6141
6641
|
function localRegistryCheck(deps, path2, predicate) {
|
|
6142
6642
|
const text = deps.readLocalFile?.(path2);
|
|
6143
6643
|
if (text == null) return null;
|
|
@@ -6287,7 +6787,7 @@ async function verifyBootstrap(repo, repoClass, deps) {
|
|
|
6287
6787
|
}
|
|
6288
6788
|
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));
|
|
6289
6789
|
if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
|
|
6290
|
-
const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects
|
|
6790
|
+
const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && projectRegistryIncludesRepo(json.projects, repo));
|
|
6291
6791
|
if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
|
|
6292
6792
|
const rulesetList = await restJson2(deps, `repos/${repo}/rulesets?includes_parents=true`, []);
|
|
6293
6793
|
const rulesets = await rulesetDetails(deps, repo, rulesetList);
|
|
@@ -6347,6 +6847,94 @@ function renderBootstrapVerifyReport(report) {
|
|
|
6347
6847
|
return lines.join("\n");
|
|
6348
6848
|
}
|
|
6349
6849
|
|
|
6850
|
+
// src/hub-auth.ts
|
|
6851
|
+
var import_node_crypto2 = require("node:crypto");
|
|
6852
|
+
var import_node_fs5 = require("node:fs");
|
|
6853
|
+
var import_node_path5 = require("node:path");
|
|
6854
|
+
var import_node_os2 = require("node:os");
|
|
6855
|
+
var REFRESH_WINDOW_MS = 10 * 60 * 1e3;
|
|
6856
|
+
var EXCHANGE_TIMEOUT_MS = 8e3;
|
|
6857
|
+
var EXCHANGE_ATTEMPTS = 2;
|
|
6858
|
+
function normalizeBaseUrl(baseUrl) {
|
|
6859
|
+
return baseUrl.replace(/\/$/, "");
|
|
6860
|
+
}
|
|
6861
|
+
function tokenFingerprint(token) {
|
|
6862
|
+
return (0, import_node_crypto2.createHash)("sha256").update(token).digest("hex");
|
|
6863
|
+
}
|
|
6864
|
+
function defaultHubSessionCachePath(env = process.env) {
|
|
6865
|
+
if (env.MMI_HUB_SESSION_CACHE) return env.MMI_HUB_SESSION_CACHE;
|
|
6866
|
+
if (process.platform === "win32") {
|
|
6867
|
+
const base2 = env.LOCALAPPDATA || (0, import_node_path5.join)((0, import_node_os2.homedir)(), "AppData", "Local");
|
|
6868
|
+
return (0, import_node_path5.join)(base2, "MMI Future", "mmi-cli", "hub-session.json");
|
|
6869
|
+
}
|
|
6870
|
+
const base = env.XDG_STATE_HOME || (0, import_node_path5.join)((0, import_node_os2.homedir)(), ".mmi");
|
|
6871
|
+
return (0, import_node_path5.join)(base, "mmi-cli", "hub-session.json");
|
|
6872
|
+
}
|
|
6873
|
+
function readCache(path2, apiUrl, now, githubTokenFingerprint) {
|
|
6874
|
+
try {
|
|
6875
|
+
const session = JSON.parse((0, import_node_fs5.readFileSync)(path2, "utf8"));
|
|
6876
|
+
if (!session.token || !session.expiresAt || session.apiUrl !== apiUrl) return null;
|
|
6877
|
+
if (session.githubTokenFingerprint !== githubTokenFingerprint) return null;
|
|
6878
|
+
if (new Date(session.expiresAt).getTime() <= now.getTime() + REFRESH_WINDOW_MS) return null;
|
|
6879
|
+
return session;
|
|
6880
|
+
} catch {
|
|
6881
|
+
return null;
|
|
6882
|
+
}
|
|
6883
|
+
}
|
|
6884
|
+
function writeCache(path2, session) {
|
|
6885
|
+
(0, import_node_fs5.mkdirSync)((0, import_node_path5.dirname)(path2), { recursive: true });
|
|
6886
|
+
const tmp = `${path2}.${process.pid}.${Date.now()}.tmp`;
|
|
6887
|
+
(0, import_node_fs5.writeFileSync)(tmp, JSON.stringify(session, null, 2) + "\n", { encoding: "utf8", mode: 384 });
|
|
6888
|
+
try {
|
|
6889
|
+
(0, import_node_fs5.chmodSync)(tmp, 384);
|
|
6890
|
+
} catch {
|
|
6891
|
+
}
|
|
6892
|
+
(0, import_node_fs5.renameSync)(tmp, path2);
|
|
6893
|
+
try {
|
|
6894
|
+
(0, import_node_fs5.chmodSync)(path2, 384);
|
|
6895
|
+
} catch {
|
|
6896
|
+
}
|
|
6897
|
+
}
|
|
6898
|
+
async function hubAuthSession(deps) {
|
|
6899
|
+
if (!deps.baseUrl) return void 0;
|
|
6900
|
+
const apiUrl = normalizeBaseUrl(deps.baseUrl);
|
|
6901
|
+
const now = deps.now?.() ?? /* @__PURE__ */ new Date();
|
|
6902
|
+
const cachePath = deps.cachePath ?? defaultHubSessionCachePath();
|
|
6903
|
+
const ghToken = await deps.githubToken();
|
|
6904
|
+
if (!ghToken) return void 0;
|
|
6905
|
+
const githubTokenFingerprint = tokenFingerprint(ghToken);
|
|
6906
|
+
const cached = readCache(cachePath, apiUrl, now, githubTokenFingerprint);
|
|
6907
|
+
if (cached) return cached;
|
|
6908
|
+
try {
|
|
6909
|
+
const res = await fetchWithRetry(
|
|
6910
|
+
deps.fetch ?? fetch,
|
|
6911
|
+
`${apiUrl}/auth/session`,
|
|
6912
|
+
{ method: "POST", headers: { Authorization: `Bearer ${ghToken}` } },
|
|
6913
|
+
{ attempts: EXCHANGE_ATTEMPTS, timeoutMs: EXCHANGE_TIMEOUT_MS }
|
|
6914
|
+
);
|
|
6915
|
+
if (!res.ok) return void 0;
|
|
6916
|
+
const body = await res.json();
|
|
6917
|
+
if (!body.token || !body.expiresAt) return void 0;
|
|
6918
|
+
const session = {
|
|
6919
|
+
token: body.token,
|
|
6920
|
+
expiresAt: body.expiresAt,
|
|
6921
|
+
login: typeof body.login === "string" ? body.login : void 0,
|
|
6922
|
+
apiUrl,
|
|
6923
|
+
githubTokenFingerprint
|
|
6924
|
+
};
|
|
6925
|
+
try {
|
|
6926
|
+
writeCache(cachePath, session);
|
|
6927
|
+
} catch {
|
|
6928
|
+
}
|
|
6929
|
+
return session;
|
|
6930
|
+
} catch {
|
|
6931
|
+
return void 0;
|
|
6932
|
+
}
|
|
6933
|
+
}
|
|
6934
|
+
async function hubAuthToken(deps) {
|
|
6935
|
+
return (await hubAuthSession(deps))?.token;
|
|
6936
|
+
}
|
|
6937
|
+
|
|
6350
6938
|
// src/bootstrap-apply.ts
|
|
6351
6939
|
function parseOwnerRepo(repo) {
|
|
6352
6940
|
const trimmed = repo.trim();
|
|
@@ -6476,16 +7064,21 @@ var PROJECTS_ENVELOPE_KEY = "projects";
|
|
|
6476
7064
|
|
|
6477
7065
|
// src/registry-client.ts
|
|
6478
7066
|
var DEFAULT_TIMEOUT_MS2 = 8e3;
|
|
7067
|
+
var RETRY_ATTEMPTS = 3;
|
|
7068
|
+
function retriedFetch(deps, url, init) {
|
|
7069
|
+
return fetchWithRetry(deps.fetch ?? fetch, url, init, {
|
|
7070
|
+
attempts: RETRY_ATTEMPTS,
|
|
7071
|
+
timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2
|
|
7072
|
+
});
|
|
7073
|
+
}
|
|
6479
7074
|
async function fetchTrainAuthority(repo, deps) {
|
|
6480
7075
|
if (!deps.baseUrl) return { ok: false, error: "Hub API URL not configured" };
|
|
6481
7076
|
const token = await deps.token();
|
|
6482
|
-
if (!token) return { ok: false, error: "no
|
|
6483
|
-
const doFetch = deps.fetch ?? fetch;
|
|
7077
|
+
if (!token) return { ok: false, error: "no Hub session token (run `gh auth login`)" };
|
|
6484
7078
|
try {
|
|
6485
|
-
const res = await
|
|
7079
|
+
const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}/train-authority?repo=${encodeURIComponent(repo)}`, {
|
|
6486
7080
|
method: "GET",
|
|
6487
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
6488
|
-
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
|
|
7081
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
6489
7082
|
});
|
|
6490
7083
|
if (!res.ok) return { ok: false, error: `train-authority HTTP ${res.status}` };
|
|
6491
7084
|
const body = await res.json();
|
|
@@ -6499,12 +7092,10 @@ async function fetchProjectsList(deps) {
|
|
|
6499
7092
|
if (!deps.baseUrl) return null;
|
|
6500
7093
|
const token = await deps.token();
|
|
6501
7094
|
if (!token) return null;
|
|
6502
|
-
const doFetch = deps.fetch ?? fetch;
|
|
6503
7095
|
try {
|
|
6504
|
-
const res = await
|
|
7096
|
+
const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}${PROJECTS_LIST_PATH}`, {
|
|
6505
7097
|
method: "GET",
|
|
6506
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
6507
|
-
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
|
|
7098
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
6508
7099
|
});
|
|
6509
7100
|
if (!res.ok) return null;
|
|
6510
7101
|
const body = await res.json();
|
|
@@ -6522,13 +7113,11 @@ async function fetchProjectBySlugChecked(slug, deps) {
|
|
|
6522
7113
|
if (!deps.baseUrl) return { ok: false, error: "no Hub API URL (set MMI_HUB_URL or use a current MMI CLI/plugin build)" };
|
|
6523
7114
|
if (!slug) return { ok: false, error: "no slug" };
|
|
6524
7115
|
const token = await deps.token();
|
|
6525
|
-
if (!token) return { ok: false, error: "no
|
|
6526
|
-
const doFetch = deps.fetch ?? fetch;
|
|
7116
|
+
if (!token) return { ok: false, error: "no Hub session token (run `gh auth login`)" };
|
|
6527
7117
|
try {
|
|
6528
|
-
const res = await
|
|
7118
|
+
const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}`, {
|
|
6529
7119
|
method: "GET",
|
|
6530
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
6531
|
-
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
|
|
7120
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
6532
7121
|
});
|
|
6533
7122
|
if (res.status === 404) return { ok: true, project: null };
|
|
6534
7123
|
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
|
|
@@ -6545,12 +7134,10 @@ async function fetchDeployStatusBySlug(slug, deps) {
|
|
|
6545
7134
|
if (!deps.baseUrl || !slug) return null;
|
|
6546
7135
|
const token = await deps.token();
|
|
6547
7136
|
if (!token) return null;
|
|
6548
|
-
const doFetch = deps.fetch ?? fetch;
|
|
6549
7137
|
try {
|
|
6550
|
-
const res = await
|
|
7138
|
+
const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}/deploy-status`, {
|
|
6551
7139
|
method: "GET",
|
|
6552
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
6553
|
-
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
|
|
7140
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
6554
7141
|
});
|
|
6555
7142
|
if (!res.ok) return null;
|
|
6556
7143
|
const body = await res.json();
|
|
@@ -6564,12 +7151,10 @@ async function fetchOrgConfig(deps) {
|
|
|
6564
7151
|
if (!deps.baseUrl) return null;
|
|
6565
7152
|
const token = await deps.token();
|
|
6566
7153
|
if (!token) return null;
|
|
6567
|
-
const doFetch = deps.fetch ?? fetch;
|
|
6568
7154
|
try {
|
|
6569
|
-
const res = await
|
|
7155
|
+
const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}${ORG_CONFIG_PATH}`, {
|
|
6570
7156
|
method: "GET",
|
|
6571
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
6572
|
-
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
|
|
7157
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
6573
7158
|
});
|
|
6574
7159
|
if (!res.ok) return null;
|
|
6575
7160
|
return await res.json();
|
|
@@ -6580,14 +7165,12 @@ async function fetchOrgConfig(deps) {
|
|
|
6580
7165
|
async function postJson(pathSuffix, payload, deps, method = "POST") {
|
|
6581
7166
|
if (!deps.baseUrl) return { ok: false, status: 0, body: null, error: "no Hub API URL (set MMI_HUB_URL or use a current MMI CLI/plugin build)" };
|
|
6582
7167
|
const token = await deps.token();
|
|
6583
|
-
if (!token) return { ok: false, status: 0, body: null, error: "no
|
|
6584
|
-
const doFetch = deps.fetch ?? fetch;
|
|
7168
|
+
if (!token) return { ok: false, status: 0, body: null, error: "no Hub session token (run `gh auth login`)" };
|
|
6585
7169
|
try {
|
|
6586
|
-
const res = await
|
|
7170
|
+
const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}${pathSuffix}`, {
|
|
6587
7171
|
method,
|
|
6588
7172
|
headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
6589
|
-
body: JSON.stringify(payload)
|
|
6590
|
-
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
|
|
7173
|
+
body: JSON.stringify(payload)
|
|
6591
7174
|
});
|
|
6592
7175
|
let body = null;
|
|
6593
7176
|
try {
|
|
@@ -6658,9 +7241,12 @@ function appAttestationOf(meta) {
|
|
|
6658
7241
|
function attestedLine(att) {
|
|
6659
7242
|
return `App-owned readiness attested by @${att.by} on ${att.at.slice(0, 10)} \u2014 the static checklist is cleared (the doctor reads no product repo files); re-run \`mmi-cli project attest\` after app-owned structural changes.`;
|
|
6660
7243
|
}
|
|
7244
|
+
var CONTRACT_UNDECLARED_LINE = "No runtime secrets declared \u2014 declare requiredRuntimeSecrets (a per-stage name map) in the registry META, or attest the app needs none with an explicit empty stage map ({ dev: [], rc: [], main: [] }).";
|
|
6661
7245
|
function appGapsFor(meta, model, slug, projectType) {
|
|
6662
7246
|
const attested = appAttestationOf(meta);
|
|
6663
|
-
|
|
7247
|
+
const isTenantWeb = !(projectType === "content" || model === "content") && projectType !== "desktop-game" && !(projectType === "non-deployable" || model === "none") && model !== "hub-serverless" && model !== "serverless";
|
|
7248
|
+
const contractUndeclared = isTenantWeb && Boolean(meta) && !hasRuntimeSecretContract(meta?.requiredRuntimeSecrets);
|
|
7249
|
+
if (attested) return contractUndeclared ? [attestedLine(attested), CONTRACT_UNDECLARED_LINE] : [attestedLine(attested)];
|
|
6664
7250
|
if (projectType === "content" || model === "content") return ["Content/KB repo: keep app-owned work to docs/content changes; release train does not apply."];
|
|
6665
7251
|
if (projectType === "desktop-game") {
|
|
6666
7252
|
return [
|
|
@@ -6692,8 +7278,8 @@ function appGapsFor(meta, model, slug, projectType) {
|
|
|
6692
7278
|
"Make app config fail clearly for missing required env in prod/rc instead of relying on hidden defaults.",
|
|
6693
7279
|
"Keep app-owned README.md and architecture.md aligned with v2 central deploy/secrets reality."
|
|
6694
7280
|
];
|
|
6695
|
-
if (
|
|
6696
|
-
gaps.unshift(
|
|
7281
|
+
if (contractUndeclared) {
|
|
7282
|
+
gaps.unshift(CONTRACT_UNDECLARED_LINE);
|
|
6697
7283
|
}
|
|
6698
7284
|
if (slug === "mmi-katip") {
|
|
6699
7285
|
gaps.push("Katip-specific app plan: declare Google Workspace service-account requirements, use the service account numeric OAuth2 client ID for DWD, remove prod-hidden impersonation defaults, and make non-critical Google Workspace failures degrade instead of crash-looping.");
|
|
@@ -6773,7 +7359,29 @@ async function runV2Heal(repoOrSlug, opts, deps) {
|
|
|
6773
7359
|
async function buildV2Doctor(repoOrSlug, deps) {
|
|
6774
7360
|
const slug = slugOfRepo(repoOrSlug);
|
|
6775
7361
|
const repo = repoFrom(repoOrSlug, slug);
|
|
6776
|
-
const
|
|
7362
|
+
const read = await deps.getProject(slug);
|
|
7363
|
+
if (!read.ok) {
|
|
7364
|
+
const degradedSecrets = {
|
|
7365
|
+
dev: { required: [], present: [], missing: [] },
|
|
7366
|
+
rc: { required: [], present: [], missing: [] },
|
|
7367
|
+
main: { required: [], present: [], missing: [] }
|
|
7368
|
+
};
|
|
7369
|
+
const degradedStage = {
|
|
7370
|
+
dev: { ok: false, required: false },
|
|
7371
|
+
rc: { ok: false, required: false },
|
|
7372
|
+
main: { ok: false, required: false }
|
|
7373
|
+
};
|
|
7374
|
+
return {
|
|
7375
|
+
ok: false,
|
|
7376
|
+
repo,
|
|
7377
|
+
slug,
|
|
7378
|
+
registryError: read.error,
|
|
7379
|
+
hubOwned: { meta: { ok: false, missing: [] }, deployCoords: degradedStage, deployState: degradedStage, secrets: degradedSecrets },
|
|
7380
|
+
autoHealAvailable: [],
|
|
7381
|
+
appOwnedGaps: [`Hub registry read failed (${read.error}) \u2014 diagnosis degraded; nothing is known about META, coords, or gaps. Likely transient (cold start, network, or auth blip): retry \`mmi-cli project doctor\` shortly.`]
|
|
7382
|
+
};
|
|
7383
|
+
}
|
|
7384
|
+
const meta = read.project;
|
|
6777
7385
|
const projectType = resolveProjectType(meta, repo);
|
|
6778
7386
|
const model = resolveDeployModel(meta, repo);
|
|
6779
7387
|
const autoHeal = buildV2HealPatch(repo, meta);
|
|
@@ -6879,6 +7487,30 @@ ${section}`.trim();
|
|
|
6879
7487
|
// src/project-set.ts
|
|
6880
7488
|
var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "edgeDomains", "requiredGcpApis", "publishRequired"];
|
|
6881
7489
|
var UNSET_KEY_SET = new Set(UNSET_KEYS);
|
|
7490
|
+
var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
|
|
7491
|
+
function parseRuntimeSecretsVar(raw) {
|
|
7492
|
+
let parsed;
|
|
7493
|
+
try {
|
|
7494
|
+
parsed = JSON.parse(raw);
|
|
7495
|
+
} catch {
|
|
7496
|
+
throw new Error('project set: requiredRuntimeSecrets must be JSON, e.g. {"dev":["KEY"],"rc":["KEY"],"main":["KEY"]}');
|
|
7497
|
+
}
|
|
7498
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
7499
|
+
throw new Error("project set: requiredRuntimeSecrets must be a stage map (a flat array is not box-loadable)");
|
|
7500
|
+
}
|
|
7501
|
+
const map = parsed;
|
|
7502
|
+
const out = {};
|
|
7503
|
+
for (const [stage2, names] of Object.entries(map)) {
|
|
7504
|
+
if (!RUNTIME_SECRET_STAGES.includes(stage2)) {
|
|
7505
|
+
throw new Error(`project set: requiredRuntimeSecrets stage "${stage2}" \u2014 expected only ${RUNTIME_SECRET_STAGES.join("/")}`);
|
|
7506
|
+
}
|
|
7507
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string" || !n.trim())) {
|
|
7508
|
+
throw new Error(`project set: requiredRuntimeSecrets.${stage2} must be an array of non-empty secret names`);
|
|
7509
|
+
}
|
|
7510
|
+
out[stage2] = names;
|
|
7511
|
+
}
|
|
7512
|
+
return out;
|
|
7513
|
+
}
|
|
6882
7514
|
function buildProjectSetPatch(input) {
|
|
6883
7515
|
const patch = {};
|
|
6884
7516
|
if (input.class) {
|
|
@@ -6908,6 +7540,8 @@ function buildProjectSetPatch(input) {
|
|
|
6908
7540
|
const n = Number(raw);
|
|
6909
7541
|
if (!Number.isFinite(n)) throw new Error("project set: projectNumber must be numeric");
|
|
6910
7542
|
patch[key] = n;
|
|
7543
|
+
} else if (key === "requiredRuntimeSecrets") {
|
|
7544
|
+
patch[key] = parseRuntimeSecretsVar(raw);
|
|
6911
7545
|
} else {
|
|
6912
7546
|
patch[key] = raw;
|
|
6913
7547
|
}
|
|
@@ -6967,10 +7601,91 @@ function parseKbTree(stdout, prefix) {
|
|
|
6967
7601
|
}
|
|
6968
7602
|
|
|
6969
7603
|
// src/plan.ts
|
|
6970
|
-
var
|
|
7604
|
+
var import_node_path6 = require("node:path");
|
|
7605
|
+
|
|
7606
|
+
// src/frontmatter.ts
|
|
7607
|
+
function splitFrontmatter(content) {
|
|
7608
|
+
const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(content);
|
|
7609
|
+
if (!match) return { entries: [], body: content };
|
|
7610
|
+
return { entries: match[1].split(/\r?\n/).filter((line) => line.trim()), body: content.slice(match[0].length) };
|
|
7611
|
+
}
|
|
7612
|
+
function entryKeyValue(line) {
|
|
7613
|
+
const i = line.indexOf(":");
|
|
7614
|
+
if (i < 0) return null;
|
|
7615
|
+
return { key: line.slice(0, i).trim().toLowerCase(), value: line.slice(i + 1).trim() };
|
|
7616
|
+
}
|
|
7617
|
+
function frontmatterValue(content, key) {
|
|
7618
|
+
const want = key.trim().toLowerCase();
|
|
7619
|
+
for (const line of splitFrontmatter(content).entries) {
|
|
7620
|
+
const kv = entryKeyValue(line);
|
|
7621
|
+
if (kv && kv.key === want) return kv.value || void 0;
|
|
7622
|
+
}
|
|
7623
|
+
return void 0;
|
|
7624
|
+
}
|
|
7625
|
+
function frontmatterList(content, key) {
|
|
7626
|
+
const raw = frontmatterValue(content, key);
|
|
7627
|
+
if (!raw) return [];
|
|
7628
|
+
return raw.replace(/^\[/, "").replace(/\]$/, "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
7629
|
+
}
|
|
7630
|
+
function extractPlanMeta(content) {
|
|
7631
|
+
const meta = {};
|
|
7632
|
+
const status = frontmatterValue(content, "status");
|
|
7633
|
+
if (status) meta.status = status;
|
|
7634
|
+
const tags = frontmatterList(content, "topic-tags");
|
|
7635
|
+
if (tags.length) meta.topicTags = tags;
|
|
7636
|
+
const title = frontmatterValue(content, "title");
|
|
7637
|
+
if (title) meta.title = title;
|
|
7638
|
+
const supersedes = frontmatterValue(content, "supersedes");
|
|
7639
|
+
if (supersedes) meta.supersedes = supersedes;
|
|
7640
|
+
return meta;
|
|
7641
|
+
}
|
|
7642
|
+
|
|
7643
|
+
// src/plan-relevance.ts
|
|
7644
|
+
var STOP = /* @__PURE__ */ new Set(["the", "and", "for", "with", "plan", "issue", "fix", "feat", "add", "wip", "src", "tsx", "mjs"]);
|
|
7645
|
+
function tokenize(s) {
|
|
7646
|
+
return (s ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 3 && !STOP.has(t));
|
|
7647
|
+
}
|
|
7648
|
+
var SUPPRESSED = /* @__PURE__ */ new Set(["superseded", "graduated"]);
|
|
7649
|
+
function signalTokens(s) {
|
|
7650
|
+
const all = [
|
|
7651
|
+
...tokenize(s.branch),
|
|
7652
|
+
...tokenize(s.issueTitle),
|
|
7653
|
+
...(s.issueLabels ?? []).flatMap(tokenize),
|
|
7654
|
+
...(s.changedFiles ?? []).flatMap(tokenize)
|
|
7655
|
+
];
|
|
7656
|
+
return new Set(all);
|
|
7657
|
+
}
|
|
7658
|
+
function rankPlansByRelevance(plans, signals, opts = {}) {
|
|
7659
|
+
const wanted = signalTokens(signals);
|
|
7660
|
+
const eligible = opts.includeAll ? plans : plans.filter((p) => !p.status || !SUPPRESSED.has(p.status.toLowerCase()));
|
|
7661
|
+
const ranked = eligible.map((plan2) => {
|
|
7662
|
+
const tagTokens = new Set((plan2.topicTags ?? []).flatMap(tokenize));
|
|
7663
|
+
const titleTokens = new Set(tokenize(plan2.title));
|
|
7664
|
+
const slugTokens = new Set(tokenize(plan2.slug));
|
|
7665
|
+
const matched = /* @__PURE__ */ new Set();
|
|
7666
|
+
let score = 0;
|
|
7667
|
+
for (const t of wanted) {
|
|
7668
|
+
if (tagTokens.has(t)) {
|
|
7669
|
+
score += 3;
|
|
7670
|
+
matched.add(t);
|
|
7671
|
+
} else if (titleTokens.has(t)) {
|
|
7672
|
+
score += 2;
|
|
7673
|
+
matched.add(t);
|
|
7674
|
+
} else if (slugTokens.has(t)) {
|
|
7675
|
+
score += 1;
|
|
7676
|
+
matched.add(t);
|
|
7677
|
+
}
|
|
7678
|
+
}
|
|
7679
|
+
return { plan: plan2, score, matched: [...matched] };
|
|
7680
|
+
});
|
|
7681
|
+
ranked.sort((a, b) => b.score - a.score || (b.plan.updatedAt ?? "").localeCompare(a.plan.updatedAt ?? ""));
|
|
7682
|
+
return ranked;
|
|
7683
|
+
}
|
|
7684
|
+
|
|
7685
|
+
// src/plan.ts
|
|
6971
7686
|
var PLANS_DIR = "plans";
|
|
6972
|
-
var META_FILE = (0,
|
|
6973
|
-
var planPath = (slug) => (0,
|
|
7687
|
+
var META_FILE = (0, import_node_path6.join)(PLANS_DIR, ".plan-meta.json");
|
|
7688
|
+
var planPath = (slug) => (0, import_node_path6.join)(PLANS_DIR, `${slug}.md`);
|
|
6974
7689
|
var metaKey = (project2, slug) => `${project2}/${slug}`;
|
|
6975
7690
|
function parseMeta(raw) {
|
|
6976
7691
|
if (!raw) return {};
|
|
@@ -6999,12 +7714,7 @@ function formatPlanList(plans) {
|
|
|
6999
7714
|
return plans.map((p) => `${p.slug} \xB7 ${p.updatedAt ?? "-"} \xB7 ${p.project}`).join("\n");
|
|
7000
7715
|
}
|
|
7001
7716
|
var TIMEOUT_MS = 8e3;
|
|
7002
|
-
var GRADUATION_KEYS = /* @__PURE__ */ new Set(["northstar-graduation", "privacy", "merged-pr"]);
|
|
7003
|
-
function splitFrontmatter(content) {
|
|
7004
|
-
const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(content);
|
|
7005
|
-
if (!match) return { entries: [], body: content };
|
|
7006
|
-
return { entries: match[1].split(/\r?\n/).filter((line) => line.trim()), body: content.slice(match[0].length) };
|
|
7007
|
-
}
|
|
7717
|
+
var GRADUATION_KEYS = /* @__PURE__ */ new Set(["northstar-graduation", "privacy", "merged-pr", "status"]);
|
|
7008
7718
|
function markPlanGraduated(content, opts) {
|
|
7009
7719
|
const { entries, body } = splitFrontmatter(normalizeEol(content));
|
|
7010
7720
|
const preserved = entries.filter((line) => {
|
|
@@ -7014,6 +7724,7 @@ function markPlanGraduated(content, opts) {
|
|
|
7014
7724
|
const next = [
|
|
7015
7725
|
...preserved,
|
|
7016
7726
|
"northstar-graduation: built-and-merged",
|
|
7727
|
+
"status: graduated",
|
|
7017
7728
|
"privacy: org",
|
|
7018
7729
|
`merged-pr: ${opts.mergedPr}`
|
|
7019
7730
|
];
|
|
@@ -7033,6 +7744,8 @@ async function planPush(deps, slug, opts = {}) {
|
|
|
7033
7744
|
const meta = parseMeta(deps.readMetaRaw());
|
|
7034
7745
|
const entry = meta[metaKey(project2, slug)];
|
|
7035
7746
|
const body = { project: project2, slug, content };
|
|
7747
|
+
const frontmatterMeta = extractPlanMeta(content);
|
|
7748
|
+
if (Object.keys(frontmatterMeta).length) body.meta = frontmatterMeta;
|
|
7036
7749
|
if (opts.force) body.force = true;
|
|
7037
7750
|
else if (entry?.etag) body.baseEtag = entry.etag;
|
|
7038
7751
|
const res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
|
|
@@ -7086,35 +7799,59 @@ async function planPull(deps, slug, opts = {}) {
|
|
|
7086
7799
|
deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
|
|
7087
7800
|
return true;
|
|
7088
7801
|
}
|
|
7802
|
+
async function fetchPlanList(deps, project2) {
|
|
7803
|
+
const qs = project2 ? `?${new URLSearchParams({ project: project2 }).toString()}` : "";
|
|
7804
|
+
const res = await deps.fetch(`${deps.apiUrl}/plan/list${qs}`, {
|
|
7805
|
+
method: "GET",
|
|
7806
|
+
headers: await deps.headers(),
|
|
7807
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
7808
|
+
});
|
|
7809
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
7810
|
+
const { plans } = await res.json();
|
|
7811
|
+
return plans ?? [];
|
|
7812
|
+
}
|
|
7089
7813
|
async function planList(deps, opts = {}) {
|
|
7090
7814
|
const project2 = opts.project ?? (opts.quiet ? await deps.project() : void 0);
|
|
7091
|
-
|
|
7092
|
-
let res;
|
|
7815
|
+
let plans;
|
|
7093
7816
|
try {
|
|
7094
|
-
|
|
7095
|
-
method: "GET",
|
|
7096
|
-
headers: await deps.headers(),
|
|
7097
|
-
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
7098
|
-
});
|
|
7817
|
+
plans = await fetchPlanList(deps, project2);
|
|
7099
7818
|
} catch (e) {
|
|
7100
7819
|
if (!opts.quiet) deps.err(`plan list: ${e.message}`);
|
|
7101
7820
|
return;
|
|
7102
7821
|
}
|
|
7103
|
-
if (
|
|
7104
|
-
if (!opts.quiet) deps.err(`plan list failed: HTTP ${res.status}`);
|
|
7105
|
-
return;
|
|
7106
|
-
}
|
|
7107
|
-
const { plans } = await res.json();
|
|
7108
|
-
if (opts.json) {
|
|
7109
|
-
deps.log(JSON.stringify(plans));
|
|
7110
|
-
return;
|
|
7111
|
-
}
|
|
7822
|
+
if (opts.json) return deps.log(JSON.stringify(plans));
|
|
7112
7823
|
if (!plans.length) {
|
|
7113
7824
|
if (!opts.quiet) deps.log("no plans");
|
|
7114
7825
|
return;
|
|
7115
7826
|
}
|
|
7116
7827
|
deps.log(formatPlanList(plans));
|
|
7117
7828
|
}
|
|
7829
|
+
function formatRelevant(ranked) {
|
|
7830
|
+
return ranked.map((r) => {
|
|
7831
|
+
const why = r.matched.length ? `matches ${r.matched.join(", ")}` : "recent";
|
|
7832
|
+
return `${r.plan.slug} \xB7 ${why} \xB7 mmi-cli northstar pull ${r.plan.slug}`;
|
|
7833
|
+
}).join("\n");
|
|
7834
|
+
}
|
|
7835
|
+
async function relevantPlans(deps, signals, opts = {}) {
|
|
7836
|
+
const project2 = opts.project ?? await deps.project();
|
|
7837
|
+
let plans;
|
|
7838
|
+
try {
|
|
7839
|
+
const scoped = await fetchPlanList(deps, project2);
|
|
7840
|
+
const unprojected = project2 === "-" ? [] : await fetchPlanList(deps, "-").catch(() => []);
|
|
7841
|
+
const seen = new Set(scoped.map((p) => `${p.project}/${p.slug}`));
|
|
7842
|
+
plans = [...scoped, ...unprojected.filter((p) => !seen.has(`${p.project}/${p.slug}`))];
|
|
7843
|
+
} catch (e) {
|
|
7844
|
+
deps.err(`northstar relevant: ${e.message}`);
|
|
7845
|
+
return;
|
|
7846
|
+
}
|
|
7847
|
+
if (!plans.length) return deps.log("no North Stars for this repo yet");
|
|
7848
|
+
const ranked = rankPlansByRelevance(plans, signals, { includeAll: opts.includeAll });
|
|
7849
|
+
const top = (opts.includeAll ? ranked : ranked.filter((r) => r.score > 0)).slice(0, opts.limit ?? 5);
|
|
7850
|
+
if (!top.length) {
|
|
7851
|
+
return deps.log(`no task-relevant North Stars among ${plans.length} for this repo \u2014 \`mmi-cli northstar relevant --all\` lists recent ones`);
|
|
7852
|
+
}
|
|
7853
|
+
deps.log(formatRelevant(top));
|
|
7854
|
+
}
|
|
7118
7855
|
async function planDelete(deps, slug, opts = {}) {
|
|
7119
7856
|
const project2 = opts.project ?? await deps.project();
|
|
7120
7857
|
const res = await deps.fetch(`${deps.apiUrl}/plan/delete`, {
|
|
@@ -7564,14 +8301,15 @@ async function awsCallerArn() {
|
|
|
7564
8301
|
return void 0;
|
|
7565
8302
|
}
|
|
7566
8303
|
}
|
|
7567
|
-
async function
|
|
7568
|
-
const
|
|
8304
|
+
async function hubHeaders(extra = {}) {
|
|
8305
|
+
const cfg = await loadConfig();
|
|
8306
|
+
const t = await hubAuthToken({ baseUrl: cfg.sagaApiUrl ?? defaultHubUrl(), githubToken });
|
|
7569
8307
|
return t ? { ...extra, Authorization: `Bearer ${t}` } : extra;
|
|
7570
8308
|
}
|
|
7571
8309
|
async function loadConfig() {
|
|
7572
8310
|
let file = {};
|
|
7573
8311
|
try {
|
|
7574
|
-
file = JSON.parse(await (0,
|
|
8312
|
+
file = JSON.parse(await (0, import_promises2.readFile)(".mmi/config.json", "utf8"));
|
|
7575
8313
|
} catch {
|
|
7576
8314
|
file = {};
|
|
7577
8315
|
}
|
|
@@ -7579,12 +8317,16 @@ async function loadConfig() {
|
|
|
7579
8317
|
return file;
|
|
7580
8318
|
}
|
|
7581
8319
|
var discoveredConfig = null;
|
|
8320
|
+
function registryDegradeError(error) {
|
|
8321
|
+
return new Error(`Hub registry read failed (${error}) \u2014 board coords could not be discovered; likely transient (cold start, network, or auth blip) \u2014 retry shortly`);
|
|
8322
|
+
}
|
|
7582
8323
|
async function loadConfigOrDiscover() {
|
|
7583
8324
|
if (discoveredConfig) return discoveredConfig;
|
|
7584
8325
|
const floor = await loadConfig();
|
|
7585
8326
|
if (!floor.sagaApiUrl) return stripMutableBoardConfig(floor);
|
|
7586
|
-
const
|
|
7587
|
-
|
|
8327
|
+
const read = await fetchProjectBySlugChecked(await repoSlug(), registryClientDeps(floor));
|
|
8328
|
+
if (!read.ok) throw registryDegradeError(read.error);
|
|
8329
|
+
discoveredConfig = read.project ? boardConfigFromProject(read.project, floor) : stripMutableBoardConfig(floor);
|
|
7588
8330
|
return discoveredConfig;
|
|
7589
8331
|
}
|
|
7590
8332
|
async function loadConfigForRepo(targetRepo2) {
|
|
@@ -7592,9 +8334,10 @@ async function loadConfigForRepo(targetRepo2) {
|
|
|
7592
8334
|
const cwdRepo = await resolveRepo();
|
|
7593
8335
|
if (cwdRepo && targetRepo2.toLowerCase() === cwdRepo.toLowerCase()) return loadConfigOrDiscover();
|
|
7594
8336
|
const floor = await loadConfig();
|
|
7595
|
-
const
|
|
7596
|
-
if (!
|
|
7597
|
-
return
|
|
8337
|
+
const read = await fetchProjectBySlugChecked(slugOf(targetRepo2), registryClientDeps(floor));
|
|
8338
|
+
if (!read.ok) throw registryDegradeError(read.error);
|
|
8339
|
+
if (!read.project) return stripMutableBoardConfig(floor);
|
|
8340
|
+
return boardConfigFromProject(read.project, floor);
|
|
7598
8341
|
}
|
|
7599
8342
|
function repoFromSelector(selector) {
|
|
7600
8343
|
const trimmed = selector.trim();
|
|
@@ -7624,21 +8367,21 @@ function sessionDeps() {
|
|
|
7624
8367
|
env: process.env,
|
|
7625
8368
|
readPersisted: () => {
|
|
7626
8369
|
try {
|
|
7627
|
-
return (0,
|
|
8370
|
+
return (0, import_node_fs6.readFileSync)(SESSION_FILE, "utf8");
|
|
7628
8371
|
} catch {
|
|
7629
8372
|
return null;
|
|
7630
8373
|
}
|
|
7631
8374
|
},
|
|
7632
8375
|
writePersisted: (id) => persistSession(id),
|
|
7633
8376
|
now: () => /* @__PURE__ */ new Date(),
|
|
7634
|
-
rand: () => (0,
|
|
8377
|
+
rand: () => (0, import_node_crypto3.randomBytes)(4).toString("hex")
|
|
7635
8378
|
};
|
|
7636
8379
|
}
|
|
7637
8380
|
var resolveSessionId = () => resolveSession(sessionDeps());
|
|
7638
8381
|
function persistSession(id) {
|
|
7639
8382
|
try {
|
|
7640
|
-
(0,
|
|
7641
|
-
(0,
|
|
8383
|
+
(0, import_node_fs6.mkdirSync)(".mmi", { recursive: true });
|
|
8384
|
+
(0, import_node_fs6.writeFileSync)(SESSION_FILE, id, "utf8");
|
|
7642
8385
|
} catch {
|
|
7643
8386
|
}
|
|
7644
8387
|
}
|
|
@@ -7654,16 +8397,11 @@ async function postCapture(capture, quiet = false) {
|
|
|
7654
8397
|
if (!quiet) console.error("mmi-cli saga: Hub API URL not configured");
|
|
7655
8398
|
return;
|
|
7656
8399
|
}
|
|
7657
|
-
const res = await fetch
|
|
8400
|
+
const res = await fetchWithRetry(fetch, `${cfg.sagaApiUrl}/saga/capture`, {
|
|
7658
8401
|
method: "POST",
|
|
7659
|
-
headers: await
|
|
7660
|
-
body: JSON.stringify({ ...capture, ...await sagaKey(cfg) })
|
|
7661
|
-
|
|
7662
|
-
// head-write timeout (20s) so a continuity note isn't lost to a slow/cold backend. No client retry:
|
|
7663
|
-
// the capture isn't guaranteed idempotent, so a retry after a server-side-completed write could
|
|
7664
|
-
// duplicate the note. Backend capture-latency root cause tracked in #255.
|
|
7665
|
-
signal: AbortSignal.timeout(2e4)
|
|
7666
|
-
});
|
|
8402
|
+
headers: await hubHeaders({ "content-type": "application/json" }),
|
|
8403
|
+
body: JSON.stringify({ ...capture, ...await sagaKey(cfg) })
|
|
8404
|
+
}, { attempts: 2, timeoutMs: 2e4, retryOn: () => false });
|
|
7667
8405
|
let message = "";
|
|
7668
8406
|
if (!res.ok) {
|
|
7669
8407
|
try {
|
|
@@ -7764,12 +8502,12 @@ async function applyGcPlan(plan2, remote) {
|
|
|
7764
8502
|
}
|
|
7765
8503
|
function resolveVersion() {
|
|
7766
8504
|
try {
|
|
7767
|
-
const manifest = (0,
|
|
7768
|
-
return JSON.parse((0,
|
|
8505
|
+
const manifest = (0, import_node_path7.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
|
|
8506
|
+
return JSON.parse((0, import_node_fs6.readFileSync)(manifest, "utf8")).version || "0.0.0";
|
|
7769
8507
|
} catch {
|
|
7770
8508
|
try {
|
|
7771
|
-
const pkg = (0,
|
|
7772
|
-
return JSON.parse((0,
|
|
8509
|
+
const pkg = (0, import_node_path7.join)(__dirname, "..", "package.json");
|
|
8510
|
+
return JSON.parse((0, import_node_fs6.readFileSync)(pkg, "utf8")).version || "0.0.0";
|
|
7773
8511
|
} catch {
|
|
7774
8512
|
return "0.0.0";
|
|
7775
8513
|
}
|
|
@@ -7777,7 +8515,7 @@ function resolveVersion() {
|
|
|
7777
8515
|
}
|
|
7778
8516
|
function readRepoVersion() {
|
|
7779
8517
|
try {
|
|
7780
|
-
return JSON.parse((0,
|
|
8518
|
+
return JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path7.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
|
|
7781
8519
|
} catch {
|
|
7782
8520
|
return void 0;
|
|
7783
8521
|
}
|
|
@@ -7874,11 +8612,11 @@ async function runRulesSync(opts, io = consoleIo) {
|
|
|
7874
8612
|
for (const entry of fetched) {
|
|
7875
8613
|
if ("error" in entry) continue;
|
|
7876
8614
|
const { file, source } = entry;
|
|
7877
|
-
const current = (0,
|
|
8615
|
+
const current = (0, import_node_fs6.existsSync)(file) ? await (0, import_promises2.readFile)(file, "utf8") : null;
|
|
7878
8616
|
if (needsUpdate(source, current)) {
|
|
7879
8617
|
const slash = file.lastIndexOf("/");
|
|
7880
|
-
if (slash > 0) (0,
|
|
7881
|
-
await (0,
|
|
8618
|
+
if (slash > 0) (0, import_node_fs6.mkdirSync)(file.slice(0, slash), { recursive: true });
|
|
8619
|
+
await (0, import_promises2.writeFile)(file, normalizeEol(source), "utf8");
|
|
7882
8620
|
changed++;
|
|
7883
8621
|
if (!opts.quiet) io.log(`mmi-cli rules: updated ${file}`);
|
|
7884
8622
|
}
|
|
@@ -7903,9 +8641,9 @@ async function runDocsSync(opts, io = consoleIo) {
|
|
|
7903
8641
|
return null;
|
|
7904
8642
|
}
|
|
7905
8643
|
},
|
|
7906
|
-
localContent: async (f) => (0,
|
|
8644
|
+
localContent: async (f) => (0, import_node_fs6.existsSync)(f) ? await (0, import_promises2.readFile)(f, "utf8") : null,
|
|
7907
8645
|
writeDoc: async (f, c) => {
|
|
7908
|
-
await (0,
|
|
8646
|
+
await (0, import_promises2.writeFile)(f, c, "utf8");
|
|
7909
8647
|
}
|
|
7910
8648
|
});
|
|
7911
8649
|
for (const f of result.updated) io.log(`mmi-cli docs: updated ${f} (from origin/${def})`);
|
|
@@ -7917,7 +8655,7 @@ docs.command("sync").option("--quiet", "stay silent unless something changed or
|
|
|
7917
8655
|
var saga = program2.command("saga").description("per-session continuity");
|
|
7918
8656
|
async function runNote(summary, o) {
|
|
7919
8657
|
const [sha, key] = await Promise.all([gitOut(["rev-parse", "--short", "HEAD"]), sagaKey(await loadConfig())]);
|
|
7920
|
-
const capture = buildNoteCapture(summary, o, (0,
|
|
8658
|
+
const capture = buildNoteCapture(summary, o, (0, import_node_crypto3.randomUUID)(), { sha: sha || void 0, branch: key.branch });
|
|
7921
8659
|
await postCapture(capture);
|
|
7922
8660
|
}
|
|
7923
8661
|
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));
|
|
@@ -7931,7 +8669,7 @@ async function runSagaShow(opts, io = consoleIo) {
|
|
|
7931
8669
|
try {
|
|
7932
8670
|
const key = await sagaKey(cfg);
|
|
7933
8671
|
const qs = opts.latestAnywhere ? "scope=anywhere" : new URLSearchParams({ project: key.project, branch: key.branch }).toString();
|
|
7934
|
-
const res = await fetch
|
|
8672
|
+
const res = await fetchWithRetry(fetch, `${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await hubHeaders() }, { attempts: 2, timeoutMs: 3e3 });
|
|
7935
8673
|
if (res.ok) {
|
|
7936
8674
|
io.log(resumeCue());
|
|
7937
8675
|
return io.log(await res.text());
|
|
@@ -7939,7 +8677,7 @@ async function runSagaShow(opts, io = consoleIo) {
|
|
|
7939
8677
|
if (!opts.quiet) io.log(`saga show failed: HTTP ${res.status}`);
|
|
7940
8678
|
} catch (e) {
|
|
7941
8679
|
if (!opts.quiet) {
|
|
7942
|
-
const reason = e.name === "TimeoutError" ? "backend unreachable (timed out after
|
|
8680
|
+
const reason = e.name === "TimeoutError" ? "backend unreachable (timed out after 2 attempts)" : e.message;
|
|
7943
8681
|
io.err(`saga show: ${reason} \u2014 continuing without saga; diagnose with \`mmi-cli saga health --json\``);
|
|
7944
8682
|
}
|
|
7945
8683
|
}
|
|
@@ -7948,7 +8686,7 @@ saga.command("show").option("--quiet", "no-op silently when unconfigured/unreach
|
|
|
7948
8686
|
saga.command("capture").option("--quiet", "capture silently (for the Stop hook)").description("per-turn deterministic capture (Stop hook): turn boundary + current sha").action(async (opts) => {
|
|
7949
8687
|
const hook = parseHookInput(await readStdin());
|
|
7950
8688
|
if (hook.session_id) persistSession(hook.session_id);
|
|
7951
|
-
await postCapture({ event: "stop", id: (0,
|
|
8689
|
+
await postCapture({ event: "stop", id: (0, import_node_crypto3.randomUUID)(), source: "hook", sha: await gitOut(["rev-parse", "--short", "HEAD"]), surface: agentSurface() }, opts.quiet ?? false);
|
|
7952
8690
|
});
|
|
7953
8691
|
saga.command("session").option("--quiet", "silent (for the SessionStart hook)").description("persist the harness session id for this repo (SessionStart hook)").action(async () => {
|
|
7954
8692
|
const hook = parseHookInput(await readStdin());
|
|
@@ -7975,7 +8713,7 @@ saga.command("head-update").option("--run", "detached worker: fetch state, run t
|
|
|
7975
8713
|
if (!cfg.sagaApiUrl) return;
|
|
7976
8714
|
const key = await sagaKey(cfg);
|
|
7977
8715
|
const qs = new URLSearchParams(key).toString();
|
|
7978
|
-
const res = await fetch(`${cfg.sagaApiUrl}/saga/state?${qs}`, { headers: await
|
|
8716
|
+
const res = await fetch(`${cfg.sagaApiUrl}/saga/state?${qs}`, { headers: await hubHeaders(), signal: AbortSignal.timeout(8e3) });
|
|
7979
8717
|
if (!res.ok) return;
|
|
7980
8718
|
const state = await res.json();
|
|
7981
8719
|
if (!state.actionLog?.length) return;
|
|
@@ -7983,7 +8721,7 @@ saga.command("head-update").option("--run", "detached worker: fetch state, run t
|
|
|
7983
8721
|
if (!update) return;
|
|
7984
8722
|
await fetch(`${cfg.sagaApiUrl}/saga/head`, {
|
|
7985
8723
|
method: "POST",
|
|
7986
|
-
headers: await
|
|
8724
|
+
headers: await hubHeaders({ "content-type": "application/json" }),
|
|
7987
8725
|
body: JSON.stringify({ ...update, ...key }),
|
|
7988
8726
|
signal: AbortSignal.timeout(2e4)
|
|
7989
8727
|
});
|
|
@@ -8000,7 +8738,7 @@ saga.command("key").option("--json", "machine-readable output").description("pri
|
|
|
8000
8738
|
});
|
|
8001
8739
|
async function probeBackend(url) {
|
|
8002
8740
|
try {
|
|
8003
|
-
const res = await fetch
|
|
8741
|
+
const res = await fetchWithRetry(fetch, `${url}/saga/head`, { headers: await hubHeaders() }, { attempts: 3, timeoutMs: 4e3 });
|
|
8004
8742
|
let message = "";
|
|
8005
8743
|
try {
|
|
8006
8744
|
const body = await res.clone().json();
|
|
@@ -8015,7 +8753,7 @@ async function probeBackend(url) {
|
|
|
8015
8753
|
async function probeSagaAccess(url, key) {
|
|
8016
8754
|
try {
|
|
8017
8755
|
const qs = new URLSearchParams(key).toString();
|
|
8018
|
-
const res = await fetch(`${url}/saga/state?${qs}`, { headers: await
|
|
8756
|
+
const res = await fetch(`${url}/saga/state?${qs}`, { headers: await hubHeaders(), signal: AbortSignal.timeout(8e3) });
|
|
8019
8757
|
return res.ok;
|
|
8020
8758
|
} catch {
|
|
8021
8759
|
return false;
|
|
@@ -8027,7 +8765,7 @@ async function runSagaHealth(o, io = consoleIo) {
|
|
|
8027
8765
|
const key = await sagaKey(cfg, session);
|
|
8028
8766
|
const source = session.source;
|
|
8029
8767
|
const [identity, liveness] = await Promise.all([
|
|
8030
|
-
|
|
8768
|
+
hubAuthSession({ baseUrl: cfg.sagaApiUrl ?? defaultHubUrl(), githubToken }).then((s) => s?.login),
|
|
8031
8769
|
cfg.sagaApiUrl ? probeBackend(cfg.sagaApiUrl) : Promise.resolve({ reachable: false })
|
|
8032
8770
|
]);
|
|
8033
8771
|
const authorized = cfg.sagaApiUrl && liveness.reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
|
|
@@ -8098,12 +8836,17 @@ kb.command("list [prefix]").description("list KB document paths (optionally unde
|
|
|
8098
8836
|
}
|
|
8099
8837
|
});
|
|
8100
8838
|
async function ghCreate(args) {
|
|
8839
|
+
const swapped = await bodyArgsViaFile(args);
|
|
8101
8840
|
try {
|
|
8102
|
-
const { stdout } = await execFileP4("gh", args);
|
|
8841
|
+
const { stdout } = await execFileP4("gh", swapped.args, { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
8103
8842
|
return parseCreatedUrl(stdout);
|
|
8104
8843
|
} catch (e) {
|
|
8844
|
+
await swapped.cleanup();
|
|
8105
8845
|
const err = e;
|
|
8106
|
-
|
|
8846
|
+
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
8847
|
+
fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
|
|
8848
|
+
} finally {
|
|
8849
|
+
await swapped.cleanup();
|
|
8107
8850
|
}
|
|
8108
8851
|
}
|
|
8109
8852
|
async function ghJson(args, timeout = 1e4) {
|
|
@@ -8123,7 +8866,13 @@ async function resolveRepo(repo) {
|
|
|
8123
8866
|
}
|
|
8124
8867
|
async function attachToProject(issueNumber, repo, priority) {
|
|
8125
8868
|
const targetRepo2 = await resolveRepo(repo);
|
|
8126
|
-
|
|
8869
|
+
let cfg;
|
|
8870
|
+
try {
|
|
8871
|
+
cfg = await loadConfigForRepo(targetRepo2);
|
|
8872
|
+
} catch (e) {
|
|
8873
|
+
console.error(`issue create: board attach skipped \u2014 ${e.message}`);
|
|
8874
|
+
return void 0;
|
|
8875
|
+
}
|
|
8127
8876
|
if (!cfg.projectId) {
|
|
8128
8877
|
console.error(`issue create: board attach skipped \u2014 no Hub registry board META for ${targetRepo2 ?? "current repo"}; run \`mmi-cli project get ${targetRepo2 ?? "<owner/repo>"}\` and backfill board coords`);
|
|
8129
8878
|
return void 0;
|
|
@@ -8134,7 +8883,7 @@ async function attachToProject(issueNumber, repo, priority) {
|
|
|
8134
8883
|
const { stdout: idOut } = await execFileP4("gh", viewArgs, { timeout: 1e4 });
|
|
8135
8884
|
const contentId = idOut.trim();
|
|
8136
8885
|
if (!contentId) throw new Error("could not resolve issue node id");
|
|
8137
|
-
const { stdout } = await execFileP4("gh", buildAddToProjectArgs(cfg.projectId, contentId), { timeout:
|
|
8886
|
+
const { stdout } = await execFileP4("gh", buildAddToProjectArgs(cfg.projectId, contentId), { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
8138
8887
|
const projectItemId = parseAddedItemId(stdout);
|
|
8139
8888
|
if (projectItemId && priority) {
|
|
8140
8889
|
try {
|
|
@@ -8153,6 +8902,7 @@ async function attachToProject(issueNumber, repo, priority) {
|
|
|
8153
8902
|
return void 0;
|
|
8154
8903
|
}
|
|
8155
8904
|
}
|
|
8905
|
+
var ghRunner = async (args, timeoutMs) => (await execFileP4("gh", args, { timeout: timeoutMs })).stdout;
|
|
8156
8906
|
function scheduleRelatedDiscovery(o) {
|
|
8157
8907
|
try {
|
|
8158
8908
|
const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
|
|
@@ -8167,39 +8917,39 @@ function scheduleRelatedDiscovery(o) {
|
|
|
8167
8917
|
}
|
|
8168
8918
|
}
|
|
8169
8919
|
function makePlanDeps(cfg, io = consoleIo) {
|
|
8170
|
-
const ensureDir = () => (0,
|
|
8920
|
+
const ensureDir = () => (0, import_node_fs6.mkdirSync)(PLANS_DIR, { recursive: true });
|
|
8171
8921
|
return {
|
|
8172
8922
|
apiUrl: cfg.sagaApiUrl,
|
|
8173
8923
|
fetch: (url, init = {}) => fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(1e4) }),
|
|
8174
|
-
headers: (extra) =>
|
|
8924
|
+
headers: (extra) => hubHeaders(extra),
|
|
8175
8925
|
project: async () => (await sagaKey(cfg)).project,
|
|
8176
8926
|
readLocal: (slug) => {
|
|
8177
8927
|
try {
|
|
8178
|
-
return (0,
|
|
8928
|
+
return (0, import_node_fs6.readFileSync)(planPath(slug), "utf8");
|
|
8179
8929
|
} catch {
|
|
8180
8930
|
return null;
|
|
8181
8931
|
}
|
|
8182
8932
|
},
|
|
8183
8933
|
writeLocal: (slug, content) => {
|
|
8184
8934
|
ensureDir();
|
|
8185
|
-
(0,
|
|
8935
|
+
(0, import_node_fs6.writeFileSync)(planPath(slug), content, "utf8");
|
|
8186
8936
|
},
|
|
8187
8937
|
removeLocal: (slug) => {
|
|
8188
8938
|
try {
|
|
8189
|
-
(0,
|
|
8939
|
+
(0, import_node_fs6.rmSync)(planPath(slug));
|
|
8190
8940
|
} catch {
|
|
8191
8941
|
}
|
|
8192
8942
|
},
|
|
8193
8943
|
readMetaRaw: () => {
|
|
8194
8944
|
try {
|
|
8195
|
-
return (0,
|
|
8945
|
+
return (0, import_node_fs6.readFileSync)(META_FILE, "utf8");
|
|
8196
8946
|
} catch {
|
|
8197
8947
|
return null;
|
|
8198
8948
|
}
|
|
8199
8949
|
},
|
|
8200
8950
|
writeMetaRaw: (raw) => {
|
|
8201
8951
|
ensureDir();
|
|
8202
|
-
(0,
|
|
8952
|
+
(0, import_node_fs6.writeFileSync)(META_FILE, raw, "utf8");
|
|
8203
8953
|
},
|
|
8204
8954
|
log: (m) => io.log(m),
|
|
8205
8955
|
err: (m) => io.err(m),
|
|
@@ -8226,6 +8976,26 @@ async function withPlan(quiet, run, io = consoleIo) {
|
|
|
8226
8976
|
}
|
|
8227
8977
|
await run(makePlanDeps(cfg, io));
|
|
8228
8978
|
}
|
|
8979
|
+
async function gatherRelevanceSignals() {
|
|
8980
|
+
const branch = await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "") || void 0;
|
|
8981
|
+
const changed = (await gitOut(["diff", "--name-only", "HEAD"]).catch(() => "")).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
8982
|
+
const signals = { branch, changedFiles: changed.length ? changed : void 0 };
|
|
8983
|
+
const issueNum = branch?.match(/\b(\d{2,})\b/)?.[1];
|
|
8984
|
+
if (issueNum) {
|
|
8985
|
+
try {
|
|
8986
|
+
const { stdout } = await execFileP4(
|
|
8987
|
+
"gh",
|
|
8988
|
+
["issue", "view", issueNum, "--json", "title,labels", "--jq", "{title:.title,labels:[.labels[].name]}"],
|
|
8989
|
+
{ timeout: 1e4 }
|
|
8990
|
+
);
|
|
8991
|
+
const j = JSON.parse(stdout);
|
|
8992
|
+
if (j.title) signals.issueTitle = j.title;
|
|
8993
|
+
if (j.labels?.length) signals.issueLabels = j.labels;
|
|
8994
|
+
} catch {
|
|
8995
|
+
}
|
|
8996
|
+
}
|
|
8997
|
+
return signals;
|
|
8998
|
+
}
|
|
8229
8999
|
function registerNorthStarCommands(cmd) {
|
|
8230
9000
|
cmd.command("push <slug>").description("push a local North Star plan (plans/<slug>.md) to the server").option("--project <name>", "override the project key").option("--force", "overwrite the remote even if it changed since your last sync").action((slug, o) => withPlan(false, async (d) => {
|
|
8231
9001
|
const ok = await planPush(d, slug, o);
|
|
@@ -8236,6 +9006,10 @@ function registerNorthStarCommands(cmd) {
|
|
|
8236
9006
|
if (!ok) process.exitCode = 1;
|
|
8237
9007
|
}));
|
|
8238
9008
|
cmd.command("list").description("list your North Star plans (cross-device)").option("--project <name>", "filter by project").option("--json", "machine-readable output").option("--quiet", "silent when unconfigured/empty/unreachable (SessionStart hook)").action((o) => withPlan(o.quiet ?? false, (d) => planList(d, o)));
|
|
9009
|
+
cmd.command("relevant").description("list North Stars relevant to your current task (branch + claimed issue + changed files)").option("--project <name>", "override the project key").option("--all", "include superseded/graduated and recent non-matching plans").option("--limit <n>", "max results (default 5)").action((o) => withPlan(false, async (d) => {
|
|
9010
|
+
const signals = await gatherRelevanceSignals();
|
|
9011
|
+
await relevantPlans(d, signals, { project: o.project, includeAll: o.all, limit: o.limit ? Number(o.limit) : void 0 });
|
|
9012
|
+
}));
|
|
8239
9013
|
cmd.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
|
|
8240
9014
|
(slug, o) => withPlan(false, async (d) => {
|
|
8241
9015
|
const ok = await planPull(d, slug, { project: o.project });
|
|
@@ -8270,7 +9044,7 @@ function makeSecretsDeps(cfg) {
|
|
|
8270
9044
|
return {
|
|
8271
9045
|
apiUrl: cfg.sagaApiUrl,
|
|
8272
9046
|
fetch: (url, init = {}) => fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(1e4) }),
|
|
8273
|
-
headers: (extra) =>
|
|
9047
|
+
headers: (extra) => hubHeaders(extra),
|
|
8274
9048
|
// Vault paths are lowercase kebab (AGENTS.md naming); sagaKey carries the repo name's original
|
|
8275
9049
|
// casing, which leaked mixed-case into `secrets where` output (#681).
|
|
8276
9050
|
slug: async () => (await sagaKey(cfg)).project.toLowerCase(),
|
|
@@ -8323,7 +9097,7 @@ secrets.command("use <key>").description("print guidance on consuming a secret w
|
|
|
8323
9097
|
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, {})));
|
|
8324
9098
|
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, {})));
|
|
8325
9099
|
function registryClientDeps(cfg) {
|
|
8326
|
-
return { baseUrl: cfg.sagaApiUrl, token: githubToken };
|
|
9100
|
+
return { baseUrl: cfg.sagaApiUrl, token: () => hubAuthToken({ baseUrl: cfg.sagaApiUrl, githubToken }) };
|
|
8327
9101
|
}
|
|
8328
9102
|
function slugOf(repoOrSlug) {
|
|
8329
9103
|
return (repoOrSlug.includes("/") ? repoOrSlug.split("/").pop() : repoOrSlug).toLowerCase();
|
|
@@ -8343,10 +9117,20 @@ tenant.command("control <owner/repo> <stage> <action>").description("run bounded
|
|
|
8343
9117
|
const res = await tenantControl({ repo, stage: stage2, action }, registryClientDeps(cfg));
|
|
8344
9118
|
reportWrite("tenant control", res);
|
|
8345
9119
|
});
|
|
9120
|
+
tenant.command("redeploy <owner/repo> <stage>").description("re-dispatch the central tenant-deploy.yml for an already-promoted ref (no re-tag/merge); train-authority gated").option("--ref <ref>", "ref to deploy (defaults to the stage branch rc/main \u2014 the promoted ref)").option("--watch", "block on the dispatched run and report its outcome (gh run watch --exit-status)").option("--json", "machine-readable output").action(async (repo, stage2, o) => {
|
|
9121
|
+
if (stage2 !== "rc" && stage2 !== "main") return fail("tenant redeploy: <stage> must be rc or main");
|
|
9122
|
+
try {
|
|
9123
|
+
const result = await runTenantRedeploy(trainApplyDeps(), { repo, stage: stage2, ref: o.ref, watch: o.watch });
|
|
9124
|
+
return printLine(o.json ? JSON.stringify(result, null, 2) : renderTenantRedeploy(result));
|
|
9125
|
+
} catch (e) {
|
|
9126
|
+
return fail(`tenant redeploy: ${e.message}`);
|
|
9127
|
+
}
|
|
9128
|
+
});
|
|
8346
9129
|
async function v2ReadinessDeps(cfg) {
|
|
8347
9130
|
const reg = registryClientDeps(cfg);
|
|
8348
9131
|
return {
|
|
8349
|
-
|
|
9132
|
+
// Checked read (#727/#733): the doctor distinguishes a FAILED read (degraded report) from a 404.
|
|
9133
|
+
getProject: (slug) => fetchProjectBySlugChecked(slug, reg),
|
|
8350
9134
|
hasDeployCoords: async (slug, stage2) => {
|
|
8351
9135
|
const status = await fetchDeployStatusBySlug(slug, reg);
|
|
8352
9136
|
return Boolean(status?.stages[stage2]);
|
|
@@ -8359,11 +9143,10 @@ async function v2ReadinessDeps(cfg) {
|
|
|
8359
9143
|
const apiUrl = cfg.sagaApiUrl;
|
|
8360
9144
|
if (!apiUrl) throw new Error("Hub API URL not configured \u2014 cannot verify secret names (set sagaApiUrl)");
|
|
8361
9145
|
const qs = new URLSearchParams({ repo: targetRepo2 }).toString();
|
|
8362
|
-
const res = await fetch
|
|
9146
|
+
const res = await fetchWithRetry(fetch, `${apiUrl.replace(/\/$/, "")}/secrets/list?${qs}`, {
|
|
8363
9147
|
method: "GET",
|
|
8364
|
-
headers: await
|
|
8365
|
-
|
|
8366
|
-
});
|
|
9148
|
+
headers: await hubHeaders()
|
|
9149
|
+
}, { attempts: 2, timeoutMs: 5e3 });
|
|
8367
9150
|
if (!res.ok) throw new Error(`secrets list failed for ${targetRepo2}: HTTP ${res.status}`);
|
|
8368
9151
|
const body = await res.json();
|
|
8369
9152
|
return (body.secrets ?? []).map((s) => s.key).filter(Boolean);
|
|
@@ -8377,15 +9160,25 @@ async function updateV2ReadinessIssue(repo, report, healed) {
|
|
|
8377
9160
|
const issues = JSON.parse(list.stdout || "[]");
|
|
8378
9161
|
const existing = issues.find((i) => i.title.toLowerCase().includes("v2 readiness"));
|
|
8379
9162
|
if (!existing) {
|
|
8380
|
-
const
|
|
8381
|
-
|
|
8382
|
-
|
|
8383
|
-
|
|
9163
|
+
const create = await bodyArgsViaFile(["issue", "create", "--repo", repo, "--title", title, "--body", freshBody, "--label", "feature"]);
|
|
9164
|
+
try {
|
|
9165
|
+
const created = await execFileP4("gh", create.args, { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
9166
|
+
const url = created.stdout.trim();
|
|
9167
|
+
const number = Number(url.match(/\/issues\/(\d+)$/)?.[1] ?? 0);
|
|
9168
|
+
return { number, url, action: "created" };
|
|
9169
|
+
} finally {
|
|
9170
|
+
await create.cleanup();
|
|
9171
|
+
}
|
|
8384
9172
|
}
|
|
8385
9173
|
const view = await execFileP4("gh", ["issue", "view", String(existing.number), "--repo", repo, "--json", "body,url", "--jq", "{body:.body,url:.url}"], { timeout: 2e4 });
|
|
8386
9174
|
const current = JSON.parse(view.stdout || "{}");
|
|
8387
9175
|
const nextBody = renderReadinessIssueBody(current.body ?? "", report, { healed });
|
|
8388
|
-
await
|
|
9176
|
+
const edit = await bodyArgsViaFile(["issue", "edit", String(existing.number), "--repo", repo, "--body", nextBody]);
|
|
9177
|
+
try {
|
|
9178
|
+
await execFileP4("gh", edit.args, { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
9179
|
+
} finally {
|
|
9180
|
+
await edit.cleanup();
|
|
9181
|
+
}
|
|
8389
9182
|
return { number: existing.number, url: current.url ?? `https://github.com/${repo}/issues/${existing.number}`, action: "updated" };
|
|
8390
9183
|
}
|
|
8391
9184
|
var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); attest is project-admin; set is master-only");
|
|
@@ -8412,9 +9205,14 @@ project.command("get [owner/repo]").description("a project's META (board ids + p
|
|
|
8412
9205
|
} catch (e) {
|
|
8413
9206
|
return fail(e.message);
|
|
8414
9207
|
}
|
|
8415
|
-
const
|
|
8416
|
-
if (!
|
|
8417
|
-
|
|
9208
|
+
const read = await fetchProjectBySlugChecked(slugOf(target), registryClientDeps(cfg));
|
|
9209
|
+
if (!read.ok) {
|
|
9210
|
+
return fail(`project get: Hub registry read failed (${read.error}) \u2014 likely transient (cold start, network, or auth blip); retry shortly`);
|
|
9211
|
+
}
|
|
9212
|
+
if (!read.project) {
|
|
9213
|
+
return fail(`project get: no registry META for ${target} (unknown or unbootstrapped)`);
|
|
9214
|
+
}
|
|
9215
|
+
console.log(JSON.stringify(read.project));
|
|
8418
9216
|
});
|
|
8419
9217
|
project.command("resolve <owner/repo>").description("deploy coords for a stage \u2014 for diagnosis. NOTE: /deploy-coords is OIDC-gated (a deploy job\u2019s id-token), so a gh-token CLI cannot read it from a dev machine").option("--stage <main|rc>", "deploy stage", "main").option("--json", "machine-readable output").action((_repoOrRepo, o) => {
|
|
8420
9218
|
const msg = "project resolve: deploy coords are served only to a deploy workflow (GitHub OIDC id-token, repo-scoped). A gh-token CLI on a dev machine cannot read /deploy-coords; inspect the DEPLOY# item via the AWS console / a master DDB read instead.";
|
|
@@ -8487,7 +9285,7 @@ project.command("attest [owner/repo]").description("attest this repo's app-owned
|
|
|
8487
9285
|
const res = await attestAppGaps(slugOf(target), repo, registryClientDeps(cfg));
|
|
8488
9286
|
reportWrite("project attest", res);
|
|
8489
9287
|
});
|
|
8490
|
-
project.command("set [owner/repo]").description("MASTER-ONLY: upsert a project META (idempotent merge; defaults to the current repo; no clobber of unspecified fields)").option("--class <class>", "deployable | content").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).option("--var <KEY=VALUE...>",
|
|
9288
|
+
project.command("set [owner/repo]").description("MASTER-ONLY: upsert a project META (idempotent merge; defaults to the current repo; no clobber of unspecified fields)").option("--class <class>", "deployable | content").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).option("--var <KEY=VALUE...>", 'META field to set (repeatable): name, division, projectId, branch, wikiRepo, vaultPath, kbPointer, requiredRuntimeSecrets (JSON stage map, e.g. {"dev":["KEY"],"rc":["KEY"],"main":["KEY"]})').option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, edgeDomains, requiredGcpApis, publishRequired").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
|
|
8491
9289
|
const cfg = await loadConfig();
|
|
8492
9290
|
let target;
|
|
8493
9291
|
try {
|
|
@@ -8586,14 +9384,15 @@ oauth.command("verify").description("probe Google authorize with an arbitrary po
|
|
|
8586
9384
|
if (mismatch) process.exitCode = 1;
|
|
8587
9385
|
});
|
|
8588
9386
|
var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
|
|
8589
|
-
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").option("--body <body>", "issue body (markdown)").option("--body-file <path|->", "read issue body from a UTF-8 file, or from stdin with -").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) => {
|
|
9387
|
+
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").option("--body <body>", "issue body (markdown)").option("--body-file <path|->", "read issue body from a UTF-8 file, or from stdin with -").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("--parent <ref>", "file as a native sub-issue of this parent (#123, owner/repo#123, or URL)").option("--no-related", "skip the auto related-issues comment").action(async (o) => {
|
|
8590
9388
|
let args;
|
|
8591
9389
|
let priority;
|
|
8592
9390
|
let body;
|
|
8593
9391
|
try {
|
|
8594
|
-
body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile:
|
|
9392
|
+
body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile: import_promises2.readFile, readStdin });
|
|
8595
9393
|
priority = normalizePriority(o.priority);
|
|
8596
9394
|
args = buildIssueArgs({ type: o.type, title: o.title, body, priority, repo: o.repo, labels: o.label });
|
|
9395
|
+
if (o.parent !== void 0) parseIssueRef(o.parent);
|
|
8597
9396
|
} catch (e) {
|
|
8598
9397
|
return fail(`issue create: ${e.message}`);
|
|
8599
9398
|
}
|
|
@@ -8601,14 +9400,26 @@ issue.command("create").description("create an issue (type \u2192 label) and pri
|
|
|
8601
9400
|
const la = ["label", "create", label, "--color", "ededed"];
|
|
8602
9401
|
if (o.repo) la.push("--repo", o.repo);
|
|
8603
9402
|
try {
|
|
8604
|
-
await execFileP4("gh", la, { timeout:
|
|
9403
|
+
await execFileP4("gh", la, { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
8605
9404
|
} catch {
|
|
8606
9405
|
}
|
|
8607
9406
|
}
|
|
8608
9407
|
const created = await ghCreate(args);
|
|
8609
9408
|
const projectItemId = await attachToProject(created.number, o.repo, priority);
|
|
9409
|
+
let parent;
|
|
9410
|
+
let parentLinkError;
|
|
9411
|
+
if (o.parent !== void 0) {
|
|
9412
|
+
try {
|
|
9413
|
+
parent = await linkSubIssue(ghRunner, o.parent, created.url, o.repo);
|
|
9414
|
+
} catch (e) {
|
|
9415
|
+
const err = e;
|
|
9416
|
+
parentLinkError = (err.stderr || err.message || String(e)).trim();
|
|
9417
|
+
process.stderr.write(`warning: issue #${created.number} created but NOT linked under ${o.parent}: ${parentLinkError}
|
|
9418
|
+
`);
|
|
9419
|
+
}
|
|
9420
|
+
}
|
|
8610
9421
|
if (o.related !== false) scheduleRelatedDiscovery({ repo: o.repo, number: created.number, title: o.title, body });
|
|
8611
|
-
console.log(JSON.stringify({ ...created, label: o.type, priority, projectItemId }));
|
|
9422
|
+
console.log(JSON.stringify({ ...created, label: o.type, priority, projectItemId, ...parentLinkFields(parent, parentLinkError) }));
|
|
8612
9423
|
});
|
|
8613
9424
|
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) => {
|
|
8614
9425
|
const number = Number(o.number);
|
|
@@ -8641,10 +9452,21 @@ issue.command("discover-related").description("find related issues for an existi
|
|
|
8641
9452
|
"comments"
|
|
8642
9453
|
]);
|
|
8643
9454
|
if (viewed.comments.some((comment) => comment.body.includes(relatedMarker(number)))) return;
|
|
8644
|
-
await execFileP4("gh", ["issue", "comment", String(number), "--repo", repo, "--body", buildRelatedComment(number, candidates)], { timeout:
|
|
9455
|
+
await execFileP4("gh", ["issue", "comment", String(number), "--repo", repo, "--body", buildRelatedComment(number, candidates)], { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
8645
9456
|
} catch {
|
|
8646
9457
|
}
|
|
8647
9458
|
});
|
|
9459
|
+
issue.command("link-child <parent> <child>").description("link an existing issue as a native sub-issue of a parent and print {parentNumber,subIssueNumber,totalCount} JSON").option("--repo <owner/repo>", "repo for bare refs on either side (defaults to the current repo)").action(async (parentRef, childRef, o) => {
|
|
9460
|
+
const defaultRepo = await resolveRepo(o.repo);
|
|
9461
|
+
try {
|
|
9462
|
+
const result = await linkSubIssue(ghRunner, parentRef, childRef, defaultRepo);
|
|
9463
|
+
console.log(JSON.stringify(result));
|
|
9464
|
+
} catch (e) {
|
|
9465
|
+
const err = e;
|
|
9466
|
+
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
9467
|
+
return fail(`issue link-child: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
|
|
9468
|
+
}
|
|
9469
|
+
});
|
|
8648
9470
|
program2.command("report").description("file a friction report on the Hub board (GitHub auth, dedups open reports) and print {number,url} JSON").requiredOption("--title <title>", "one-line friction summary").option("--body <body>", "report body (markdown)").option("--body-file <path|->", "read report body from a UTF-8 file, or from stdin with -").option("--type <type>", "bug | feature | task (sets the matching label)", "task").option("--priority <priority>", "urgent | high | medium | low (board Priority field when configured)", "medium").option("--repo <owner/repo>", `target repo (defaults to the org Hub: ${HUB_REPO})`).option("--force", "file a new issue even when an open report looks like a duplicate").option("--json", "machine-readable output (already the default \u2014 report always prints JSON; #682)").action(async (o) => {
|
|
8649
9471
|
let body;
|
|
8650
9472
|
let priority;
|
|
@@ -8652,7 +9474,7 @@ program2.command("report").description("file a friction report on the Hub board
|
|
|
8652
9474
|
const targetRepo2 = o.repo ?? HUB_REPO;
|
|
8653
9475
|
const sourceRepo = await resolveRepo(void 0);
|
|
8654
9476
|
try {
|
|
8655
|
-
body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile:
|
|
9477
|
+
body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile: import_promises2.readFile, readStdin });
|
|
8656
9478
|
priority = normalizePriority(o.priority);
|
|
8657
9479
|
args = buildIssueArgs({
|
|
8658
9480
|
type: o.type,
|
|
@@ -8687,7 +9509,7 @@ program2.command("report").description("file a friction report on the Hub board
|
|
|
8687
9509
|
const dup = findDuplicateReport({ title: o.title, body }, openReports);
|
|
8688
9510
|
if (dup) {
|
|
8689
9511
|
try {
|
|
8690
|
-
await execFileP4("gh", ["issue", "comment", String(dup.number), "--repo", targetRepo2, "--body", buildDupComment(dup.number, body, sourceRepo)], { timeout:
|
|
9512
|
+
await execFileP4("gh", ["issue", "comment", String(dup.number), "--repo", targetRepo2, "--body", buildDupComment(dup.number, body, sourceRepo)], { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
8691
9513
|
} catch (e) {
|
|
8692
9514
|
const err = e;
|
|
8693
9515
|
return fail(`report: duplicate of #${dup.number} (${dup.url}) but the +1 comment failed: ${(err.stderr || err.message || String(e)).trim()}`);
|
|
@@ -8696,7 +9518,7 @@ program2.command("report").description("file a friction report on the Hub board
|
|
|
8696
9518
|
}
|
|
8697
9519
|
}
|
|
8698
9520
|
try {
|
|
8699
|
-
await execFileP4("gh", ["label", "create", REPORT_LABEL, "--color", "ededed", "--repo", targetRepo2], { timeout:
|
|
9521
|
+
await execFileP4("gh", ["label", "create", REPORT_LABEL, "--color", "ededed", "--repo", targetRepo2], { timeout: GH_MUTATION_TIMEOUT_MS });
|
|
8700
9522
|
} catch {
|
|
8701
9523
|
}
|
|
8702
9524
|
const created = await ghCreate(args);
|
|
@@ -8704,8 +9526,14 @@ program2.command("report").description("file a friction report on the Hub board
|
|
|
8704
9526
|
console.log(JSON.stringify({ ...created, deduped: false, label: REPORT_LABEL, priority, projectItemId }));
|
|
8705
9527
|
});
|
|
8706
9528
|
var pr = program2.command("pr").description("pull requests \u2014 reliable create with structured output");
|
|
8707
|
-
pr.command("create").description("create a PR and print {number,url} JSON").requiredOption("--title <title>", "PR title").
|
|
8708
|
-
|
|
9529
|
+
pr.command("create").description("create a PR and print {number,url} JSON").requiredOption("--title <title>", "PR title").option("--body <body>", "PR body (markdown)").option("--body-file <path|->", "read PR body from a UTF-8 file, or from stdin with -").option("--base <branch>", "base branch (defaults to the repo default)").option("--head <branch>", "head branch (defaults to the current branch)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (o) => {
|
|
9530
|
+
let body;
|
|
9531
|
+
try {
|
|
9532
|
+
body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile: import_promises2.readFile, readStdin });
|
|
9533
|
+
} catch (e) {
|
|
9534
|
+
return fail(`pr create: ${e.message}`);
|
|
9535
|
+
}
|
|
9536
|
+
const created = await ghCreate(buildPrArgs({ title: o.title, body, base: o.base, head: o.head, repo: o.repo }));
|
|
8709
9537
|
console.log(JSON.stringify(created));
|
|
8710
9538
|
});
|
|
8711
9539
|
async function remoteBranchExists(branch) {
|
|
@@ -8717,6 +9545,20 @@ async function remoteBranchExists(branch) {
|
|
|
8717
9545
|
return void 0;
|
|
8718
9546
|
}
|
|
8719
9547
|
}
|
|
9548
|
+
var COMPOSE_TIMEOUT_MS = 12e4;
|
|
9549
|
+
function teardownWorktreeStage(worktreePath) {
|
|
9550
|
+
return runWorktreeStageTeardown(worktreePath, {
|
|
9551
|
+
hasStageState: (wt) => (0, import_node_fs6.existsSync)(stageStatePath(wt)),
|
|
9552
|
+
stopRecordedStage: async (wt) => (await stopStage({ cwd: wt })).pid,
|
|
9553
|
+
listComposeProjects: async () => {
|
|
9554
|
+
const { stdout } = await execFileP4("docker", ["compose", "ls", "--all", "--format", "json"], { timeout: GC_GH_TIMEOUT_MS });
|
|
9555
|
+
return parseComposeLs(stdout);
|
|
9556
|
+
},
|
|
9557
|
+
composeDown: async (project2) => {
|
|
9558
|
+
await execFileP4("docker", ["compose", "-p", project2, "down", "-v"], { timeout: COMPOSE_TIMEOUT_MS });
|
|
9559
|
+
}
|
|
9560
|
+
});
|
|
9561
|
+
}
|
|
8720
9562
|
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) => {
|
|
8721
9563
|
const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
|
|
8722
9564
|
const repoArgs = o.repo ? ["--repo", o.repo] : [];
|
|
@@ -8728,12 +9570,14 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
|
|
|
8728
9570
|
const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
|
|
8729
9571
|
let remoteDeleteAttempted = false;
|
|
8730
9572
|
let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
|
|
8731
|
-
await execFileP4("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout:
|
|
9573
|
+
await execFileP4("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GH_MUTATION_TIMEOUT_MS }).catch((e) => {
|
|
8732
9574
|
const message = String(e.message || "");
|
|
8733
9575
|
if (/already been merged/i.test(message)) {
|
|
8734
9576
|
remoteNotAttemptedReason = "pr-already-merged";
|
|
8735
9577
|
return;
|
|
8736
9578
|
}
|
|
9579
|
+
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
9580
|
+
if (note) throw new Error(`gh pr merge ${number}: ${note}`);
|
|
8737
9581
|
if (!/used by worktree|cannot delete branch/i.test(message)) throw e;
|
|
8738
9582
|
});
|
|
8739
9583
|
if (!remoteNotAttemptedReason) remoteDeleteAttempted = true;
|
|
@@ -8751,7 +9595,8 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
|
|
|
8751
9595
|
} : await cleanupPrMergeLocalBranch(headRef, {
|
|
8752
9596
|
beforeWorktrees,
|
|
8753
9597
|
startingPath,
|
|
8754
|
-
execGit: async (args) => (await execFileP4("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
|
|
9598
|
+
execGit: async (args) => (await execFileP4("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
|
|
9599
|
+
teardownWorktreeStage
|
|
8755
9600
|
});
|
|
8756
9601
|
console.log(JSON.stringify({
|
|
8757
9602
|
merged: number,
|
|
@@ -8867,16 +9712,50 @@ function rawValues(flag) {
|
|
|
8867
9712
|
return out;
|
|
8868
9713
|
}
|
|
8869
9714
|
function printLine(value) {
|
|
8870
|
-
(0,
|
|
9715
|
+
(0, import_node_fs6.writeSync)(1, `${value}
|
|
8871
9716
|
`);
|
|
8872
9717
|
}
|
|
8873
9718
|
function stageKeepAlive() {
|
|
8874
9719
|
return setTimeout(() => void 0, 5 * 60 * 1e3);
|
|
8875
9720
|
}
|
|
9721
|
+
async function resolveStage() {
|
|
9722
|
+
const cfg = await loadConfig();
|
|
9723
|
+
const local = cfg.stage;
|
|
9724
|
+
const read = await fetchProjectBySlugChecked(await repoSlug(), registryClientDeps(cfg)).catch((e) => ({ ok: false, error: e.message }));
|
|
9725
|
+
const project2 = read.ok ? read.project : null;
|
|
9726
|
+
const portRangeMeta = project2?.portRange ?? void 0;
|
|
9727
|
+
const portRange = portRangeMeta && typeof portRangeMeta.start === "number" && typeof portRangeMeta.end === "number" ? [portRangeMeta.start, portRangeMeta.end] : void 0;
|
|
9728
|
+
return decideStage({
|
|
9729
|
+
local,
|
|
9730
|
+
shell: shellFor(),
|
|
9731
|
+
registry: { deployModel: project2?.deployModel, portRange, error: read.ok ? void 0 : read.error },
|
|
9732
|
+
hasCompose: (0, import_node_fs6.existsSync)((0, import_node_path7.join)(process.cwd(), "docker-compose.yml")),
|
|
9733
|
+
hasEnvExample: (0, import_node_fs6.existsSync)((0, import_node_path7.join)(process.cwd(), ".env.example"))
|
|
9734
|
+
});
|
|
9735
|
+
}
|
|
9736
|
+
function stageStepsFor(res, stops = true) {
|
|
9737
|
+
if (res.source === "derived" && res.derived) return derivedStagePlan(res.derived, shellFor(), stops);
|
|
9738
|
+
if (res.source === "local") return stagePlan(res.config ?? {}, stops);
|
|
9739
|
+
return [{ label: `no local stage to run \u2014 ${res.gap ?? "stage config gap"}` }];
|
|
9740
|
+
}
|
|
9741
|
+
function staleStageNote(res) {
|
|
9742
|
+
if (!res.staleIgnored) return null;
|
|
9743
|
+
const fields = res.staleFields ?? [];
|
|
9744
|
+
const list = fields.join(", ");
|
|
9745
|
+
const label = fields.length > 1 ? `fields ${list}` : `field ${list || "build/up"}`;
|
|
9746
|
+
if (res.source === "local") {
|
|
9747
|
+
return `note: POSIX-only .mmi stage ${label} ignored on PowerShell \u2014 kept the rest of the local recipe`;
|
|
9748
|
+
}
|
|
9749
|
+
return `note: stale POSIX-only .mmi stage ${label} ignored on PowerShell \u2014 using the registry-derived default`;
|
|
9750
|
+
}
|
|
9751
|
+
function reportedStageUrl(res, result) {
|
|
9752
|
+
if (!res.derived) return void 0;
|
|
9753
|
+
return result.port != null ? stageUrlForPort(result.port) : res.derived.url;
|
|
9754
|
+
}
|
|
8876
9755
|
program2.command("port-range <repo>").description("assign (idempotently) + print the repo's local stage port block via the atomic ORG#config.portCursor allocator (committed-file fallback)").option("--json", "machine-readable output").action(async (repo, o) => {
|
|
8877
|
-
const path2 = (0,
|
|
9756
|
+
const path2 = (0, import_node_path7.join)(process.cwd(), "infra", "port-ranges.json");
|
|
8878
9757
|
const allocate = async (seed) => {
|
|
8879
|
-
const { stdout } = await execFileP4("node", [(0,
|
|
9758
|
+
const { stdout } = await execFileP4("node", [(0, import_node_path7.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
|
|
8880
9759
|
const parsed = JSON.parse(stdout);
|
|
8881
9760
|
if (!Array.isArray(parsed.range) || parsed.range.length !== 2) throw new Error("port-ddb: no range in output");
|
|
8882
9761
|
return parsed.range;
|
|
@@ -8885,20 +9764,27 @@ program2.command("port-range <repo>").description("assign (idempotently) + print
|
|
|
8885
9764
|
printLine(o.json ? JSON.stringify({ repo, portRange: [start, end] }) : `${repo}: stage.portRange [${start}, ${end}]`);
|
|
8886
9765
|
});
|
|
8887
9766
|
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) => {
|
|
8888
|
-
const
|
|
9767
|
+
const res = await resolveStage();
|
|
8889
9768
|
if (o.apply) {
|
|
9769
|
+
if (res.source === "none") return fail(`stage: ${res.gap}`);
|
|
9770
|
+
const cfg = res.config ?? res.derived.config;
|
|
8890
9771
|
const hold = stageKeepAlive();
|
|
8891
9772
|
try {
|
|
8892
9773
|
const result = await runStage(cfg, { timeoutMs: Number(o.timeoutMs || 6e4) });
|
|
8893
|
-
|
|
9774
|
+
const reportUrl = reportedStageUrl(res, result);
|
|
9775
|
+
const url = reportUrl ? ` \u2014 ${reportUrl}` : "";
|
|
9776
|
+
return printLine(o.json ? JSON.stringify({ ...result, source: res.source, url: reportUrl }) : `mmi-cli stage: ${result.message}${url}`);
|
|
8894
9777
|
} catch (e) {
|
|
8895
9778
|
return fail(`stage: ${e.message}`);
|
|
8896
9779
|
} finally {
|
|
8897
9780
|
clearTimeout(hold);
|
|
8898
9781
|
}
|
|
8899
9782
|
}
|
|
8900
|
-
const steps =
|
|
8901
|
-
|
|
9783
|
+
const steps = stageStepsFor(res);
|
|
9784
|
+
if (o.json) return console.log(JSON.stringify({ command: "stage", source: res.source, url: res.derived?.url, staleIgnored: res.staleIgnored, staleFields: res.staleFields, registryError: res.registryError, steps }, null, 2));
|
|
9785
|
+
const note = staleStageNote(res);
|
|
9786
|
+
if (note) printLine(note);
|
|
9787
|
+
console.log(renderSteps("mmi-cli stage: dry-run plan", steps));
|
|
8902
9788
|
});
|
|
8903
9789
|
stage.command("stop").description("stop the previous local stage process recorded in tmp/stage/state.json").option("--json", "machine-readable output").option("--apply", "kill the recorded process tree and remove the state file").action(async () => {
|
|
8904
9790
|
const o = { json: rawFlag("--json"), apply: rawFlag("--apply") };
|
|
@@ -8915,11 +9801,16 @@ stage.command("stop").description("stop the previous local stage process recorde
|
|
|
8915
9801
|
});
|
|
8916
9802
|
stage.command("start").description("start the configured local stage process and optionally wait for health").option("--json", "machine-readable output").option("--apply", "start the configured stage.up process").option("--timeout-ms <ms>", "bounded health timeout", "60000").action(async () => {
|
|
8917
9803
|
const o = { json: rawFlag("--json"), apply: rawFlag("--apply"), timeoutMs: rawValue("--timeout-ms", "60000") };
|
|
8918
|
-
const
|
|
9804
|
+
const res = await resolveStage();
|
|
8919
9805
|
if (!o.apply) {
|
|
8920
|
-
const steps =
|
|
8921
|
-
|
|
8922
|
-
|
|
9806
|
+
const steps = stageStepsFor(res, false);
|
|
9807
|
+
if (o.json) return printLine(JSON.stringify({ command: "stage start", source: res.source, url: res.derived?.url, staleIgnored: res.staleIgnored, staleFields: res.staleFields, registryError: res.registryError, steps }, null, 2));
|
|
9808
|
+
const note = staleStageNote(res);
|
|
9809
|
+
if (note) printLine(note);
|
|
9810
|
+
return printLine(renderSteps("mmi-cli stage start: dry-run plan", steps));
|
|
9811
|
+
}
|
|
9812
|
+
if (res.source === "none") return fail(`stage start: ${res.gap}`);
|
|
9813
|
+
const cfg = res.config ?? res.derived.config;
|
|
8923
9814
|
try {
|
|
8924
9815
|
const hold = stageKeepAlive();
|
|
8925
9816
|
let printed = false;
|
|
@@ -8928,10 +9819,12 @@ stage.command("start").description("start the configured local stage process and
|
|
|
8928
9819
|
timeoutMs: Number(o.timeoutMs || 6e4),
|
|
8929
9820
|
onReady: (ready) => {
|
|
8930
9821
|
printed = true;
|
|
8931
|
-
|
|
9822
|
+
const reportUrl = reportedStageUrl(res, ready);
|
|
9823
|
+
const url = reportUrl ? ` \u2014 ${reportUrl}` : "";
|
|
9824
|
+
printLine(o.json ? JSON.stringify({ ...ready, source: res.source, url: reportUrl }) : `mmi-cli stage start: ${ready.message}${url}`);
|
|
8932
9825
|
}
|
|
8933
9826
|
});
|
|
8934
|
-
if (!printed) printLine(o.json ? JSON.stringify(result) : `mmi-cli stage start: ${result.message}`);
|
|
9827
|
+
if (!printed) printLine(o.json ? JSON.stringify({ ...result, source: res.source, url: reportedStageUrl(res, result) }) : `mmi-cli stage start: ${result.message}`);
|
|
8935
9828
|
} finally {
|
|
8936
9829
|
clearTimeout(hold);
|
|
8937
9830
|
}
|
|
@@ -8941,11 +9834,16 @@ stage.command("start").description("start the configured local stage process and
|
|
|
8941
9834
|
});
|
|
8942
9835
|
stage.command("run").description("force-stop previous stage, build, start, and health-check").option("--json", "machine-readable output").option("--apply", "run the configured stage sequence").option("--timeout-ms <ms>", "bounded build/health timeout", "60000").action(async () => {
|
|
8943
9836
|
const o = { json: rawFlag("--json"), apply: rawFlag("--apply"), timeoutMs: rawValue("--timeout-ms", "60000") };
|
|
8944
|
-
const
|
|
9837
|
+
const res = await resolveStage();
|
|
8945
9838
|
if (!o.apply) {
|
|
8946
|
-
const steps =
|
|
8947
|
-
|
|
8948
|
-
|
|
9839
|
+
const steps = stageStepsFor(res);
|
|
9840
|
+
if (o.json) return printLine(JSON.stringify({ command: "stage run", source: res.source, url: res.derived?.url, staleIgnored: res.staleIgnored, staleFields: res.staleFields, registryError: res.registryError, steps }, null, 2));
|
|
9841
|
+
const note = staleStageNote(res);
|
|
9842
|
+
if (note) printLine(note);
|
|
9843
|
+
return printLine(renderSteps("mmi-cli stage run: dry-run plan", steps));
|
|
9844
|
+
}
|
|
9845
|
+
if (res.source === "none") return fail(`stage run: ${res.gap}`);
|
|
9846
|
+
const cfg = res.config ?? res.derived.config;
|
|
8949
9847
|
try {
|
|
8950
9848
|
const hold = stageKeepAlive();
|
|
8951
9849
|
let printed = false;
|
|
@@ -8953,12 +9851,14 @@ stage.command("run").description("force-stop previous stage, build, start, and h
|
|
|
8953
9851
|
const result = await runStage(cfg, {
|
|
8954
9852
|
timeoutMs: Number(o.timeoutMs || 6e4),
|
|
8955
9853
|
onReady: (ready) => {
|
|
8956
|
-
const
|
|
9854
|
+
const reportUrl = reportedStageUrl(res, ready);
|
|
9855
|
+
const url = reportUrl ? ` \u2014 ${reportUrl}` : "";
|
|
9856
|
+
const runReady = { ...ready, action: "run", source: res.source, url: reportUrl, message: `built and ${ready.message}${url}` };
|
|
8957
9857
|
printed = true;
|
|
8958
9858
|
printLine(o.json ? JSON.stringify(runReady) : `mmi-cli stage run: ${runReady.message}`);
|
|
8959
9859
|
}
|
|
8960
9860
|
});
|
|
8961
|
-
if (!printed) printLine(o.json ? JSON.stringify(result) : `mmi-cli stage run: ${result.message}`);
|
|
9861
|
+
if (!printed) printLine(o.json ? JSON.stringify({ ...result, source: res.source, url: reportedStageUrl(res, result) }) : `mmi-cli stage run: ${result.message}`);
|
|
8962
9862
|
} finally {
|
|
8963
9863
|
clearTimeout(hold);
|
|
8964
9864
|
}
|
|
@@ -8971,8 +9871,37 @@ program2.command("stage-live").description("explain that remote rc/live environm
|
|
|
8971
9871
|
const steps = stageLivePlan();
|
|
8972
9872
|
console.log(o.json ? JSON.stringify({ command: "stage-live", steps }, null, 2) : renderSteps("mmi-cli stage-live: not an org command", steps));
|
|
8973
9873
|
});
|
|
9874
|
+
var GH_TRAIN_TIMEOUT_MS = 3e4;
|
|
9875
|
+
var GH_RUN_WATCH_TIMEOUT_MS = 20 * 6e4;
|
|
9876
|
+
function trainApplyDeps() {
|
|
9877
|
+
return {
|
|
9878
|
+
run: async (file, args) => {
|
|
9879
|
+
const timeout = file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
|
|
9880
|
+
return (await execFileP4(file, args, { timeout })).stdout;
|
|
9881
|
+
},
|
|
9882
|
+
runSelf: async (args) => (await execFileP4(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
|
|
9883
|
+
trainAuthority: async (repo) => {
|
|
9884
|
+
const verdict = await fetchTrainAuthority(repo, registryClientDeps(await loadConfig()));
|
|
9885
|
+
return verdict.ok ? { ok: true, role: verdict.authority.role, train: verdict.authority.train } : verdict;
|
|
9886
|
+
}
|
|
9887
|
+
};
|
|
9888
|
+
}
|
|
9889
|
+
function renderDeployLine(d) {
|
|
9890
|
+
const parts = [d.dispatch];
|
|
9891
|
+
if (d.runUrl) parts.push(`run ${d.runUrl}`);
|
|
9892
|
+
if (d.deployStatus === "success") parts.push("deploy: SUCCEEDED");
|
|
9893
|
+
else if (d.deployStatus === "failure") parts.push("deploy: FAILED (promotion stands; retry the deploy, do not re-tag)");
|
|
9894
|
+
else if (d.runId != null) parts.push(`deploy: dispatched (watch: gh run watch ${d.runId} --repo mutmutco/MMI-Hub --exit-status)`);
|
|
9895
|
+
return parts.join("; ");
|
|
9896
|
+
}
|
|
9897
|
+
function renderTrainApply(commandName, r) {
|
|
9898
|
+
return `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
|
|
9899
|
+
}
|
|
9900
|
+
function renderTenantRedeploy(r) {
|
|
9901
|
+
return `mmi-cli tenant redeploy: ${r.repo} ${r.stage} (ref=${r.ref}) [${r.deployModel}]; ${renderDeployLine(r)}`;
|
|
9902
|
+
}
|
|
8974
9903
|
for (const commandName of ["rcand", "release", "hotfix"]) {
|
|
8975
|
-
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--apply", commandName === "hotfix" ? "reserved; hotfix uses the /hotfix skill PR path" : "execute the guarded master-only train after explicit approval").action(async (o) => {
|
|
9904
|
+
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the dispatched tenant-deploy.yml run and report its outcome (tenant-container)").option("--apply", commandName === "hotfix" ? "reserved; hotfix uses the /hotfix skill PR path" : "execute the guarded master-only train after explicit approval").action(async (o) => {
|
|
8976
9905
|
try {
|
|
8977
9906
|
await requireFreshTrainCli(commandName);
|
|
8978
9907
|
} catch (e) {
|
|
@@ -8981,16 +9910,8 @@ for (const commandName of ["rcand", "release", "hotfix"]) {
|
|
|
8981
9910
|
if (o.apply) {
|
|
8982
9911
|
if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
|
|
8983
9912
|
try {
|
|
8984
|
-
const result = await runTrainApply(commandName, {
|
|
8985
|
-
|
|
8986
|
-
runSelf: async (args) => (await execFileP4(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
|
|
8987
|
-
trainAuthority: async (repo) => {
|
|
8988
|
-
const verdict = await fetchTrainAuthority(repo, registryClientDeps(await loadConfig()));
|
|
8989
|
-
return verdict.ok ? { ok: true, role: verdict.authority.role, train: verdict.authority.train } : verdict;
|
|
8990
|
-
}
|
|
8991
|
-
});
|
|
8992
|
-
const message = `mmi-cli ${commandName}: applied ${result.repo} ${result.stage} train at ${result.tag} [${result.deployModel}]; ${result.dispatch}`;
|
|
8993
|
-
return printLine(o.json ? JSON.stringify(result, null, 2) : message);
|
|
9913
|
+
const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch });
|
|
9914
|
+
return printLine(o.json ? JSON.stringify(result, null, 2) : renderTrainApply(commandName, result));
|
|
8994
9915
|
} catch (e) {
|
|
8995
9916
|
return fail(`${commandName}: ${e.message}`);
|
|
8996
9917
|
}
|
|
@@ -9010,13 +9931,14 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
|
|
|
9010
9931
|
const o = { class: rawValue("--class", "deployable"), json: rawFlag("--json") };
|
|
9011
9932
|
if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
|
|
9012
9933
|
const cfg = await loadConfig();
|
|
9013
|
-
const
|
|
9934
|
+
const reg = registryClientDeps(cfg);
|
|
9935
|
+
const apiProjects = await fetchProjectsJson(reg);
|
|
9014
9936
|
const slug = (repo.includes("/") ? repo.split("/")[1] : repo).toLowerCase();
|
|
9015
|
-
const meta = await fetchProjectBySlug(slug,
|
|
9937
|
+
const meta = await fetchProjectBySlug(slug, reg);
|
|
9016
9938
|
const report = await verifyBootstrap(repo, o.class, {
|
|
9017
9939
|
client: defaultGitHubClient(),
|
|
9018
9940
|
projectMeta: meta,
|
|
9019
|
-
readLocalFile: (path2) => path2 === "projects.json" && apiProjects != null ? apiProjects : (0,
|
|
9941
|
+
readLocalFile: (path2) => path2 === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs6.existsSync)(path2) ? (0, import_node_fs6.readFileSync)(path2, "utf8") : null,
|
|
9020
9942
|
// requiredGcpApis is stored as an array by a JSON write, but `project set --var KEY=VALUE` stores a raw
|
|
9021
9943
|
// comma-string — accept either so the seeded value verifies regardless of how it was written.
|
|
9022
9944
|
requiredGcpApis: (() => {
|
|
@@ -9057,12 +9979,12 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
9057
9979
|
return fail(`bootstrap apply: ${e.message}`);
|
|
9058
9980
|
}
|
|
9059
9981
|
const manifestPath = "skills/bootstrap/seeds/manifest.json";
|
|
9060
|
-
if (!(0,
|
|
9061
|
-
const manifest = loadBootstrapSeeds((0,
|
|
9982
|
+
if (!(0, import_node_fs6.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; run from the MMI-Hub repo root`);
|
|
9983
|
+
const manifest = loadBootstrapSeeds((0, import_node_fs6.readFileSync)(manifestPath, "utf8"));
|
|
9062
9984
|
const baseBranch = o.class === "content" ? "main" : "development";
|
|
9063
9985
|
const slug = parsedRepo.slug;
|
|
9064
9986
|
const gh = async (args) => execFileP4("gh", args, { timeout: 2e4 });
|
|
9065
|
-
const readFile2 = (p) => (0,
|
|
9987
|
+
const readFile2 = (p) => (0, import_node_fs6.existsSync)(p) ? (0, import_node_fs6.readFileSync)(p, "utf8") : null;
|
|
9066
9988
|
const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
|
|
9067
9989
|
const vars = {};
|
|
9068
9990
|
for (const value of rawValues("--var")) {
|
|
@@ -9135,7 +10057,7 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
9135
10057
|
}
|
|
9136
10058
|
if (o.execute) {
|
|
9137
10059
|
const cfg = await loadConfig();
|
|
9138
|
-
const res = await registerProject(registerPayload,
|
|
10060
|
+
const res = await registerProject(registerPayload, registryClientDeps(cfg));
|
|
9139
10061
|
if (res.ok) {
|
|
9140
10062
|
ddbWrites.push({ slug: registerPayload.slug, action: "register", record: registerPayload });
|
|
9141
10063
|
applied.push(`ddb register ${registerPayload.slug}`);
|
|
@@ -9186,16 +10108,16 @@ access.command("audit").description("audit collaborator roles + train-branch pus
|
|
|
9186
10108
|
if (o.class !== "deployable" && o.class !== "content") return fail("access audit: --class must be deployable or content");
|
|
9187
10109
|
targets = [{ repo: o.repo, class: o.class }];
|
|
9188
10110
|
} else {
|
|
9189
|
-
const projectsJson = registryProjects ? JSON.stringify({ projects: registryProjects }) : (0,
|
|
10111
|
+
const projectsJson = registryProjects ? JSON.stringify({ projects: registryProjects }) : (0, import_node_fs6.existsSync)("projects.json") ? (0, import_node_fs6.readFileSync)("projects.json", "utf8") : null;
|
|
9190
10112
|
if (!projectsJson) return fail("access audit: no project registry \u2014 Hub API unreachable and projects.json not found; run from the MMI-Hub repo root or pass --repo <owner/repo>");
|
|
9191
|
-
const fanoutJson = (0,
|
|
10113
|
+
const fanoutJson = (0, import_node_fs6.existsSync)(".github/fanout-targets.json") ? (0, import_node_fs6.readFileSync)(".github/fanout-targets.json", "utf8") : null;
|
|
9192
10114
|
targets = loadAccessTargets(projectsJson, fanoutJson);
|
|
9193
10115
|
}
|
|
9194
10116
|
const derivedMatrix = registryProjects ? accessMatrixFromProjects(registryProjects) : {};
|
|
9195
|
-
const fileMatrix = (0,
|
|
10117
|
+
const fileMatrix = (0, import_node_fs6.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs6.readFileSync)("access-matrix.json", "utf8")) : {};
|
|
9196
10118
|
const matrix = mergeAccessMatrix(fileMatrix, derivedMatrix);
|
|
9197
10119
|
const derivedContracts = registryProjects ? dataAccessContractsFromProjects(registryProjects) : { consumers: {} };
|
|
9198
|
-
const fileContracts = (0,
|
|
10120
|
+
const fileContracts = (0, import_node_fs6.existsSync)("data-access-contracts.json") ? loadDataAccessContracts((0, import_node_fs6.readFileSync)("data-access-contracts.json", "utf8")) : { consumers: {} };
|
|
9199
10121
|
const dataAccess = mergeDataAccessContracts(fileContracts, derivedContracts);
|
|
9200
10122
|
const report = await auditOrgAccess(targets, deps, matrix, dataAccess);
|
|
9201
10123
|
console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
|
|
@@ -9204,18 +10126,18 @@ access.command("audit").description("audit collaborator roles + train-branch pus
|
|
|
9204
10126
|
var isWin = process.platform === "win32";
|
|
9205
10127
|
var installedPluginsPath = (surface = detectSurface(process.env)) => {
|
|
9206
10128
|
const homeDir = surface === "codex" ? ".codex" : ".claude";
|
|
9207
|
-
return (0,
|
|
10129
|
+
return (0, import_node_path7.join)((0, import_node_os3.homedir)(), homeDir, "plugins", "installed_plugins.json");
|
|
9208
10130
|
};
|
|
9209
10131
|
function readInstalledPlugins() {
|
|
9210
10132
|
try {
|
|
9211
|
-
return JSON.parse((0,
|
|
10133
|
+
return JSON.parse((0, import_node_fs6.readFileSync)(installedPluginsPath(), "utf8"));
|
|
9212
10134
|
} catch {
|
|
9213
10135
|
return null;
|
|
9214
10136
|
}
|
|
9215
10137
|
}
|
|
9216
10138
|
function readClaudeSettings() {
|
|
9217
10139
|
try {
|
|
9218
|
-
return JSON.parse((0,
|
|
10140
|
+
return JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path7.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
|
|
9219
10141
|
} catch {
|
|
9220
10142
|
return null;
|
|
9221
10143
|
}
|
|
@@ -9237,7 +10159,7 @@ function writeProjectInstallRecord(record) {
|
|
|
9237
10159
|
const list = file.plugins[MMI_PLUGIN_ID] ?? [];
|
|
9238
10160
|
list.push(record);
|
|
9239
10161
|
file.plugins[MMI_PLUGIN_ID] = list;
|
|
9240
|
-
(0,
|
|
10162
|
+
(0, import_node_fs6.writeFileSync)(installedPluginsPath(), `${JSON.stringify(file, null, 2)}
|
|
9241
10163
|
`, "utf8");
|
|
9242
10164
|
return true;
|
|
9243
10165
|
} catch {
|
|
@@ -9250,9 +10172,9 @@ function backupAndWriteInstalledPlugins(records, pluginId) {
|
|
|
9250
10172
|
if (!file) return false;
|
|
9251
10173
|
if (!file.plugins) file.plugins = {};
|
|
9252
10174
|
const path2 = installedPluginsPath();
|
|
9253
|
-
(0,
|
|
10175
|
+
(0, import_node_fs6.copyFileSync)(path2, `${path2}.bak`);
|
|
9254
10176
|
file.plugins[pluginId] = records;
|
|
9255
|
-
(0,
|
|
10177
|
+
(0, import_node_fs6.writeFileSync)(path2, `${JSON.stringify(file, null, 2)}
|
|
9256
10178
|
`, "utf8");
|
|
9257
10179
|
return true;
|
|
9258
10180
|
} catch {
|
|
@@ -9261,14 +10183,14 @@ function backupAndWriteInstalledPlugins(records, pluginId) {
|
|
|
9261
10183
|
}
|
|
9262
10184
|
function mmiPluginCacheRootSnapshots() {
|
|
9263
10185
|
const roots = [
|
|
9264
|
-
{ surface: "claude", root: (0,
|
|
9265
|
-
{ surface: "codex", root: (0,
|
|
10186
|
+
{ surface: "claude", root: (0, import_node_path7.join)((0, import_node_os3.homedir)(), ".claude", "plugins", "cache", "mmi", "mmi") },
|
|
10187
|
+
{ surface: "codex", root: (0, import_node_path7.join)((0, import_node_os3.homedir)(), ".codex", "plugins", "cache", "mmi", "mmi") }
|
|
9266
10188
|
];
|
|
9267
10189
|
return roots.flatMap(({ surface, root }) => {
|
|
9268
10190
|
try {
|
|
9269
|
-
const entries = (0,
|
|
10191
|
+
const entries = (0, import_node_fs6.readdirSync)(root, { withFileTypes: true }).map((entry) => ({
|
|
9270
10192
|
name: entry.name,
|
|
9271
|
-
path: (0,
|
|
10193
|
+
path: (0, import_node_path7.join)(root, entry.name),
|
|
9272
10194
|
isDirectory: entry.isDirectory()
|
|
9273
10195
|
}));
|
|
9274
10196
|
return [{ surface, root, entries }];
|
|
@@ -9278,10 +10200,10 @@ function mmiPluginCacheRootSnapshots() {
|
|
|
9278
10200
|
});
|
|
9279
10201
|
}
|
|
9280
10202
|
function uniqueQuarantineTarget(path2) {
|
|
9281
|
-
if (!(0,
|
|
10203
|
+
if (!(0, import_node_fs6.existsSync)(path2)) return path2;
|
|
9282
10204
|
for (let i = 1; i < 100; i += 1) {
|
|
9283
10205
|
const candidate = `${path2}-${i}`;
|
|
9284
|
-
if (!(0,
|
|
10206
|
+
if (!(0, import_node_fs6.existsSync)(candidate)) return candidate;
|
|
9285
10207
|
}
|
|
9286
10208
|
return `${path2}-${Date.now()}`;
|
|
9287
10209
|
}
|
|
@@ -9289,27 +10211,27 @@ function quarantinePluginCacheDirs(plan2) {
|
|
|
9289
10211
|
let moved = 0;
|
|
9290
10212
|
for (const move of plan2) {
|
|
9291
10213
|
try {
|
|
9292
|
-
if (!(0,
|
|
10214
|
+
if (!(0, import_node_fs6.existsSync)(move.from)) continue;
|
|
9293
10215
|
const target = uniqueQuarantineTarget(move.to);
|
|
9294
|
-
(0,
|
|
9295
|
-
(0,
|
|
10216
|
+
(0, import_node_fs6.mkdirSync)((0, import_node_path7.dirname)(target), { recursive: true });
|
|
10217
|
+
(0, import_node_fs6.renameSync)(move.from, target);
|
|
9296
10218
|
moved += 1;
|
|
9297
10219
|
} catch {
|
|
9298
10220
|
}
|
|
9299
10221
|
}
|
|
9300
10222
|
return moved;
|
|
9301
10223
|
}
|
|
9302
|
-
var gitignorePath = () => (0,
|
|
10224
|
+
var gitignorePath = () => (0, import_node_path7.join)(process.cwd(), ".gitignore");
|
|
9303
10225
|
function readGitignore() {
|
|
9304
10226
|
try {
|
|
9305
|
-
return (0,
|
|
10227
|
+
return (0, import_node_fs6.readFileSync)(gitignorePath(), "utf8");
|
|
9306
10228
|
} catch {
|
|
9307
10229
|
return null;
|
|
9308
10230
|
}
|
|
9309
10231
|
}
|
|
9310
10232
|
function writeGitignore(content) {
|
|
9311
10233
|
try {
|
|
9312
|
-
(0,
|
|
10234
|
+
(0, import_node_fs6.writeFileSync)(gitignorePath(), content, "utf8");
|
|
9313
10235
|
return true;
|
|
9314
10236
|
} catch {
|
|
9315
10237
|
return false;
|
|
@@ -9345,7 +10267,7 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
9345
10267
|
let onPath = pathProbe;
|
|
9346
10268
|
if (!onPath) {
|
|
9347
10269
|
const root = process.env.CLAUDE_PLUGIN_ROOT;
|
|
9348
|
-
if (root && (0,
|
|
10270
|
+
if (root && (0, import_node_fs6.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
|
|
9349
10271
|
}
|
|
9350
10272
|
checks.push({ ok: onPath, label: "mmi-cli on PATH", fix: "auto-provisioned at session start \u2014 reopen the session, or install the MMI plugin" });
|
|
9351
10273
|
const surface = detectSurface(process.env);
|
|
@@ -9390,7 +10312,12 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
9390
10312
|
if (!gitignoreCheck.ok && gitignoreCheck.contentToWrite && !opts.json && !opts.banner) {
|
|
9391
10313
|
if (writeGitignore(gitignoreCheck.contentToWrite)) {
|
|
9392
10314
|
gitignoreCheck = { ...gitignoreCheck, ok: true };
|
|
9393
|
-
|
|
10315
|
+
const drift = gitignoreCheck.seeded ? "inserted the org-managed block" : [
|
|
10316
|
+
gitignoreCheck.added?.length ? `added ${gitignoreCheck.added.join(", ")}` : "",
|
|
10317
|
+
gitignoreCheck.removed?.length ? `removed ${gitignoreCheck.removed.join(", ")}` : ""
|
|
10318
|
+
].filter(Boolean).join("; ") || "normalized the block";
|
|
10319
|
+
io.err(` \u21BB repaired: org-managed .gitignore block \u2014 ${drift}`);
|
|
10320
|
+
io.err(" this is an org-managed update (not unrelated churn) \u2014 stage & commit .gitignore so it stops recurring");
|
|
9394
10321
|
}
|
|
9395
10322
|
}
|
|
9396
10323
|
checks.push(gitignoreCheck);
|
|
@@ -9462,24 +10389,22 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
9462
10389
|
${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
|
|
9463
10390
|
}
|
|
9464
10391
|
program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, Hub API default, plugin git clone, plugin install record, .gitignore managed block, plugin config drift, installed plugin version) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output").action((opts) => runDoctor(opts));
|
|
9465
|
-
program2.command("session-start").description("run the SessionStart verbs (rules
|
|
10392
|
+
program2.command("session-start").description("run the SessionStart verbs (rules sync, saga session+show, saga health, doctor) in one process; docs sync runs detached").action(async () => {
|
|
9466
10393
|
try {
|
|
9467
10394
|
const hook = parseHookInput(await readStdin());
|
|
9468
10395
|
if (hook.session_id) persistSession(hook.session_id);
|
|
9469
10396
|
} catch (e) {
|
|
9470
10397
|
console.error(`[mmi-hook] saga session failed: ${e.message}`);
|
|
9471
10398
|
}
|
|
9472
|
-
|
|
9473
|
-
|
|
9474
|
-
|
|
9475
|
-
|
|
9476
|
-
|
|
9477
|
-
|
|
9478
|
-
|
|
9479
|
-
const sequential = [
|
|
9480
|
-
{ name: "doctor", run: (io) => runDoctor({ banner: true }, io) }
|
|
9481
|
-
];
|
|
10399
|
+
spawnDetachedSelf(["docs", "sync", "--quiet"], { spawn: import_node_child_process6.spawn, execPath: process.execPath, scriptPath: process.argv[1] });
|
|
10400
|
+
const { parallel, sequential } = buildSessionStartPlan({
|
|
10401
|
+
rulesSync: (io) => runRulesSync({ quiet: true }, io),
|
|
10402
|
+
sagaShow: (io) => runSagaShow({ quiet: true }, io),
|
|
10403
|
+
sagaHealth: (io) => runSagaHealth({ banner: true, quiet: true }, io),
|
|
10404
|
+
doctor: (io) => runDoctor({ banner: true }, io)
|
|
10405
|
+
});
|
|
9482
10406
|
await runSessionStart(parallel, sequential, consoleIo);
|
|
10407
|
+
consoleIo.log(northstarPointer());
|
|
9483
10408
|
});
|
|
9484
10409
|
function fail(msg) {
|
|
9485
10410
|
console.error(`mmi-cli ${msg}`);
|