@mutmutco/cli 2.17.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +971 -250
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -3525,7 +3525,7 @@ function parseHookInput(stdin) {
|
|
|
3525
3525
|
}
|
|
3526
3526
|
|
|
3527
3527
|
// src/index.ts
|
|
3528
|
-
var
|
|
3528
|
+
var import_node_child_process7 = require("node:child_process");
|
|
3529
3529
|
var import_node_util6 = require("node:util");
|
|
3530
3530
|
var import_node_path8 = require("node:path");
|
|
3531
3531
|
var import_node_os3 = require("node:os");
|
|
@@ -5106,6 +5106,42 @@ function ghError(e) {
|
|
|
5106
5106
|
}
|
|
5107
5107
|
|
|
5108
5108
|
// src/gc.ts
|
|
5109
|
+
var WORKTREE_LOCK_RE = /EPERM|EBUSY|EACCES|ENOTEMPTY|permission denied|access is denied|used by another process|resource busy|directory not empty/i;
|
|
5110
|
+
function isWorktreeLockError(error) {
|
|
5111
|
+
return WORKTREE_LOCK_RE.test(error instanceof Error ? error.message : String(error));
|
|
5112
|
+
}
|
|
5113
|
+
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5114
|
+
async function removeWorktreeWithRecovery(wtPath, deps) {
|
|
5115
|
+
const maxAttempts = deps.maxAttempts ?? 3;
|
|
5116
|
+
const backoff = deps.backoffMs ?? [250, 1e3];
|
|
5117
|
+
let attempts = 0;
|
|
5118
|
+
let lastError;
|
|
5119
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
5120
|
+
attempts++;
|
|
5121
|
+
try {
|
|
5122
|
+
await deps.git(["worktree", "remove", "--force", wtPath]);
|
|
5123
|
+
return { status: "removed", attempts, recovery: i > 0 ? "retry" : void 0 };
|
|
5124
|
+
} catch (e) {
|
|
5125
|
+
lastError = e;
|
|
5126
|
+
const retriesLeft = i < maxAttempts - 1;
|
|
5127
|
+
if (retriesLeft && isWorktreeLockError(e)) {
|
|
5128
|
+
await deps.sleep(backoff[Math.min(i, backoff.length - 1)]);
|
|
5129
|
+
continue;
|
|
5130
|
+
}
|
|
5131
|
+
break;
|
|
5132
|
+
}
|
|
5133
|
+
}
|
|
5134
|
+
if (deps.removeWorktreeDir) {
|
|
5135
|
+
try {
|
|
5136
|
+
await deps.removeWorktreeDir(wtPath);
|
|
5137
|
+
await deps.git(["worktree", "prune"]).catch(() => "");
|
|
5138
|
+
return { status: "removed", attempts, recovery: "fallback" };
|
|
5139
|
+
} catch (fallbackError) {
|
|
5140
|
+
lastError = fallbackError;
|
|
5141
|
+
}
|
|
5142
|
+
}
|
|
5143
|
+
return { status: "failed", attempts, error: errorMessage(lastError) };
|
|
5144
|
+
}
|
|
5109
5145
|
function buildRemoteBranchCleanupReport(branch, input) {
|
|
5110
5146
|
if (!input.attempted) return { name: branch, status: "not-attempted", reason: input.reason };
|
|
5111
5147
|
if (input.existsAfter === true) return { name: branch, status: "failed", reason: "still-present-after-delete" };
|
|
@@ -5145,6 +5181,14 @@ function buildPrMergeResultPayload(input) {
|
|
|
5145
5181
|
worktree: input.localCleanup.worktree
|
|
5146
5182
|
};
|
|
5147
5183
|
}
|
|
5184
|
+
function buildPrMergeArgs(input) {
|
|
5185
|
+
const args = ["pr", "merge", input.number, ...input.repoArgs, input.method, "--delete-branch"];
|
|
5186
|
+
if (input.auto) args.push("--auto");
|
|
5187
|
+
return args;
|
|
5188
|
+
}
|
|
5189
|
+
function basePolicyBlocksImmediateMerge(message) {
|
|
5190
|
+
return /base branch policy prohibits|protected branch|required status check|merge queue/i.test(message);
|
|
5191
|
+
}
|
|
5148
5192
|
async function checkRemoteBranchExists(branch, deps, options = {}) {
|
|
5149
5193
|
if (!branch) return void 0;
|
|
5150
5194
|
try {
|
|
@@ -5394,14 +5438,18 @@ async function cleanupPrMergeLocalBranch(branch, options) {
|
|
|
5394
5438
|
stageTeardown = { status: "failed", error: errorMessage(e) };
|
|
5395
5439
|
}
|
|
5396
5440
|
}
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5441
|
+
const outcome = await removeWorktreeWithRecovery(wtPath, {
|
|
5442
|
+
git,
|
|
5443
|
+
sleep: options.sleep ?? defaultSleep,
|
|
5444
|
+
removeWorktreeDir: options.removeWorktreeDir
|
|
5445
|
+
});
|
|
5446
|
+
if (outcome.status === "removed") {
|
|
5447
|
+
report.worktree = { path: wtPath, status: "removed", stageTeardown, recovery: outcome.recovery };
|
|
5448
|
+
} else {
|
|
5401
5449
|
report.worktree = {
|
|
5402
5450
|
path: wtPath,
|
|
5403
5451
|
status: "failed",
|
|
5404
|
-
error:
|
|
5452
|
+
error: outcome.error,
|
|
5405
5453
|
safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath),
|
|
5406
5454
|
stageTeardown
|
|
5407
5455
|
};
|
|
@@ -5529,10 +5577,11 @@ function rcandVersionStep(targets) {
|
|
|
5529
5577
|
}
|
|
5530
5578
|
function trainPlan(command, options = {}) {
|
|
5531
5579
|
const isHub = options.repo?.toLowerCase() === "mutmutco/mmi-hub";
|
|
5580
|
+
const isDirect = options.releaseTrack === "direct" || options.releaseTrack === void 0 && isHub;
|
|
5532
5581
|
if (command === "rcand") {
|
|
5533
|
-
if (
|
|
5582
|
+
if (isDirect) {
|
|
5534
5583
|
return [
|
|
5535
|
-
{ label: "
|
|
5584
|
+
{ label: "direct-track repos skip rc; use /release from development instead", command: "mmi-cli release --apply", gated: true }
|
|
5536
5585
|
];
|
|
5537
5586
|
}
|
|
5538
5587
|
return [
|
|
@@ -5541,23 +5590,22 @@ function trainPlan(command, options = {}) {
|
|
|
5541
5590
|
rcandVersionStep(options),
|
|
5542
5591
|
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
5543
5592
|
{ label: "preflight required rc secret names", command: "mmi-cli secrets preflight --stage rc --repo <owner/repo>", gated: true },
|
|
5544
|
-
{ 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; if missing: mmi-cli train prep --apply", gated: true },
|
|
5545
5593
|
{ label: "merge development to rc", gated: true },
|
|
5546
5594
|
{ label: "trigger the deploy path for this repo model, returning Hub Actions run id/url data (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, correlate/watch that run", gated: true },
|
|
5547
5595
|
{ 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 }
|
|
5548
5596
|
];
|
|
5549
5597
|
}
|
|
5550
5598
|
if (command === "release") {
|
|
5551
|
-
if (
|
|
5599
|
+
if (isDirect) {
|
|
5552
5600
|
return [
|
|
5553
5601
|
{ label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
|
|
5554
5602
|
{ label: "verify current branch is development", gated: true },
|
|
5555
5603
|
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
5556
5604
|
{ label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
|
|
5557
|
-
{ label: "Hub releases skip rc; verify the distribution bump is already landed on development", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
|
|
5558
5605
|
{ label: "merge development to main", gated: true },
|
|
5606
|
+
{ label: "fold the version bump into the release commit (Hub: full distribution set; app repos: root package manifest) \u2014 runs inside the apply step, no separate bump PR", gated: true },
|
|
5559
5607
|
{ label: "tag release and publish GitHub Release", gated: true },
|
|
5560
|
-
{ label: "trigger the
|
|
5608
|
+
{ label: "trigger the repo deploy path from the release event", command: "hub-serverless: deploy.yml + publish.yml auto-fire on the release; other models deploy via their own workflow", gated: true },
|
|
5561
5609
|
{ label: "roll development forward", gated: true }
|
|
5562
5610
|
];
|
|
5563
5611
|
}
|
|
@@ -5566,9 +5614,9 @@ function trainPlan(command, options = {}) {
|
|
|
5566
5614
|
{ label: "verify current branch is rc", gated: true },
|
|
5567
5615
|
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
5568
5616
|
{ label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
|
|
5569
|
-
{ label: "verify every main-only hotfix commit is covered by the rc candidate", command: "
|
|
5617
|
+
{ label: "verify every main-only hotfix commit is covered by the rc candidate (the guard runs automatically inside the apply step below; --ack <sha> overrides a verified, trailer-less port)", command: "mmi-cli release --apply [--ack <sha>]", gated: true },
|
|
5570
5618
|
{ label: "merge rc to main", gated: true },
|
|
5571
|
-
{ label: "
|
|
5619
|
+
{ label: "fold the version bump into the release commit (app repos: root package manifest) \u2014 runs inside the apply step, no separate bump PR", gated: true },
|
|
5572
5620
|
{ label: "tag release and publish GitHub Release", gated: true },
|
|
5573
5621
|
{ label: "trigger the deploy path for this repo model, returning Hub Actions run id/url data (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, correlate/watch those runs", gated: true },
|
|
5574
5622
|
{ label: "roll development forward", gated: true }
|
|
@@ -5578,7 +5626,7 @@ function trainPlan(command, options = {}) {
|
|
|
5578
5626
|
{ label: "verify the fix is merged on development (the only hotfix origin)", gated: true },
|
|
5579
5627
|
{ label: "branch hotfix from main and cherry-pick the dev commits", command: "git cherry-pick -x <dev-sha>", gated: true },
|
|
5580
5628
|
{ label: "land on main via PR, tag, deploy prod", gated: true },
|
|
5581
|
-
{ label: "
|
|
5629
|
+
{ label: "no residue: development already has the fix; the next /release fold + back-merge re-aligns version manifests" }
|
|
5582
5630
|
];
|
|
5583
5631
|
}
|
|
5584
5632
|
function bootstrapPlan(repo, repoClass) {
|
|
@@ -5599,10 +5647,13 @@ function bootstrapPlan(repo, repoClass) {
|
|
|
5599
5647
|
function shellFor(platform = process.platform) {
|
|
5600
5648
|
return platform === "win32" ? "powershell" : "bash";
|
|
5601
5649
|
}
|
|
5650
|
+
function isCentralContainerModel(model) {
|
|
5651
|
+
return model === "tenant-container" || model === "solo-container";
|
|
5652
|
+
}
|
|
5602
5653
|
function deriveStageGap(inputs) {
|
|
5603
5654
|
const missing = [];
|
|
5604
|
-
if (inputs.deployModel
|
|
5605
|
-
return `local stage default applies to
|
|
5655
|
+
if (!isCentralContainerModel(inputs.deployModel)) {
|
|
5656
|
+
return `local stage default applies to central-container repos only (tenant-container/solo-container; registry deployModel = ${inputs.deployModel ?? "unset"})`;
|
|
5606
5657
|
}
|
|
5607
5658
|
if (!inputs.hasCompose) missing.push("docker-compose.yml");
|
|
5608
5659
|
if (!inputs.hasEnvExample) missing.push(".env.example");
|
|
@@ -5657,9 +5708,9 @@ function stalePosixFields(config, shell2) {
|
|
|
5657
5708
|
}
|
|
5658
5709
|
function sanitizeLocalStage(local, stale) {
|
|
5659
5710
|
if (!stale.length) return local;
|
|
5660
|
-
const
|
|
5661
|
-
for (const field of stale) delete
|
|
5662
|
-
return
|
|
5711
|
+
const clean3 = { ...local };
|
|
5712
|
+
for (const field of stale) delete clean3[field];
|
|
5713
|
+
return clean3;
|
|
5663
5714
|
}
|
|
5664
5715
|
function staleNote(staleFields, outcome) {
|
|
5665
5716
|
const list = staleFields.join(", ");
|
|
@@ -6395,16 +6446,21 @@ async function runStage(config = {}, opts = {}) {
|
|
|
6395
6446
|
}
|
|
6396
6447
|
|
|
6397
6448
|
// src/project-model.ts
|
|
6398
|
-
var PROJECT_TYPES = ["web-app", "hub-service", "content", "desktop-game", "non-deployable"];
|
|
6399
|
-
var DEPLOY_MODELS = ["hub-serverless", "serverless", "tenant-container", "content", "none"];
|
|
6449
|
+
var PROJECT_TYPES = ["web-app", "hub-service", "content", "desktop-game", "non-deployable", "cli-tool", "worker"];
|
|
6450
|
+
var DEPLOY_MODELS = ["hub-serverless", "serverless", "tenant-container", "solo-container", "registry-publish", "content", "none"];
|
|
6451
|
+
var RELEASE_TRACKS = ["full", "direct", "trunk"];
|
|
6400
6452
|
var PROJECT_TYPE_SET = new Set(PROJECT_TYPES);
|
|
6401
6453
|
var DEPLOY_MODEL_SET = new Set(DEPLOY_MODELS);
|
|
6454
|
+
var RELEASE_TRACK_SET = new Set(RELEASE_TRACKS);
|
|
6402
6455
|
function isProjectType(value) {
|
|
6403
6456
|
return Boolean(value && PROJECT_TYPE_SET.has(value));
|
|
6404
6457
|
}
|
|
6405
6458
|
function isDeployModel(value) {
|
|
6406
6459
|
return Boolean(value && DEPLOY_MODEL_SET.has(value));
|
|
6407
6460
|
}
|
|
6461
|
+
function isReleaseTrack(value) {
|
|
6462
|
+
return Boolean(value && RELEASE_TRACK_SET.has(value));
|
|
6463
|
+
}
|
|
6408
6464
|
function repoIsHub(repo) {
|
|
6409
6465
|
return repo.toLowerCase().endsWith("/mmi-hub") || repo.toLowerCase() === "mmi-hub";
|
|
6410
6466
|
}
|
|
@@ -6413,6 +6469,8 @@ function resolveProjectTypeConfident(meta, repo) {
|
|
|
6413
6469
|
if (isProjectType(rawType)) return rawType;
|
|
6414
6470
|
if (meta?.class === "content" || meta?.deployModel === "content") return "content";
|
|
6415
6471
|
if (meta?.deployModel === "hub-serverless" || repoIsHub(repo)) return "hub-service";
|
|
6472
|
+
if (meta?.deployModel === "registry-publish") return "cli-tool";
|
|
6473
|
+
if (meta?.deployModel === "solo-container") return "worker";
|
|
6416
6474
|
if (meta?.deployModel === "none") return "non-deployable";
|
|
6417
6475
|
return void 0;
|
|
6418
6476
|
}
|
|
@@ -6426,10 +6484,22 @@ function resolveDeployModel(meta, repo) {
|
|
|
6426
6484
|
if (projectType === "content" || meta?.class === "content") return "content";
|
|
6427
6485
|
if (projectType === "hub-service" || repoIsHub(repo)) return "hub-serverless";
|
|
6428
6486
|
if (projectType === "desktop-game" || projectType === "non-deployable") return "none";
|
|
6487
|
+
if (projectType === "cli-tool") return "registry-publish";
|
|
6429
6488
|
return "tenant-container";
|
|
6430
6489
|
}
|
|
6431
6490
|
function projectTypeClearsWebProfile(projectType, deployModel) {
|
|
6432
|
-
return projectType === "content" || projectType === "desktop-game" || projectType === "non-deployable" || deployModel === "content" || deployModel === "none";
|
|
6491
|
+
return projectType === "content" || projectType === "desktop-game" || projectType === "non-deployable" || projectType === "cli-tool" || deployModel === "content" || deployModel === "none" || deployModel === "registry-publish";
|
|
6492
|
+
}
|
|
6493
|
+
function resolveReleaseTrack(meta) {
|
|
6494
|
+
const raw = typeof meta?.releaseTrack === "string" ? meta.releaseTrack : void 0;
|
|
6495
|
+
if (isReleaseTrack(raw)) return raw;
|
|
6496
|
+
if (meta?.class === "content" || meta?.deployModel === "content") return "trunk";
|
|
6497
|
+
return "full";
|
|
6498
|
+
}
|
|
6499
|
+
function branchesForTrack(track) {
|
|
6500
|
+
if (track === "trunk") return ["main"];
|
|
6501
|
+
if (track === "direct") return ["development", "main"];
|
|
6502
|
+
return ["development", "rc", "main"];
|
|
6433
6503
|
}
|
|
6434
6504
|
|
|
6435
6505
|
// src/train-apply.ts
|
|
@@ -6445,9 +6515,35 @@ function requireValue(value, label) {
|
|
|
6445
6515
|
if (!value) throw new Error(`${label} could not be resolved`);
|
|
6446
6516
|
return value;
|
|
6447
6517
|
}
|
|
6448
|
-
async function
|
|
6449
|
-
if (model
|
|
6450
|
-
|
|
6518
|
+
async function resolveFoldPaths(deps, model) {
|
|
6519
|
+
if (model === "hub-serverless") {
|
|
6520
|
+
return (await deps.run("node", ["scripts/release-distribution.mjs", "changed-files"])).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
6521
|
+
}
|
|
6522
|
+
try {
|
|
6523
|
+
await deps.run("git", ["cat-file", "-e", "origin/main:package.json"]);
|
|
6524
|
+
} catch {
|
|
6525
|
+
return [];
|
|
6526
|
+
}
|
|
6527
|
+
return ["package.json", "package-lock.json"];
|
|
6528
|
+
}
|
|
6529
|
+
async function foldReleaseVersion(deps, model, tag, foldPaths) {
|
|
6530
|
+
if (foldPaths.length === 0) return "no version manifest to fold \u2014 the tag is the version";
|
|
6531
|
+
const version = tag.replace(/^v/, "");
|
|
6532
|
+
if (model === "hub-serverless") {
|
|
6533
|
+
await deps.run("node", ["scripts/release-distribution.mjs", "prepare", version]);
|
|
6534
|
+
} else {
|
|
6535
|
+
await deps.run("npm", ["version", version, "--no-git-tag-version", "--allow-same-version"]);
|
|
6536
|
+
}
|
|
6537
|
+
for (const path2 of foldPaths) {
|
|
6538
|
+
await deps.run("git", ["add", "--", path2]).catch(() => void 0);
|
|
6539
|
+
}
|
|
6540
|
+
const staged = (await deps.run("git", ["diff", "--cached", "--name-only"])).trim();
|
|
6541
|
+
if (!staged) return `version fold: manifests already at ${version} \u2014 nothing committed`;
|
|
6542
|
+
await deps.run("git", ["commit", "-m", `chore(release): bump distribution to ${tag}`]);
|
|
6543
|
+
if (model === "hub-serverless") {
|
|
6544
|
+
await deps.run("node", ["scripts/release-distribution.mjs", "verify", version, "--skip-npm-view"]);
|
|
6545
|
+
}
|
|
6546
|
+
return `version fold: distribution bumped to ${version} in the release commit`;
|
|
6451
6547
|
}
|
|
6452
6548
|
async function verifyPublishedRelease(deps, repo, tag, expectedTarget, expectedSha) {
|
|
6453
6549
|
const out = await deps.run("gh", ["release", "view", tag, "--repo", repo, "--json", "tagName,targetCommitish"]);
|
|
@@ -6497,18 +6593,18 @@ async function predictMergeConflicts(deps, ours, theirs) {
|
|
|
6497
6593
|
return files;
|
|
6498
6594
|
}
|
|
6499
6595
|
}
|
|
6500
|
-
async function mergeWithSpineResolution(deps, sourceRef, label, resolve) {
|
|
6596
|
+
async function mergeWithSpineResolution(deps, sourceRef, label, resolve, extraTolerated = []) {
|
|
6501
6597
|
try {
|
|
6502
6598
|
await deps.run("git", ["merge", sourceRef, "--no-edit"]);
|
|
6503
6599
|
return;
|
|
6504
6600
|
} catch {
|
|
6505
6601
|
}
|
|
6506
6602
|
const unmerged = (await deps.run("git", ["diff", "--name-only", "--diff-filter=U"])).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
6507
|
-
const
|
|
6508
|
-
if (unmerged.length === 0 ||
|
|
6603
|
+
const blocking = unmerged.filter((f) => !isSpinePath(f) && !extraTolerated.includes(f));
|
|
6604
|
+
if (unmerged.length === 0 || blocking.length > 0) {
|
|
6509
6605
|
await deps.run("git", ["merge", "--abort"]);
|
|
6510
6606
|
throw new Error(
|
|
6511
|
-
unmerged.length === 0 ? `${label} merge failed without conflicted paths \u2014 merge aborted; inspect the repo state and rerun` : `${label} merge conflicts on non-spine path(s): ${
|
|
6607
|
+
unmerged.length === 0 ? `${label} merge failed without conflicted paths \u2014 merge aborted; inspect the repo state and rerun` : `${label} merge conflicts on non-spine path(s): ${blocking.join(", ")} \u2014 merge aborted (the train is misaligned; reconcile the branches via an approved alignment PR, then rerun)`
|
|
6512
6608
|
);
|
|
6513
6609
|
}
|
|
6514
6610
|
await deps.run("git", ["checkout", `--${resolve}`, "--", ...unmerged]);
|
|
@@ -6552,6 +6648,13 @@ var HUB_REPO2 = "mutmutco/MMI-Hub";
|
|
|
6552
6648
|
function isHubControlRepo(repo) {
|
|
6553
6649
|
return repo.toLowerCase() === HUB_REPO2.toLowerCase();
|
|
6554
6650
|
}
|
|
6651
|
+
async function loadProjectMeta(deps, ctx) {
|
|
6652
|
+
try {
|
|
6653
|
+
return JSON.parse(await deps.runSelf(["project", "get", ctx.repo]));
|
|
6654
|
+
} catch {
|
|
6655
|
+
return null;
|
|
6656
|
+
}
|
|
6657
|
+
}
|
|
6555
6658
|
var CORRELATE_ATTEMPTS = 5;
|
|
6556
6659
|
var CORRELATE_DELAY_MS = 1500;
|
|
6557
6660
|
var CORRELATE_SKEW_SLACK_MS = 1e4;
|
|
@@ -6561,7 +6664,7 @@ var TRAIN_PROTECTION_CONTEXTS_JQ = "[.contexts[]]";
|
|
|
6561
6664
|
var TRAIN_RULES_CONTEXTS_JQ = '[.[]|select(.type=="required_status_checks")|.parameters.required_status_checks[].context]';
|
|
6562
6665
|
var TRAIN_CHECK_ATTEMPTS = 40;
|
|
6563
6666
|
var TRAIN_CHECK_DELAY_MS = 15e3;
|
|
6564
|
-
async function
|
|
6667
|
+
async function correlateDispatchedRun(deps, workflow, since) {
|
|
6565
6668
|
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6566
6669
|
const threshold = since - CORRELATE_SKEW_SLACK_MS;
|
|
6567
6670
|
for (let attempt = 0; attempt < CORRELATE_ATTEMPTS; attempt++) {
|
|
@@ -6574,7 +6677,7 @@ async function correlateTenantRun(deps, since) {
|
|
|
6574
6677
|
"--repo",
|
|
6575
6678
|
HUB_REPO2,
|
|
6576
6679
|
"--workflow",
|
|
6577
|
-
|
|
6680
|
+
workflow,
|
|
6578
6681
|
"--limit",
|
|
6579
6682
|
"10",
|
|
6580
6683
|
"--json",
|
|
@@ -6589,6 +6692,12 @@ async function correlateTenantRun(deps, since) {
|
|
|
6589
6692
|
}
|
|
6590
6693
|
return {};
|
|
6591
6694
|
}
|
|
6695
|
+
function correlateTenantRun(deps, since) {
|
|
6696
|
+
return correlateDispatchedRun(deps, "tenant-deploy.yml", since);
|
|
6697
|
+
}
|
|
6698
|
+
function correlatePublishRun(deps, since) {
|
|
6699
|
+
return correlateDispatchedRun(deps, "tenant-publish.yml", since);
|
|
6700
|
+
}
|
|
6592
6701
|
async function correlateWorkflowRun(deps, args) {
|
|
6593
6702
|
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
6594
6703
|
const threshold = args.since - CORRELATE_SKEW_SLACK_MS;
|
|
@@ -6763,12 +6872,19 @@ async function resolveRcResumeTag(deps, base, sha) {
|
|
|
6763
6872
|
return { tag: newest, note };
|
|
6764
6873
|
}
|
|
6765
6874
|
async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince, autoRunHeadSha) {
|
|
6766
|
-
if (model === "tenant-container") {
|
|
6875
|
+
if (model === "tenant-container" || model === "solo-container") {
|
|
6876
|
+
const since = (deps.now ?? Date.now)();
|
|
6877
|
+
await deps.dispatchTenantDeploy({ repo: ctx.repo, slug: ctx.slug, ref, stage: stage2 });
|
|
6878
|
+
const { runId, runUrl } = await correlateTenantRun(deps, since);
|
|
6879
|
+
const deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
|
|
6880
|
+
return { note: `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`, runId, runUrl, deployStatus };
|
|
6881
|
+
}
|
|
6882
|
+
if (model === "registry-publish") {
|
|
6767
6883
|
const since = (deps.now ?? Date.now)();
|
|
6768
6884
|
await deps.run("gh", [
|
|
6769
6885
|
"workflow",
|
|
6770
6886
|
"run",
|
|
6771
|
-
"tenant-
|
|
6887
|
+
"tenant-publish.yml",
|
|
6772
6888
|
"--repo",
|
|
6773
6889
|
HUB_REPO2,
|
|
6774
6890
|
"-f",
|
|
@@ -6780,9 +6896,9 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince
|
|
|
6780
6896
|
"-f",
|
|
6781
6897
|
`stage=${stage2}`
|
|
6782
6898
|
]);
|
|
6783
|
-
const { runId, runUrl } = await
|
|
6899
|
+
const { runId, runUrl } = await correlatePublishRun(deps, since);
|
|
6784
6900
|
const deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
|
|
6785
|
-
return { note: `dispatched tenant-
|
|
6901
|
+
return { note: `dispatched tenant-publish.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`, runId, runUrl, deployStatus };
|
|
6786
6902
|
}
|
|
6787
6903
|
if (model === "hub-serverless") {
|
|
6788
6904
|
const 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)";
|
|
@@ -6813,13 +6929,7 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model, watch, autoRunSince
|
|
|
6813
6929
|
}
|
|
6814
6930
|
return { note: `no manual dispatch: ${model} repo deploys via its own push-triggered workflow`, deployStatus: "pending" };
|
|
6815
6931
|
}
|
|
6816
|
-
async function preflight(deps, ctx, stage2) {
|
|
6817
|
-
let meta = null;
|
|
6818
|
-
try {
|
|
6819
|
-
meta = JSON.parse(await deps.runSelf(["project", "get", ctx.repo]));
|
|
6820
|
-
} catch {
|
|
6821
|
-
meta = null;
|
|
6822
|
-
}
|
|
6932
|
+
async function preflight(deps, ctx, stage2, meta) {
|
|
6823
6933
|
const model = resolveDeployModel2(meta, ctx.repo);
|
|
6824
6934
|
if (model === "content") {
|
|
6825
6935
|
throw new Error(`${ctx.repo} is a content repo (deployModel=content) \u2014 the release train does not apply (trunk-based; PR to main)`);
|
|
@@ -6835,19 +6945,20 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6835
6945
|
const ctx = await buildTrainApplyContext(deps);
|
|
6836
6946
|
await requireCleanTree(deps);
|
|
6837
6947
|
await deps.run("git", ["fetch", "origin"]);
|
|
6948
|
+
const meta = await loadProjectMeta(deps, ctx);
|
|
6949
|
+
const directTrack = isHubControlRepo(ctx.repo) || resolveReleaseTrack(meta) === "direct";
|
|
6838
6950
|
if (command === "rcand") {
|
|
6839
6951
|
await requireBranch(deps, "development");
|
|
6952
|
+
if (directTrack) {
|
|
6953
|
+
throw new Error("direct-track repos release straight to main (no rc); run `mmi-cli release --apply` from development instead of rcand");
|
|
6954
|
+
}
|
|
6840
6955
|
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
6841
6956
|
ensurePositiveCount(
|
|
6842
6957
|
await deps.run("git", ["rev-list", "--count", "origin/rc..origin/development"]),
|
|
6843
6958
|
"nothing to promote: origin/development is not ahead of origin/rc"
|
|
6844
6959
|
);
|
|
6845
|
-
const deployModel2 = await preflight(deps, ctx, "rc");
|
|
6846
|
-
if (isHubControlRepo(ctx.repo) && deployModel2 === "hub-serverless") {
|
|
6847
|
-
throw new Error("MMI-Hub releases directly from development to main; run mmi-cli release --apply from development instead of rcand");
|
|
6848
|
-
}
|
|
6960
|
+
const deployModel2 = await preflight(deps, ctx, "rc", meta);
|
|
6849
6961
|
const releaseBase = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release base");
|
|
6850
|
-
await verifyHubDistributionVersion(deps, deployModel2, releaseBase);
|
|
6851
6962
|
await deps.run("git", ["checkout", "rc"]);
|
|
6852
6963
|
await deps.run("git", ["pull", "--ff-only", "origin", "rc"]);
|
|
6853
6964
|
await deps.run("git", ["merge", "development", "--no-edit"]);
|
|
@@ -6863,24 +6974,21 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6863
6974
|
const d2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch, autoRunSince2, rcSha);
|
|
6864
6975
|
return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, checks: checks2, resumeNote, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, workflowRuns: d2.workflowRuns, deployStatus: d2.deployStatus };
|
|
6865
6976
|
}
|
|
6866
|
-
if (
|
|
6977
|
+
if (directTrack) {
|
|
6867
6978
|
await requireBranch(deps, "development");
|
|
6868
6979
|
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
6869
6980
|
ensurePositiveCount(
|
|
6870
6981
|
await deps.run("git", ["rev-list", "--count", "origin/main..origin/development"]),
|
|
6871
6982
|
"nothing to release: origin/development is not ahead of origin/main"
|
|
6872
6983
|
);
|
|
6873
|
-
const deployModel2 = await preflight(deps, ctx, "main");
|
|
6874
|
-
if (deployModel2 !== "hub-serverless") {
|
|
6875
|
-
throw new Error(`MMI-Hub direct release requires deployModel=hub-serverless, got ${deployModel2}`);
|
|
6876
|
-
}
|
|
6984
|
+
const deployModel2 = await preflight(deps, ctx, "main", meta);
|
|
6877
6985
|
const tag2 = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "cycle"])), "release tag");
|
|
6878
|
-
await
|
|
6986
|
+
const foldPaths2 = await resolveFoldPaths(deps, deployModel2);
|
|
6879
6987
|
const predicted2 = await predictMergeConflicts(deps, "origin/main", "origin/development");
|
|
6880
|
-
const
|
|
6881
|
-
if (
|
|
6988
|
+
const predictedBlocking2 = predicted2.filter((f) => !isSpinePath(f) && !foldPaths2.includes(f));
|
|
6989
|
+
if (predictedBlocking2.length > 0) {
|
|
6882
6990
|
throw new Error(
|
|
6883
|
-
`development -> main merge would conflict on non-spine path(s): ${
|
|
6991
|
+
`development -> main merge would conflict on non-spine path(s): ${predictedBlocking2.join(", ")} \u2014 no merge was started. The train is misaligned: reconcile main and development via an approved alignment PR, then rerun release.`
|
|
6884
6992
|
);
|
|
6885
6993
|
}
|
|
6886
6994
|
await deps.run("git", ["checkout", "main"]);
|
|
@@ -6888,8 +6996,9 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6888
6996
|
if (predicted2.length === 0) {
|
|
6889
6997
|
await deps.run("git", ["merge", "development", "--no-edit"]);
|
|
6890
6998
|
} else {
|
|
6891
|
-
await mergeWithSpineResolution(deps, "development", "development -> main", "theirs");
|
|
6999
|
+
await mergeWithSpineResolution(deps, "development", "development -> main", "theirs", foldPaths2);
|
|
6892
7000
|
}
|
|
7001
|
+
const versionFold2 = await foldReleaseVersion(deps, deployModel2, tag2, foldPaths2);
|
|
6893
7002
|
const releaseSha2 = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
|
|
6894
7003
|
await ensureTagPushed(deps, tag2, releaseSha2);
|
|
6895
7004
|
const requiredChecks2 = await discoverRequiredCheckContexts(deps, ctx, "main");
|
|
@@ -6912,13 +7021,14 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6912
7021
|
deployModel: deployModel2,
|
|
6913
7022
|
promoted: true,
|
|
6914
7023
|
checks: checks2,
|
|
7024
|
+
versionFold: versionFold2,
|
|
6915
7025
|
dispatch: d2.note,
|
|
6916
7026
|
runId: d2.runId,
|
|
6917
7027
|
runUrl: d2.runUrl,
|
|
6918
7028
|
workflowRuns: d2.workflowRuns,
|
|
6919
7029
|
deployStatus: d2.deployStatus,
|
|
6920
7030
|
rcRetirement: "not-applicable",
|
|
6921
|
-
rcRetirementNote: "
|
|
7031
|
+
rcRetirementNote: "direct-track release skips rc; no rc runtime to retire",
|
|
6922
7032
|
announceNote: announceNote2,
|
|
6923
7033
|
release: { tag: tag2, url: releaseUrl2, targetSha: releaseSha2 }
|
|
6924
7034
|
};
|
|
@@ -6928,12 +7038,20 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6928
7038
|
await deps.run("git", ["rev-list", "--count", "origin/main..origin/rc"]),
|
|
6929
7039
|
"nothing to release: origin/rc is not ahead of origin/main"
|
|
6930
7040
|
);
|
|
6931
|
-
const deployModel = await preflight(deps, ctx, "main");
|
|
7041
|
+
const deployModel = await preflight(deps, ctx, "main", meta);
|
|
7042
|
+
const foldPaths = await resolveFoldPaths(deps, deployModel);
|
|
6932
7043
|
const predicted = await predictMergeConflicts(deps, "origin/main", "origin/rc");
|
|
6933
|
-
const
|
|
6934
|
-
if (
|
|
7044
|
+
const predictedBlocking = predicted.filter((f) => !isSpinePath(f) && !foldPaths.includes(f));
|
|
7045
|
+
if (predictedBlocking.length > 0) {
|
|
7046
|
+
throw new Error(
|
|
7047
|
+
`rc -> main merge would conflict on non-spine path(s): ${predictedBlocking.join(", ")} \u2014 no merge was started. The train is misaligned: reconcile main and rc via an approved alignment PR (do not hand-resolve on main), then rerun release.`
|
|
7048
|
+
);
|
|
7049
|
+
}
|
|
7050
|
+
const coverage = deps.hotfixCoverage({ mainRef: "origin/main", rcRef: "origin/rc", ack: options.ack ?? [] });
|
|
7051
|
+
if (!coverage.ok) {
|
|
7052
|
+
const list = coverage.uncovered.map((c) => `${c.sha.slice(0, 8)} ${c.subject}`).join("; ");
|
|
6935
7053
|
throw new Error(
|
|
6936
|
-
`
|
|
7054
|
+
`hotfix-coverage: ${coverage.uncovered.length} main-only commit(s) not proven in origin/rc \u2014 the candidate would revert a prod hotfix: ${list}. Re-cut /rcand from development, or have the authorized human verify the content is in the candidate and rerun release with --ack <sha>[,<sha>\u2026].`
|
|
6937
7055
|
);
|
|
6938
7056
|
}
|
|
6939
7057
|
const releasedRcSha = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
|
|
@@ -6942,10 +7060,10 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6942
7060
|
if (predicted.length === 0) {
|
|
6943
7061
|
await deps.run("git", ["merge", "rc", "--no-edit"]);
|
|
6944
7062
|
} else {
|
|
6945
|
-
await mergeWithSpineResolution(deps, "rc", "rc -> main", "theirs");
|
|
7063
|
+
await mergeWithSpineResolution(deps, "rc", "rc -> main", "theirs", foldPaths);
|
|
6946
7064
|
}
|
|
6947
7065
|
const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
|
|
6948
|
-
await
|
|
7066
|
+
const versionFold = await foldReleaseVersion(deps, deployModel, tag, foldPaths);
|
|
6949
7067
|
const releaseSha = requireValue(clean(await deps.run("git", ["rev-parse", "main"])), "release sha");
|
|
6950
7068
|
await ensureTagPushed(deps, tag, releaseSha);
|
|
6951
7069
|
const requiredChecks = await discoverRequiredCheckContexts(deps, ctx, "main");
|
|
@@ -6971,6 +7089,7 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6971
7089
|
deployModel,
|
|
6972
7090
|
promoted: true,
|
|
6973
7091
|
checks,
|
|
7092
|
+
versionFold,
|
|
6974
7093
|
dispatch: d.note,
|
|
6975
7094
|
runId: d.runId,
|
|
6976
7095
|
runUrl: d.runUrl,
|
|
@@ -7015,6 +7134,49 @@ function retireCategoryFrom(text) {
|
|
|
7015
7134
|
return void 0;
|
|
7016
7135
|
}
|
|
7017
7136
|
}
|
|
7137
|
+
var TRANSPORT_REASONS = /* @__PURE__ */ new Set(["invalid-instance", "invalid-document", "throttled", "timeout", "other"]);
|
|
7138
|
+
function retireReasonFrom(text) {
|
|
7139
|
+
try {
|
|
7140
|
+
const r = JSON.parse(text).reason;
|
|
7141
|
+
return typeof r === "string" && TRANSPORT_REASONS.has(r) ? r : void 0;
|
|
7142
|
+
} catch {
|
|
7143
|
+
return void 0;
|
|
7144
|
+
}
|
|
7145
|
+
}
|
|
7146
|
+
function isRetryableTransport(reason) {
|
|
7147
|
+
return reason === void 0 || reason === "throttled" || reason === "timeout" || reason === "other";
|
|
7148
|
+
}
|
|
7149
|
+
var RETIRE_MAX_ATTEMPTS = 3;
|
|
7150
|
+
var RETIRE_BACKOFF_MS = 1500;
|
|
7151
|
+
async function attemptRetire(deps, ctx) {
|
|
7152
|
+
try {
|
|
7153
|
+
const out = await deps.runSelf(["tenant", "control", ctx.repo, "rc", "retire"]);
|
|
7154
|
+
let commandId = "";
|
|
7155
|
+
let category = retireCategoryFrom(out);
|
|
7156
|
+
try {
|
|
7157
|
+
commandId = String(JSON.parse(out).commandId ?? "");
|
|
7158
|
+
} catch {
|
|
7159
|
+
}
|
|
7160
|
+
if (category === "retired-edge-pending") {
|
|
7161
|
+
return { result: {
|
|
7162
|
+
status: "retired",
|
|
7163
|
+
category,
|
|
7164
|
+
note: `rc runtime retired; edge vhost reconcile pending (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept`
|
|
7165
|
+
} };
|
|
7166
|
+
}
|
|
7167
|
+
category = category ?? "retired";
|
|
7168
|
+
return { result: {
|
|
7169
|
+
status: "retired",
|
|
7170
|
+
category,
|
|
7171
|
+
note: `rc runtime retired (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept; /rcand or tenant redeploy recreates rc next cycle`
|
|
7172
|
+
} };
|
|
7173
|
+
} catch (e) {
|
|
7174
|
+
const err = e;
|
|
7175
|
+
const category = retireCategoryFrom(err.stdout ?? "") ?? "transport-failed";
|
|
7176
|
+
const reason = retireReasonFrom(err.stdout ?? "");
|
|
7177
|
+
return { result: { status: "failed", category, note: `rc retirement failed (the release itself succeeded): ${err.message}` }, reason, message: err.message };
|
|
7178
|
+
}
|
|
7179
|
+
}
|
|
7018
7180
|
async function retireRcRuntime(deps, ctx, model, deployStatus, releasedRcSha) {
|
|
7019
7181
|
if (model !== "tenant-container") {
|
|
7020
7182
|
return { status: "not-applicable", note: `${model} has no co-resident rc runtime to retire` };
|
|
@@ -7037,30 +7199,22 @@ async function retireRcRuntime(deps, ctx, model, deployStatus, releasedRcSha) {
|
|
|
7037
7199
|
note: `origin/rc moved past the released candidate (${releasedRcSha.slice(0, 7)} -> ${rcNow.slice(0, 7)}) \u2014 a new candidate is in flight; rc runtime left untouched`
|
|
7038
7200
|
};
|
|
7039
7201
|
}
|
|
7040
|
-
const
|
|
7041
|
-
let
|
|
7042
|
-
let
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
}
|
|
7054
|
-
category = category ?? "retired";
|
|
7055
|
-
return {
|
|
7056
|
-
status: "retired",
|
|
7057
|
-
category,
|
|
7058
|
-
note: `rc runtime retired (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept; /rcand or tenant redeploy recreates rc next cycle`
|
|
7059
|
-
};
|
|
7202
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
7203
|
+
let last;
|
|
7204
|
+
for (let attempt = 1; attempt <= RETIRE_MAX_ATTEMPTS; attempt++) {
|
|
7205
|
+
last = await attemptRetire(deps, ctx);
|
|
7206
|
+
if (last.result.status === "retired") return last.result;
|
|
7207
|
+
const retryable = last.result.category === "transport-failed" && isRetryableTransport(last.reason);
|
|
7208
|
+
if (!retryable || attempt === RETIRE_MAX_ATTEMPTS) break;
|
|
7209
|
+
await sleep(RETIRE_BACKOFF_MS * attempt);
|
|
7210
|
+
}
|
|
7211
|
+
const f = last;
|
|
7212
|
+
const reasonSuffix = f.reason ? ` [reason: ${f.reason}]` : "";
|
|
7213
|
+
const note = `rc retirement failed (the release itself succeeded)${reasonSuffix}: ${f.message ?? f.result.note}. The rc runtime may be orphaned on the box \u2014 retire it with: mmi-cli tenant control ${ctx.repo} rc retire (or sweep all: mmi-cli tenant sweep-rc --retire --yes)`;
|
|
7214
|
+
return { status: "failed", category: f.result.category, note };
|
|
7060
7215
|
} catch (e) {
|
|
7061
7216
|
const err = e;
|
|
7062
|
-
|
|
7063
|
-
return { status: "failed", category, note: `rc retirement failed (the release itself succeeded): ${err.message}` };
|
|
7217
|
+
return { status: "failed", category: "transport-failed", note: `rc retirement failed (the release itself succeeded): ${err.message}` };
|
|
7064
7218
|
}
|
|
7065
7219
|
}
|
|
7066
7220
|
async function runTenantRedeploy(deps, options) {
|
|
@@ -7082,46 +7236,154 @@ async function runTenantRedeploy(deps, options) {
|
|
|
7082
7236
|
meta = null;
|
|
7083
7237
|
}
|
|
7084
7238
|
const deployModel = resolveDeployModel2(meta, repo);
|
|
7085
|
-
if (deployModel !== "tenant-container") {
|
|
7086
|
-
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)`);
|
|
7239
|
+
if (deployModel !== "tenant-container" && deployModel !== "solo-container") {
|
|
7240
|
+
throw new Error(`${repo} is ${deployModel}, not a central-container model (tenant-container/solo-container) \u2014 there is no central tenant-deploy run to retry (its deploy fires from its own workflow)`);
|
|
7087
7241
|
}
|
|
7088
7242
|
const d = await dispatchDeploy(deps, ctx, stage2, ref, deployModel, watch);
|
|
7089
7243
|
return { ...ctx, command: "tenant-redeploy", stage: stage2, ref, deployModel, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, workflowRuns: d.workflowRuns, deployStatus: d.deployStatus };
|
|
7090
7244
|
}
|
|
7091
7245
|
|
|
7092
|
-
// src/
|
|
7093
|
-
|
|
7094
|
-
|
|
7246
|
+
// src/hotfix-coverage.ts
|
|
7247
|
+
var import_node_child_process6 = require("node:child_process");
|
|
7248
|
+
var CHERRY_TRAILER = /\(cherry picked from commit ([0-9a-f]{7,40})\)/g;
|
|
7249
|
+
function checkHotfixCoverage(options = {}) {
|
|
7250
|
+
const { cwd = process.cwd(), mainRef = "origin/main", rcRef = "origin/rc", manifestPaths = [] } = options;
|
|
7251
|
+
const ack = (options.ack ?? []).filter(Boolean);
|
|
7252
|
+
const git = options.git ?? ((args, opts) => (0, import_node_child_process6.execFileSync)("git", args, { cwd, encoding: "utf8", input: opts?.input, stdio: ["pipe", "pipe", "pipe"] }));
|
|
7253
|
+
const revList = (range) => {
|
|
7254
|
+
const out = git(["rev-list", "--no-merges", range]).trim();
|
|
7255
|
+
return out ? out.split("\n") : [];
|
|
7256
|
+
};
|
|
7257
|
+
const isAncestor = (sha, ref) => {
|
|
7258
|
+
try {
|
|
7259
|
+
git(["merge-base", "--is-ancestor", sha, ref]);
|
|
7260
|
+
return true;
|
|
7261
|
+
} catch {
|
|
7262
|
+
return false;
|
|
7263
|
+
}
|
|
7264
|
+
};
|
|
7265
|
+
const patchId = (sha) => {
|
|
7266
|
+
const diff = git(["show", "--no-color", "--pretty=format:", sha]);
|
|
7267
|
+
if (!diff.trim()) return null;
|
|
7268
|
+
const out = git(["patch-id", "--stable"], { input: diff }).trim();
|
|
7269
|
+
return out ? out.split(" ")[0] : null;
|
|
7270
|
+
};
|
|
7271
|
+
const changedPaths = (sha) => {
|
|
7272
|
+
const out = git(["show", "--no-color", "--name-only", "--pretty=format:", sha]).trim();
|
|
7273
|
+
return out ? out.split("\n") : [];
|
|
7274
|
+
};
|
|
7275
|
+
const cherrySources = (sha) => {
|
|
7276
|
+
const message = git(["log", "-1", "--format=%B", sha]);
|
|
7277
|
+
return [...message.matchAll(CHERRY_TRAILER)].map((m) => m[1]);
|
|
7278
|
+
};
|
|
7279
|
+
const mainOnly = revList(`${rcRef}..${mainRef}`);
|
|
7280
|
+
const rcSidePatchIds = /* @__PURE__ */ new Map();
|
|
7281
|
+
let rcSideBuilt = false;
|
|
7282
|
+
const buildRcSide = () => {
|
|
7283
|
+
if (rcSideBuilt) return;
|
|
7284
|
+
rcSideBuilt = true;
|
|
7285
|
+
for (const sha of revList(`${mainRef}..${rcRef}`)) {
|
|
7286
|
+
const id = patchId(sha);
|
|
7287
|
+
if (id) rcSidePatchIds.set(id, sha);
|
|
7288
|
+
}
|
|
7289
|
+
};
|
|
7290
|
+
const commits = mainOnly.map((sha) => {
|
|
7291
|
+
const subject = git(["log", "-1", "--format=%s", sha]).trim();
|
|
7292
|
+
const paths = changedPaths(sha);
|
|
7293
|
+
if (paths.length > 0 && paths.every((p) => manifestPaths.includes(p))) {
|
|
7294
|
+
return { sha, subject, coverage: "exempt-distribution" };
|
|
7295
|
+
}
|
|
7296
|
+
const sources = cherrySources(sha);
|
|
7297
|
+
if (sources.length > 0) {
|
|
7298
|
+
if (sources.every((s) => isAncestor(s, rcRef))) {
|
|
7299
|
+
return { sha, subject, coverage: "trailer", sources };
|
|
7300
|
+
}
|
|
7301
|
+
if (ack.some((a) => sha.startsWith(a))) return { sha, subject, coverage: "acked", sources };
|
|
7302
|
+
return { sha, subject, coverage: "uncovered", sources };
|
|
7303
|
+
}
|
|
7304
|
+
const id = patchId(sha);
|
|
7305
|
+
if (id) {
|
|
7306
|
+
buildRcSide();
|
|
7307
|
+
const matchedRcSha = rcSidePatchIds.get(id);
|
|
7308
|
+
if (matchedRcSha) {
|
|
7309
|
+
return { sha, subject, coverage: "patch-id", matchedRcSha };
|
|
7310
|
+
}
|
|
7311
|
+
}
|
|
7312
|
+
if (ack.some((a) => sha.startsWith(a))) return { sha, subject, coverage: "acked" };
|
|
7313
|
+
return { sha, subject, coverage: "uncovered" };
|
|
7314
|
+
});
|
|
7315
|
+
const uncovered = commits.filter((c) => c.coverage === "uncovered");
|
|
7316
|
+
return { ok: uncovered.length === 0, mainRef, rcRef, commits, uncovered };
|
|
7095
7317
|
}
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7318
|
+
|
|
7319
|
+
// src/tenant-sweep.ts
|
|
7320
|
+
function isRcBearingTenant(p) {
|
|
7321
|
+
if ((p.deployModel ?? "") !== "tenant-container") return false;
|
|
7322
|
+
return (p.releaseTrack ?? "full") === "full";
|
|
7323
|
+
}
|
|
7324
|
+
function pickRepo(p) {
|
|
7325
|
+
const repos = p.repos ?? [];
|
|
7326
|
+
if (repos.length === 0) return null;
|
|
7327
|
+
const bySlug = repos.find((r) => r.split("/")[1]?.toLowerCase() === (p.slug ?? "").toLowerCase());
|
|
7328
|
+
return bySlug ?? repos[0];
|
|
7329
|
+
}
|
|
7330
|
+
async function sweepRcOrphans(deps, opts) {
|
|
7331
|
+
const projects = await deps.listProjects();
|
|
7332
|
+
if (!projects) throw new Error("project list unavailable \u2014 Hub unreachable or this repo is not bootstrapped");
|
|
7333
|
+
const targets = projects.filter(isRcBearingTenant);
|
|
7334
|
+
const stages = [];
|
|
7335
|
+
for (const p of targets) {
|
|
7336
|
+
const repo = pickRepo(p);
|
|
7337
|
+
if (!repo) continue;
|
|
7338
|
+
const slug = p.slug ?? "";
|
|
7339
|
+
let serviceState;
|
|
7340
|
+
try {
|
|
7341
|
+
serviceState = (await deps.status(repo)).serviceState || "unknown";
|
|
7342
|
+
} catch (e) {
|
|
7343
|
+
stages.push({ repo, slug, serviceState: "error", orphanCandidate: false, detail: e.message });
|
|
7344
|
+
continue;
|
|
7345
|
+
}
|
|
7346
|
+
const orphanCandidate = serviceState === "running";
|
|
7347
|
+
const stage2 = { repo, slug, serviceState, orphanCandidate };
|
|
7348
|
+
if (orphanCandidate && opts.retire) {
|
|
7349
|
+
try {
|
|
7350
|
+
const r = await deps.retire(repo);
|
|
7351
|
+
stage2.retired = r.ok ? "retired" : "failed";
|
|
7352
|
+
stage2.category = r.category;
|
|
7353
|
+
stage2.reason = r.reason;
|
|
7354
|
+
} catch (e) {
|
|
7355
|
+
stage2.retired = "failed";
|
|
7356
|
+
stage2.detail = e.message;
|
|
7357
|
+
}
|
|
7358
|
+
}
|
|
7359
|
+
stages.push(stage2);
|
|
7100
7360
|
}
|
|
7101
|
-
return
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7361
|
+
return {
|
|
7362
|
+
scanned: stages.length,
|
|
7363
|
+
running: stages.filter((s) => s.orphanCandidate && s.retired !== "retired").length,
|
|
7364
|
+
stages,
|
|
7365
|
+
retireAttempted: !!opts.retire
|
|
7366
|
+
};
|
|
7105
7367
|
}
|
|
7106
|
-
|
|
7107
|
-
const
|
|
7108
|
-
const
|
|
7109
|
-
|
|
7110
|
-
|
|
7368
|
+
function renderSweep(r) {
|
|
7369
|
+
const lines = [`tenant sweep-rc: scanned ${r.scanned} tenant-container(s); ${r.running} rc runtime(s) running`];
|
|
7370
|
+
for (const s of r.stages) {
|
|
7371
|
+
let line = ` ${s.repo} rc: ${s.orphanCandidate ? "RUNNING" : s.serviceState}`;
|
|
7372
|
+
if (s.retired) line += ` -> ${s.retired}${s.category ? ` (${s.category}${s.reason ? `/${s.reason}` : ""})` : ""}`;
|
|
7373
|
+
if (s.detail) line += ` \u2014 ${s.detail}`;
|
|
7374
|
+
lines.push(line);
|
|
7375
|
+
}
|
|
7376
|
+
if (r.running > 0 && !r.retireAttempted) {
|
|
7377
|
+
lines.push("Retire an orphan with: mmi-cli tenant control <owner/repo> rc retire (or sweep all running: mmi-cli tenant sweep-rc --retire --yes)");
|
|
7111
7378
|
}
|
|
7112
|
-
|
|
7113
|
-
const stagedFiles = parseChangedFiles(await deps.run("node", ["scripts/release-distribution.mjs", "changed-files"]));
|
|
7114
|
-
if (stagedFiles.length === 0) throw new Error("no locked distribution files returned by release-distribution changed-files");
|
|
7115
|
-
await deps.run("git", ["add", "--", ...stagedFiles]);
|
|
7116
|
-
await deps.run("node", ["scripts/release-distribution.mjs", "verify", target, "--skip-npm-view"]);
|
|
7117
|
-
return { target, version, applied: true, command: "mmi-cli pr create --base development --head <current-branch>", stagedFiles };
|
|
7379
|
+
return lines.join("\n");
|
|
7118
7380
|
}
|
|
7119
7381
|
|
|
7120
7382
|
// src/hotfix-apply.ts
|
|
7121
7383
|
var HOTFIX_RELEASE_WORKFLOWS = ["deploy.yml", "publish.yml"];
|
|
7122
7384
|
var HOTFIX_RUN_FIND_ATTEMPTS = 10;
|
|
7123
7385
|
var HOTFIX_RUN_FIND_DELAY_MS = 15e3;
|
|
7124
|
-
function
|
|
7386
|
+
function clean2(out) {
|
|
7125
7387
|
return out.trim();
|
|
7126
7388
|
}
|
|
7127
7389
|
function sleeper(deps) {
|
|
@@ -7175,7 +7437,7 @@ async function resolveHotfixSource(deps, ctx, from) {
|
|
|
7175
7437
|
if (!sha2) throw new Error(`PR #${num} has no merge commit recorded \u2014 name the commit SHA explicitly`);
|
|
7176
7438
|
return { sha: sha2, label: `PR #${num} (${sha2.slice(0, 7)})` };
|
|
7177
7439
|
}
|
|
7178
|
-
const sha =
|
|
7440
|
+
const sha = clean2(await deps.run("git", ["rev-parse", "--verify", `${from}^{commit}`]));
|
|
7179
7441
|
if (!sha) throw new Error(`could not resolve commit ${from}`);
|
|
7180
7442
|
return { sha, label: sha.slice(0, 7) };
|
|
7181
7443
|
}
|
|
@@ -7202,7 +7464,7 @@ async function runHotfixStart(deps, options) {
|
|
|
7202
7464
|
};
|
|
7203
7465
|
}
|
|
7204
7466
|
const { sha, label } = await resolveHotfixSource(deps, ctx, options.from);
|
|
7205
|
-
const remoteBranch =
|
|
7467
|
+
const remoteBranch = clean2(await deps.run("git", ["ls-remote", "origin", `refs/heads/${branch}`]));
|
|
7206
7468
|
if (remoteBranch) {
|
|
7207
7469
|
await deps.run("git", ["checkout", branch]);
|
|
7208
7470
|
await deps.run("git", ["pull", "--ff-only", "origin", branch]);
|
|
@@ -7228,7 +7490,7 @@ async function runHotfixStart(deps, options) {
|
|
|
7228
7490
|
}
|
|
7229
7491
|
await deps.run("git", ["push", "-u", "origin", branch]);
|
|
7230
7492
|
}
|
|
7231
|
-
const prUrl =
|
|
7493
|
+
const prUrl = clean2(await deps.run("gh", [
|
|
7232
7494
|
"pr",
|
|
7233
7495
|
"create",
|
|
7234
7496
|
"--repo",
|
|
@@ -7311,7 +7573,7 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
|
|
|
7311
7573
|
if (releaseExists) {
|
|
7312
7574
|
releaseNote = `Release ${tag} already exists \u2014 resumed without recreating`;
|
|
7313
7575
|
} else {
|
|
7314
|
-
const tagCommit =
|
|
7576
|
+
const tagCommit = clean2(await deps.run("git", ["rev-parse", `${tag}^{commit}`]));
|
|
7315
7577
|
await deps.run("gh", ["release", "create", tag, "--repo", ctx.repo, "--target", tagCommit, "--generate-notes", "--latest"]);
|
|
7316
7578
|
releaseNote = `Release ${tag} created (target ${tagCommit.slice(0, 7)})`;
|
|
7317
7579
|
if (deps.announce) {
|
|
@@ -7322,7 +7584,7 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
|
|
|
7322
7584
|
for (const workflow of HOTFIX_RELEASE_WORKFLOWS) {
|
|
7323
7585
|
runs.push(await watchReleaseRun(deps, ctx, workflow, mergedSha));
|
|
7324
7586
|
}
|
|
7325
|
-
const previousRef =
|
|
7587
|
+
const previousRef = clean2(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
|
|
7326
7588
|
let verifyNote;
|
|
7327
7589
|
const publishSucceeded = runs.find((r) => r.workflow === "publish.yml")?.conclusion === "success";
|
|
7328
7590
|
try {
|
|
@@ -7335,15 +7597,6 @@ async function runHotfixRelease(deps, versionInput, options = {}) {
|
|
|
7335
7597
|
}
|
|
7336
7598
|
return { ...ctx, command: "hotfix-release", tag, mergedSha, checks, tagNote, releaseNote, runs, verifyNote, announceNote };
|
|
7337
7599
|
}
|
|
7338
|
-
function versionAtLeast2(actual, wanted) {
|
|
7339
|
-
const pa = actual.split(".").map(Number);
|
|
7340
|
-
const pw = wanted.split(".").map(Number);
|
|
7341
|
-
if (pa.length < 3 || pa.some(Number.isNaN) || pw.length < 3 || pw.some(Number.isNaN)) return false;
|
|
7342
|
-
for (let i = 0; i < 3; i += 1) {
|
|
7343
|
-
if (pa[i] !== pw[i]) return pa[i] > pw[i];
|
|
7344
|
-
}
|
|
7345
|
-
return true;
|
|
7346
|
-
}
|
|
7347
7600
|
function deriveHotfixState(f) {
|
|
7348
7601
|
if (!f.branchExists && !f.pr && !f.tagPushed && !f.releaseExists) {
|
|
7349
7602
|
return { state: "not-started", next: `mmi-cli hotfix start --from <pr#|sha> (would mint ${f.tag})` };
|
|
@@ -7360,13 +7613,7 @@ function deriveHotfixState(f) {
|
|
|
7360
7613
|
if (!f.tagPushed || !f.releaseExists) {
|
|
7361
7614
|
return { state: "pr-merged (not released)", next: `mmi-cli hotfix release ${f.tag}` };
|
|
7362
7615
|
}
|
|
7363
|
-
|
|
7364
|
-
return {
|
|
7365
|
-
state: `UNFINISHED \u2014 released but development distribution manifests behind (dev ${f.devDistribution.version} < ${f.version})`,
|
|
7366
|
-
next: `forward-bump development (never a back-merge): branch from origin/development, node scripts/release-distribution.mjs prepare ${f.version}, land by PR (/hotfix Step 5)`
|
|
7367
|
-
};
|
|
7368
|
-
}
|
|
7369
|
-
return { state: "complete", next: "nothing \u2014 pipeline complete (rc absorbs the fix at the next /rcand; /release guards coverage)" };
|
|
7616
|
+
return { state: "complete", next: "nothing \u2014 pipeline complete (rc absorbs the fix at the next /rcand; /release guards coverage and re-aligns the version manifests via its fold + back-merge)" };
|
|
7370
7617
|
}
|
|
7371
7618
|
async function runHotfixStatus(deps, versionInput) {
|
|
7372
7619
|
const ctx = await buildTrainApplyContext(deps);
|
|
@@ -7390,9 +7637,9 @@ async function runHotfixStatus(deps, versionInput) {
|
|
|
7390
7637
|
return { ...ctx, command: "hotfix-status", ...facts, ...deriveHotfixState(facts) };
|
|
7391
7638
|
}
|
|
7392
7639
|
async function gatherHotfixFacts(deps, ctx, tag, version) {
|
|
7393
|
-
const branchExists = Boolean(
|
|
7640
|
+
const branchExists = Boolean(clean2(await deps.run("git", ["ls-remote", "origin", `refs/heads/${hotfixBranch(tag)}`])));
|
|
7394
7641
|
const pr2 = await findHotfixPr(deps, ctx, tag);
|
|
7395
|
-
const remoteTag =
|
|
7642
|
+
const remoteTag = clean2(await deps.run("git", ["ls-remote", "origin", `refs/tags/${tag}`]));
|
|
7396
7643
|
const tagPushed = Boolean(remoteTag);
|
|
7397
7644
|
const tagSha = remoteTag.split(/\s+/)[0] || "";
|
|
7398
7645
|
let releaseExists = false;
|
|
@@ -7426,19 +7673,8 @@ async function gatherHotfixFacts(deps, ctx, tag, version) {
|
|
|
7426
7673
|
}
|
|
7427
7674
|
}
|
|
7428
7675
|
}
|
|
7429
|
-
const npmVersion = await deps.run("npm", ["view", "@mutmutco/cli", "version", "--silent"]).then(
|
|
7430
|
-
|
|
7431
|
-
(out) => {
|
|
7432
|
-
try {
|
|
7433
|
-
return JSON.parse(out).version ?? "unknown";
|
|
7434
|
-
} catch {
|
|
7435
|
-
return "unknown";
|
|
7436
|
-
}
|
|
7437
|
-
},
|
|
7438
|
-
() => "unknown"
|
|
7439
|
-
);
|
|
7440
|
-
const devDistribution = { version: devVersion, aligned: versionAtLeast2(devVersion, version) };
|
|
7441
|
-
return { tag, version, branchExists, pr: pr2 ? { number: pr2.number, state: pr2.state, url: pr2.url } : null, tagPushed, releaseExists, runs, npmVersion, devDistribution };
|
|
7676
|
+
const npmVersion = await deps.run("npm", ["view", "@mutmutco/cli", "version", "--silent"]).then(clean2, () => "unknown");
|
|
7677
|
+
return { tag, version, branchExists, pr: pr2 ? { number: pr2.number, state: pr2.state, url: pr2.url } : null, tagPushed, releaseExists, runs, npmVersion };
|
|
7442
7678
|
}
|
|
7443
7679
|
|
|
7444
7680
|
// src/release-announce.ts
|
|
@@ -7605,6 +7841,18 @@ function ensurePortRange(repo, path2) {
|
|
|
7605
7841
|
function portCursorSeed(registry2) {
|
|
7606
7842
|
return nextPortBlock(registry2)[0];
|
|
7607
7843
|
}
|
|
7844
|
+
function metaPortRange(meta) {
|
|
7845
|
+
const r = meta?.portRange;
|
|
7846
|
+
if (r && typeof r.start === "number" && typeof r.end === "number") return [r.start, r.end];
|
|
7847
|
+
return null;
|
|
7848
|
+
}
|
|
7849
|
+
function decidePortRange(input) {
|
|
7850
|
+
if (!input.metaReadOk) {
|
|
7851
|
+
return { action: "fail", reason: "could not verify the existing port block (Hub registry read failed) \u2014 retry; NOT allocating (a re-allocation on an unverified read would advance the cursor and hand out a duplicate block)" };
|
|
7852
|
+
}
|
|
7853
|
+
if (input.metaPortRange) return { action: "return", range: input.metaPortRange };
|
|
7854
|
+
return { action: "allocate" };
|
|
7855
|
+
}
|
|
7608
7856
|
function existingPortRange(repo, registry2) {
|
|
7609
7857
|
return registry2[repo] ?? null;
|
|
7610
7858
|
}
|
|
@@ -7884,7 +8132,8 @@ var requiredProjectWorkflows = [
|
|
|
7884
8132
|
];
|
|
7885
8133
|
var requiredOrgRulesetTypes = ["pull_request", "non_fast_forward", "deletion"];
|
|
7886
8134
|
var requiredHubStatusChecks = ["cli", "infra", "docs"];
|
|
7887
|
-
function expectedBranches(repoClass) {
|
|
8135
|
+
function expectedBranches(repoClass, releaseTrack) {
|
|
8136
|
+
if (isReleaseTrack(releaseTrack)) return branchesForTrack(releaseTrack);
|
|
7888
8137
|
return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
|
|
7889
8138
|
}
|
|
7890
8139
|
function gcpProjectForSlug(slug) {
|
|
@@ -7977,9 +8226,9 @@ function localRegistryCheck(deps, path2, predicate) {
|
|
|
7977
8226
|
if (text == null) return null;
|
|
7978
8227
|
return predicate(safeJson2(text, null));
|
|
7979
8228
|
}
|
|
7980
|
-
async function verifyBootstrap(repo, repoClass, deps) {
|
|
7981
|
-
const branchesWanted = expectedBranches(repoClass);
|
|
7982
|
-
const baseBranch = repoClass === "content" ? "main" : "development";
|
|
8229
|
+
async function verifyBootstrap(repo, repoClass, deps, releaseTrack) {
|
|
8230
|
+
const branchesWanted = expectedBranches(repoClass, releaseTrack);
|
|
8231
|
+
const baseBranch = releaseTrack === "trunk" || repoClass === "content" ? "main" : "development";
|
|
7983
8232
|
const checks = [];
|
|
7984
8233
|
const repoInfo = await restJson2(deps, `repos/${repo}`, {});
|
|
7985
8234
|
checks.push({ ok: Boolean(repoInfo.default_branch), label: "repo exists" });
|
|
@@ -8281,6 +8530,15 @@ function parseOwnerRepo(repo) {
|
|
|
8281
8530
|
if (owner.includes("\\") || name.includes("\\")) throw new Error("repo must be owner/repo");
|
|
8282
8531
|
return { owner, name, slug: name.toLowerCase(), fullName: `${owner}/${name}` };
|
|
8283
8532
|
}
|
|
8533
|
+
var DEFAULT_INSTALL_CMD = "npm ci";
|
|
8534
|
+
function withDerivedRepoVars(vars, parsed, cls) {
|
|
8535
|
+
const out = { ...vars };
|
|
8536
|
+
out.REPO_NAME ??= parsed.name;
|
|
8537
|
+
out.REPO_SLUG ??= parsed.slug;
|
|
8538
|
+
out.CLASS ??= cls;
|
|
8539
|
+
out.INSTALL_CMD ??= DEFAULT_INSTALL_CMD;
|
|
8540
|
+
return out;
|
|
8541
|
+
}
|
|
8284
8542
|
function planSeedAction(seed, exists) {
|
|
8285
8543
|
if (seed.source === "fanout") {
|
|
8286
8544
|
return { target: seed.target, action: "skip", ownership: "fanout", reason: "delivered by the fanout pipeline" };
|
|
@@ -8293,6 +8551,18 @@ function planSeedAction(seed, exists) {
|
|
|
8293
8551
|
}
|
|
8294
8552
|
return exists ? { target: seed.target, action: "update", ownership: "org", reason: "org-owned, refresh to current" } : { target: seed.target, action: "create", ownership: "org", reason: "org-owned, missing" };
|
|
8295
8553
|
}
|
|
8554
|
+
function reconcileSeedAction(action, content, isManagedBlock) {
|
|
8555
|
+
if (action.action === "skip") return action;
|
|
8556
|
+
if (content == null) {
|
|
8557
|
+
return { ...action, action: "skip", reason: "no resolvable content" };
|
|
8558
|
+
}
|
|
8559
|
+
if (isManagedBlock) return action;
|
|
8560
|
+
const unfilled = missingPlaceholders(content);
|
|
8561
|
+
if (unfilled.length) {
|
|
8562
|
+
return { ...action, action: "skip", reason: `unfilled: ${unfilled.join(", ")} \u2014 pass --var` };
|
|
8563
|
+
}
|
|
8564
|
+
return action;
|
|
8565
|
+
}
|
|
8296
8566
|
function renderSeedPlan(actions) {
|
|
8297
8567
|
const lines = ["bootstrap apply \u2014 seed plan (dry-run; no mutations):"];
|
|
8298
8568
|
for (const a of actions) {
|
|
@@ -8302,6 +8572,20 @@ function renderSeedPlan(actions) {
|
|
|
8302
8572
|
lines.push(` \u2014 ${order.map((k) => `${actions.filter((a) => a.action === k).length} ${k}`).join(", ")}`);
|
|
8303
8573
|
return lines.join("\n");
|
|
8304
8574
|
}
|
|
8575
|
+
var GITHUB_DEFAULT_LABELS = [
|
|
8576
|
+
"documentation",
|
|
8577
|
+
"duplicate",
|
|
8578
|
+
"enhancement",
|
|
8579
|
+
"good first issue",
|
|
8580
|
+
"help wanted",
|
|
8581
|
+
"invalid",
|
|
8582
|
+
"question",
|
|
8583
|
+
"wontfix"
|
|
8584
|
+
];
|
|
8585
|
+
function labelsToPrune(orgLabelNames) {
|
|
8586
|
+
const org = new Set(orgLabelNames);
|
|
8587
|
+
return GITHUB_DEFAULT_LABELS.filter((name) => !org.has(name));
|
|
8588
|
+
}
|
|
8305
8589
|
function resolveSeedContent(seed, vars, readFile2) {
|
|
8306
8590
|
if (seed.source === "self") return readFile2(seed.target);
|
|
8307
8591
|
if (seed.source.startsWith("seed:")) {
|
|
@@ -8314,10 +8598,13 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
|
|
|
8314
8598
|
const parsedRepo = parseOwnerRepo(repo);
|
|
8315
8599
|
const slug = parsedRepo.slug;
|
|
8316
8600
|
if (options.projectType && !isProjectType(options.projectType)) {
|
|
8317
|
-
throw new Error(
|
|
8601
|
+
throw new Error(`projectType must be one of: ${PROJECT_TYPES.join(", ")}`);
|
|
8318
8602
|
}
|
|
8319
8603
|
if (options.deployModel && !isDeployModel(options.deployModel)) {
|
|
8320
|
-
throw new Error(
|
|
8604
|
+
throw new Error(`deployModel must be one of: ${DEPLOY_MODELS.join(", ")}`);
|
|
8605
|
+
}
|
|
8606
|
+
if (options.releaseTrack && !isReleaseTrack(options.releaseTrack)) {
|
|
8607
|
+
throw new Error("releaseTrack must be full, direct, or trunk");
|
|
8321
8608
|
}
|
|
8322
8609
|
const shape = {
|
|
8323
8610
|
class: cls,
|
|
@@ -8327,7 +8614,7 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
|
|
|
8327
8614
|
const projectType = resolveProjectTypeConfident(shape, parsedRepo.fullName);
|
|
8328
8615
|
if (!projectType) {
|
|
8329
8616
|
throw new Error(
|
|
8330
|
-
`Project type for ${parsedRepo.fullName} is unset and not derivable \u2014 pass --project-type
|
|
8617
|
+
`Project type for ${parsedRepo.fullName} is unset and not derivable \u2014 pass --project-type <${PROJECT_TYPES.join("|")}> and --deploy-model <${DEPLOY_MODELS.join("|")}> (prevents defaulting a non-web repo to tenant-container).`
|
|
8331
8618
|
);
|
|
8332
8619
|
}
|
|
8333
8620
|
const deployModel = resolveDeployModel({ ...shape, projectType }, parsedRepo.fullName);
|
|
@@ -8359,6 +8646,8 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
|
|
|
8359
8646
|
class: cls,
|
|
8360
8647
|
projectType,
|
|
8361
8648
|
deployModel,
|
|
8649
|
+
// #917: only emit when explicitly direct/trunk; absent → registry resolves to `full` (no clobber).
|
|
8650
|
+
releaseTrack: isReleaseTrack(options.releaseTrack) ? options.releaseTrack : void 0,
|
|
8362
8651
|
// Board coords (from GraphQL at bootstrap, passed as --var by the skill).
|
|
8363
8652
|
projectOwner: vars.PROJECT_OWNER || void 0,
|
|
8364
8653
|
projectNumber: num(vars.PROJECT_NUMBER),
|
|
@@ -8374,6 +8663,83 @@ function buildRegisterPayload(repo, cls, vars, options = {}) {
|
|
|
8374
8663
|
for (const k of Object.keys(payload)) if (payload[k] === void 0) delete payload[k];
|
|
8375
8664
|
return payload;
|
|
8376
8665
|
}
|
|
8666
|
+
var BOARD_FIELD_VAR_MAP = {
|
|
8667
|
+
Status: {
|
|
8668
|
+
fieldIdVar: "STATUS_FIELD_ID",
|
|
8669
|
+
options: { Todo: "STATUS_TODO", "In Progress": "STATUS_IN_PROGRESS", "In Review": "STATUS_IN_REVIEW", Done: "STATUS_DONE" }
|
|
8670
|
+
},
|
|
8671
|
+
Priority: {
|
|
8672
|
+
fieldIdVar: "PRIORITY_FIELD_ID",
|
|
8673
|
+
options: { Urgent: "PRIORITY_URGENT", High: "PRIORITY_HIGH", Medium: "PRIORITY_MEDIUM", Low: "PRIORITY_LOW" }
|
|
8674
|
+
}
|
|
8675
|
+
};
|
|
8676
|
+
function extractBoardFieldVars(fieldsJson) {
|
|
8677
|
+
const out = {};
|
|
8678
|
+
const nodes = Array.isArray(fieldsJson) ? fieldsJson : fieldsJson?.data?.node?.fields?.nodes ?? fieldsJson?.node?.fields?.nodes;
|
|
8679
|
+
if (!Array.isArray(nodes)) return out;
|
|
8680
|
+
for (const node of nodes) {
|
|
8681
|
+
const field = node;
|
|
8682
|
+
const name = typeof field.name === "string" ? field.name : void 0;
|
|
8683
|
+
const map = name ? BOARD_FIELD_VAR_MAP[name] : void 0;
|
|
8684
|
+
if (!map || typeof field.id !== "string" || !field.id) continue;
|
|
8685
|
+
out[map.fieldIdVar] = field.id;
|
|
8686
|
+
const options = Array.isArray(field.options) ? field.options : [];
|
|
8687
|
+
for (const opt of options) {
|
|
8688
|
+
const o = opt;
|
|
8689
|
+
const varName = typeof o.name === "string" ? map.options[o.name] : void 0;
|
|
8690
|
+
if (varName && typeof o.id === "string" && o.id) out[varName] = o.id;
|
|
8691
|
+
}
|
|
8692
|
+
}
|
|
8693
|
+
return out;
|
|
8694
|
+
}
|
|
8695
|
+
function boardFieldsQueryArgs(projectId) {
|
|
8696
|
+
const query = "query($id: ID!) { node(id: $id) { ... on ProjectV2 { fields(first: 50) { nodes { ... on ProjectV2SingleSelectField { id name options { id name } } } } } } }";
|
|
8697
|
+
return ["api", "graphql", "-f", `query=${query}`, "-f", `id=${projectId}`];
|
|
8698
|
+
}
|
|
8699
|
+
function serializeRegistry(obj) {
|
|
8700
|
+
return `${JSON.stringify(obj, null, 2)}
|
|
8701
|
+
`;
|
|
8702
|
+
}
|
|
8703
|
+
function planFanoutRegistration(fanoutTargetsRaw, projectsRaw, entry) {
|
|
8704
|
+
const fanout = JSON.parse(fanoutTargetsRaw);
|
|
8705
|
+
const projects = JSON.parse(projectsRaw);
|
|
8706
|
+
const fanoutRepos = Array.isArray(fanout.repos) ? fanout.repos : [];
|
|
8707
|
+
const projectEntries = Array.isArray(projects.projects) ? projects.projects : [];
|
|
8708
|
+
const name = entry.name ?? entry.repo;
|
|
8709
|
+
const canonName = entry.repo.toLowerCase();
|
|
8710
|
+
const inFanout = fanoutRepos.some((r) => typeof r.repo === "string" && r.repo.toLowerCase() === canonName);
|
|
8711
|
+
const inProjects = projectEntries.some(
|
|
8712
|
+
(p) => p.slug === entry.slug || Array.isArray(p.repos) && p.repos.some((full) => String(full).split("/").pop()?.toLowerCase() === canonName)
|
|
8713
|
+
);
|
|
8714
|
+
if (inFanout || inProjects) {
|
|
8715
|
+
return { changed: false, fanoutTargets: fanoutTargetsRaw, projects: projectsRaw };
|
|
8716
|
+
}
|
|
8717
|
+
const projectEntry = {
|
|
8718
|
+
name,
|
|
8719
|
+
slug: entry.slug,
|
|
8720
|
+
projectId: entry.projectId,
|
|
8721
|
+
wikiRepo: entry.wikiRepo,
|
|
8722
|
+
repos: [`mutmutco/${entry.repo}`]
|
|
8723
|
+
};
|
|
8724
|
+
if (entry.cls === "content") projectEntry.branch = "main";
|
|
8725
|
+
for (const k of Object.keys(projectEntry)) if (projectEntry[k] === void 0) delete projectEntry[k];
|
|
8726
|
+
const nextProjects = { ...projects, projects: [...projectEntries, projectEntry] };
|
|
8727
|
+
const fanoutEntry = { repo: entry.repo, branch: entry.branch, class: entry.cls };
|
|
8728
|
+
const nextFanout = { ...fanout, repos: [...fanoutRepos, fanoutEntry] };
|
|
8729
|
+
return {
|
|
8730
|
+
changed: true,
|
|
8731
|
+
fanoutTargets: serializeRegistry(nextFanout),
|
|
8732
|
+
projects: serializeRegistry(nextProjects)
|
|
8733
|
+
};
|
|
8734
|
+
}
|
|
8735
|
+
function decideFanoutPrAction(openPrs) {
|
|
8736
|
+
const list = Array.isArray(openPrs) ? openPrs : [];
|
|
8737
|
+
for (const pr2 of list) {
|
|
8738
|
+
const url = pr2?.url;
|
|
8739
|
+
if (typeof url === "string" && url.trim()) return { action: "reuse", url: url.trim() };
|
|
8740
|
+
}
|
|
8741
|
+
return { action: "create" };
|
|
8742
|
+
}
|
|
8377
8743
|
function contentPutArgs(repo, path2, content, branch, sha) {
|
|
8378
8744
|
const args = [
|
|
8379
8745
|
"api",
|
|
@@ -8537,6 +8903,33 @@ async function tenantControl(payload, deps) {
|
|
|
8537
8903
|
const timeoutMs = payload.wait ? WAITED_TENANT_CONTROL_TIMEOUT_MS : void 0;
|
|
8538
8904
|
return postJson("/tenant-control", payload, deps, "POST", { noRetry, timeoutMs });
|
|
8539
8905
|
}
|
|
8906
|
+
async function tenantDeploy(payload, deps) {
|
|
8907
|
+
return postJson("/tenant-deploy", payload, deps, "POST", { noRetry: true });
|
|
8908
|
+
}
|
|
8909
|
+
|
|
8910
|
+
// src/tenant-verify-secrets.ts
|
|
8911
|
+
function tenantControlWait(action) {
|
|
8912
|
+
return action === "status" || action === "retire" || action === "verify-secrets";
|
|
8913
|
+
}
|
|
8914
|
+
function renderVerifySecrets(body) {
|
|
8915
|
+
const secrets2 = body?.secrets ?? [];
|
|
8916
|
+
const counts = {
|
|
8917
|
+
match: secrets2.filter((s) => s.status === "match").length,
|
|
8918
|
+
mismatch: secrets2.filter((s) => s.status === "mismatch").length,
|
|
8919
|
+
missing: secrets2.filter((s) => s.status === "missing").length
|
|
8920
|
+
};
|
|
8921
|
+
const lines = secrets2.map((s) => `${s.key}: ${s.status}`);
|
|
8922
|
+
lines.push(`verify-secrets: ${counts.match} match, ${counts.mismatch} mismatch, ${counts.missing} missing`);
|
|
8923
|
+
const ssmStatus = body?.ssmStatus ?? "pending";
|
|
8924
|
+
if (ssmStatus !== "Success") {
|
|
8925
|
+
return { lines, failure: `verify-secrets did not complete (ssm status ${ssmStatus})${body?.commandId ? ` \u2014 command ${body.commandId}` : ""}` };
|
|
8926
|
+
}
|
|
8927
|
+
const bad = counts.mismatch + counts.missing;
|
|
8928
|
+
if (bad > 0) {
|
|
8929
|
+
return { lines, failure: `${bad} of ${secrets2.length} required secret(s) not matching the vault (${counts.mismatch} mismatch, ${counts.missing} missing)` };
|
|
8930
|
+
}
|
|
8931
|
+
return { lines, failure: null };
|
|
8932
|
+
}
|
|
8540
8933
|
|
|
8541
8934
|
// src/project-readiness.ts
|
|
8542
8935
|
function dnsErrorToResolution(code) {
|
|
@@ -8563,11 +8956,17 @@ function declaresNoPublicEdge(meta) {
|
|
|
8563
8956
|
const ed = meta?.edgeDomains;
|
|
8564
8957
|
return Boolean(ed && typeof ed === "object" && !Array.isArray(ed) && Object.keys(ed).length === 0);
|
|
8565
8958
|
}
|
|
8959
|
+
function declaresWorker(meta) {
|
|
8960
|
+
return meta?.projectType === "worker";
|
|
8961
|
+
}
|
|
8962
|
+
function isCentralContainerModel2(model) {
|
|
8963
|
+
return model === "tenant-container" || model === "solo-container";
|
|
8964
|
+
}
|
|
8566
8965
|
function isNoEdgeTenantWorker(meta, model) {
|
|
8567
|
-
return model
|
|
8966
|
+
return isCentralContainerModel2(model) && (declaresNoPublicEdge(meta) || declaresWorker(meta));
|
|
8568
8967
|
}
|
|
8569
8968
|
function projectRequiresDeployCoords(model, stage2, meta) {
|
|
8570
|
-
if (model
|
|
8969
|
+
if (!isCentralContainerModel2(model)) return false;
|
|
8571
8970
|
if (stage2 && isNoEdgeTenantWorker(meta, model)) return stage2 === "main";
|
|
8572
8971
|
return true;
|
|
8573
8972
|
}
|
|
@@ -8600,7 +8999,7 @@ function attestedLine(att) {
|
|
|
8600
8999
|
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: [] }).";
|
|
8601
9000
|
function appGapsFor(meta, model, slug, projectType) {
|
|
8602
9001
|
const attested = appAttestationOf(meta);
|
|
8603
|
-
const isTenantWeb = !(projectType === "content" || model === "content") && projectType !== "desktop-game" && !(projectType === "non-deployable" || model === "none") && model !== "hub-serverless" && model !== "serverless";
|
|
9002
|
+
const isTenantWeb = !(projectType === "content" || model === "content") && projectType !== "desktop-game" && projectType !== "cli-tool" && !(projectType === "non-deployable" || model === "none") && model !== "hub-serverless" && model !== "serverless" && model !== "registry-publish";
|
|
8604
9003
|
const contractUndeclared = isTenantWeb && Boolean(meta) && !hasRuntimeSecretContract(meta?.requiredRuntimeSecrets);
|
|
8605
9004
|
if (attested) return contractUndeclared ? [attestedLine(attested), CONTRACT_UNDECLARED_LINE] : [attestedLine(attested)];
|
|
8606
9005
|
if (projectType === "content" || model === "content") return ["Content/KB repo: keep app-owned work to docs/content changes; release train does not apply."];
|
|
@@ -8629,13 +9028,20 @@ function appGapsFor(meta, model, slug, projectType) {
|
|
|
8629
9028
|
"Keep app-owned README.md and architecture.md aligned with v2 central deploy/secrets reality."
|
|
8630
9029
|
];
|
|
8631
9030
|
}
|
|
9031
|
+
if (projectType === "cli-tool" || model === "registry-publish") {
|
|
9032
|
+
return [
|
|
9033
|
+
"Distributable CLI/plugin: ship a publishable package.json (name, version, bin/exports) \u2014 release publishes to npm (plugin-marketplace publish is future work when a plugin tenant lands), not a box deploy; no Hub deploy coords, edge, or OAuth.",
|
|
9034
|
+
"Keep the published version in lockstep with the release tag; make the publish idempotent (skip when the version is already on the registry).",
|
|
9035
|
+
"Keep app-owned README.md and architecture.md aligned with the registry-publish release path."
|
|
9036
|
+
];
|
|
9037
|
+
}
|
|
8632
9038
|
const gaps = [
|
|
8633
9039
|
"Ensure the compose file carries env_file: .env and the app reads plain environment variables \u2014 the box injects coords + declared runtime secrets into the release .env; the app never self-loads SSM.",
|
|
8634
9040
|
"Make app config fail clearly for missing required env in prod/rc instead of relying on hidden defaults.",
|
|
8635
9041
|
"Keep app-owned README.md and architecture.md aligned with v2 central deploy/secrets reality."
|
|
8636
9042
|
];
|
|
8637
9043
|
if (isNoEdgeTenantWorker(meta, model)) {
|
|
8638
|
-
gaps.unshift("No public edge
|
|
9044
|
+
gaps.unshift("No public edge (worker/outbound-only tenant \u2014 `projectType: worker` or `edgeDomains: {}`): skip Cloudflare edge auto-heal, OAuth defaults, and dev/rc remote deploy coords unless META explicitly adds them.");
|
|
8639
9045
|
}
|
|
8640
9046
|
if (contractUndeclared) {
|
|
8641
9047
|
gaps.unshift(CONTRACT_UNDECLARED_LINE);
|
|
@@ -8701,7 +9107,7 @@ function buildV2HealPatch(repoOrSlug, meta) {
|
|
|
8701
9107
|
}
|
|
8702
9108
|
if (!meta?.vaultPath) patch.vaultPath = `/mmi-future/${slug}`;
|
|
8703
9109
|
if (!meta?.kbPointer) patch.kbPointer = `kb/projects/${slug}.md`;
|
|
8704
|
-
if (confidentType && !meta?.edgeDomains && model === "tenant-container") {
|
|
9110
|
+
if (confidentType && !meta?.edgeDomains && model === "tenant-container" && projectType !== "worker") {
|
|
8705
9111
|
patch.edgeDomains = {
|
|
8706
9112
|
dev: `dev.${sub}.mutatismutandis.co`,
|
|
8707
9113
|
rc: `rc.${sub}.mutatismutandis.co`,
|
|
@@ -8724,7 +9130,7 @@ function buildV2HealPatch(repoOrSlug, meta) {
|
|
|
8724
9130
|
patch.requiredRuntimeSecrets = next;
|
|
8725
9131
|
}
|
|
8726
9132
|
}
|
|
8727
|
-
const appOwnedGaps = confidentType ? appGapsFor(meta, model, slug, confidentType) : [`Project type is unset and not derivable \u2014 classify with \`mmi-cli project set ${repo} --project-type <web-app|hub-service|content|desktop-game|non-deployable> --deploy-model <tenant-container|hub-serverless|serverless|content|none>\` before heal completes the v2 fields (prevents defaulting a non-web repo to tenant-container).`];
|
|
9133
|
+
const appOwnedGaps = confidentType ? appGapsFor(meta, model, slug, confidentType) : [`Project type is unset and not derivable \u2014 classify with \`mmi-cli project set ${repo} --project-type <web-app|hub-service|content|desktop-game|non-deployable|cli-tool|worker> --deploy-model <tenant-container|solo-container|hub-serverless|serverless|registry-publish|content|none>\` before heal completes the v2 fields (prevents defaulting a non-web repo to tenant-container).`];
|
|
8728
9134
|
return { slug, patch, appOwnedGaps };
|
|
8729
9135
|
}
|
|
8730
9136
|
async function runV2Heal(repoOrSlug, opts, deps) {
|
|
@@ -8923,6 +9329,158 @@ function parseEdgeDomainsVar(raw) {
|
|
|
8923
9329
|
}
|
|
8924
9330
|
return out;
|
|
8925
9331
|
}
|
|
9332
|
+
var PRIORITY_OPTION_NAMES = ["Urgent", "High", "Medium", "Low"];
|
|
9333
|
+
function parsePriorityOptionsVar(raw) {
|
|
9334
|
+
let parsed;
|
|
9335
|
+
try {
|
|
9336
|
+
parsed = JSON.parse(raw);
|
|
9337
|
+
} catch {
|
|
9338
|
+
throw new Error('project set: priorityOptions must be JSON, e.g. {"Urgent":"<id>","High":"<id>","Medium":"<id>","Low":"<id>"}');
|
|
9339
|
+
}
|
|
9340
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
9341
|
+
throw new Error("project set: priorityOptions must be a {Urgent,High,Medium,Low} map of option-id strings");
|
|
9342
|
+
}
|
|
9343
|
+
const map = parsed;
|
|
9344
|
+
const out = {};
|
|
9345
|
+
for (const [name, id] of Object.entries(map)) {
|
|
9346
|
+
if (!PRIORITY_OPTION_NAMES.includes(name)) {
|
|
9347
|
+
throw new Error(`project set: priorityOptions "${name}" \u2014 expected only ${PRIORITY_OPTION_NAMES.join("/")}`);
|
|
9348
|
+
}
|
|
9349
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
9350
|
+
throw new Error(`project set: priorityOptions.${name} must be a non-empty option-id string`);
|
|
9351
|
+
}
|
|
9352
|
+
out[name] = id.trim();
|
|
9353
|
+
}
|
|
9354
|
+
return out;
|
|
9355
|
+
}
|
|
9356
|
+
function parsePortRangeVar(raw) {
|
|
9357
|
+
let parsed;
|
|
9358
|
+
try {
|
|
9359
|
+
parsed = JSON.parse(raw);
|
|
9360
|
+
} catch {
|
|
9361
|
+
throw new Error('project set: portRange must be JSON, e.g. {"start":3700,"end":3710} or [3700,3710]');
|
|
9362
|
+
}
|
|
9363
|
+
let start;
|
|
9364
|
+
let end;
|
|
9365
|
+
if (Array.isArray(parsed) && parsed.length === 2) {
|
|
9366
|
+
[start, end] = parsed;
|
|
9367
|
+
} else if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
9368
|
+
({ start, end } = parsed);
|
|
9369
|
+
} else {
|
|
9370
|
+
throw new Error("project set: portRange must be {start,end} or a [start,end] tuple");
|
|
9371
|
+
}
|
|
9372
|
+
if (typeof start !== "number" || typeof end !== "number" || !Number.isFinite(start) || !Number.isFinite(end)) {
|
|
9373
|
+
throw new Error("project set: portRange start/end must be finite numbers");
|
|
9374
|
+
}
|
|
9375
|
+
if (start > end) throw new Error("project set: portRange start must be <= end");
|
|
9376
|
+
return { start, end };
|
|
9377
|
+
}
|
|
9378
|
+
function parseStatusOptionsVar(raw) {
|
|
9379
|
+
let parsed;
|
|
9380
|
+
try {
|
|
9381
|
+
parsed = JSON.parse(raw);
|
|
9382
|
+
} catch {
|
|
9383
|
+
throw new Error('project set: statusOptions must be JSON, e.g. {"Todo":"<id>","In Progress":"<id>","In Review":"<id>","Done":"<id>"}');
|
|
9384
|
+
}
|
|
9385
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
9386
|
+
throw new Error("project set: statusOptions must be a map of status name to option-id string");
|
|
9387
|
+
}
|
|
9388
|
+
const map = parsed;
|
|
9389
|
+
const out = {};
|
|
9390
|
+
for (const [name, id] of Object.entries(map)) {
|
|
9391
|
+
if (typeof id !== "string" || !id.trim()) {
|
|
9392
|
+
throw new Error(`project set: statusOptions.${name} must be a non-empty option-id string`);
|
|
9393
|
+
}
|
|
9394
|
+
out[name] = id.trim();
|
|
9395
|
+
}
|
|
9396
|
+
return out;
|
|
9397
|
+
}
|
|
9398
|
+
function parseOauthVar(raw) {
|
|
9399
|
+
let parsed;
|
|
9400
|
+
try {
|
|
9401
|
+
parsed = JSON.parse(raw);
|
|
9402
|
+
} catch {
|
|
9403
|
+
throw new Error('project set: oauth must be JSON, e.g. {"subdomains":["app"],"domains":["example.co"],"callbackPath":"/auth/callback"}');
|
|
9404
|
+
}
|
|
9405
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
9406
|
+
throw new Error("project set: oauth must be a {subdomains,domains,callbackPath} object");
|
|
9407
|
+
}
|
|
9408
|
+
const map = parsed;
|
|
9409
|
+
const out = {};
|
|
9410
|
+
for (const [key, value] of Object.entries(map)) {
|
|
9411
|
+
if (key === "subdomains" || key === "domains") {
|
|
9412
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) {
|
|
9413
|
+
throw new Error(`project set: oauth.${key} must be an array of non-empty strings`);
|
|
9414
|
+
}
|
|
9415
|
+
out[key] = value.map((v) => v.trim());
|
|
9416
|
+
} else if (key === "callbackPath") {
|
|
9417
|
+
if (typeof value !== "string" || !value.trim()) throw new Error("project set: oauth.callbackPath must be a non-empty string");
|
|
9418
|
+
out.callbackPath = value.trim();
|
|
9419
|
+
} else {
|
|
9420
|
+
throw new Error(`project set: oauth key "${key}" \u2014 expected only subdomains/domains/callbackPath`);
|
|
9421
|
+
}
|
|
9422
|
+
}
|
|
9423
|
+
return out;
|
|
9424
|
+
}
|
|
9425
|
+
function parseReposVar(raw) {
|
|
9426
|
+
let parsed;
|
|
9427
|
+
try {
|
|
9428
|
+
parsed = JSON.parse(raw);
|
|
9429
|
+
} catch {
|
|
9430
|
+
throw new Error('project set: repos must be a JSON array, e.g. ["mutmutco/mm-foo"]');
|
|
9431
|
+
}
|
|
9432
|
+
if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((r) => typeof r !== "string" || !r.trim())) {
|
|
9433
|
+
throw new Error("project set: repos must be a non-empty array of owner/name strings");
|
|
9434
|
+
}
|
|
9435
|
+
return parsed.map((r) => r.trim());
|
|
9436
|
+
}
|
|
9437
|
+
function parsePublishRequiredVar(raw) {
|
|
9438
|
+
if (raw === "true") return true;
|
|
9439
|
+
if (raw === "false") return false;
|
|
9440
|
+
throw new Error("project set: publishRequired must be true or false");
|
|
9441
|
+
}
|
|
9442
|
+
var SETTABLE_VAR_KEYS = [
|
|
9443
|
+
"name",
|
|
9444
|
+
"division",
|
|
9445
|
+
"projectId",
|
|
9446
|
+
"projectOwner",
|
|
9447
|
+
"projectNumber",
|
|
9448
|
+
"branch",
|
|
9449
|
+
"wikiRepo",
|
|
9450
|
+
"vaultPath",
|
|
9451
|
+
"kbPointer",
|
|
9452
|
+
"repos",
|
|
9453
|
+
"oauth",
|
|
9454
|
+
"publishRequired",
|
|
9455
|
+
"requiredGcpApis",
|
|
9456
|
+
"requiredRuntimeSecrets",
|
|
9457
|
+
"edgeDomains",
|
|
9458
|
+
"statusFieldId",
|
|
9459
|
+
"statusOptions",
|
|
9460
|
+
"priorityFieldId",
|
|
9461
|
+
"priorityOptions",
|
|
9462
|
+
"portRange"
|
|
9463
|
+
];
|
|
9464
|
+
var SETTABLE_VAR_KEY_SET = new Set(SETTABLE_VAR_KEYS);
|
|
9465
|
+
var SETTABLE_VAR_HINTS = {
|
|
9466
|
+
projectNumber: "numeric",
|
|
9467
|
+
publishRequired: "true|false",
|
|
9468
|
+
repos: 'JSON array, e.g. ["mutmutco/mm-foo"]',
|
|
9469
|
+
oauth: "JSON {subdomains,domains,callbackPath}",
|
|
9470
|
+
requiredGcpApis: "comma-string",
|
|
9471
|
+
requiredRuntimeSecrets: 'JSON stage map, e.g. {"dev":["KEY"],"rc":["KEY"],"main":["KEY"]}',
|
|
9472
|
+
edgeDomains: "JSON {dev,rc,main} domain map",
|
|
9473
|
+
statusOptions: "JSON name\u2192id map",
|
|
9474
|
+
priorityOptions: "JSON {Urgent,High,Medium,Low}\u2192id map",
|
|
9475
|
+
portRange: "JSON {start,end} or [start,end]"
|
|
9476
|
+
};
|
|
9477
|
+
function settableVarHelp() {
|
|
9478
|
+
const keys = SETTABLE_VAR_KEYS.map((k) => {
|
|
9479
|
+
const hint = SETTABLE_VAR_HINTS[k];
|
|
9480
|
+
return hint ? `${k} (${hint})` : k;
|
|
9481
|
+
});
|
|
9482
|
+
return `META field to set (repeatable): ${keys.join(", ")}`;
|
|
9483
|
+
}
|
|
8926
9484
|
function buildProjectSetPatch(input) {
|
|
8927
9485
|
const patch = {};
|
|
8928
9486
|
if (input.class) {
|
|
@@ -8943,22 +9501,42 @@ function buildProjectSetPatch(input) {
|
|
|
8943
9501
|
}
|
|
8944
9502
|
patch.deployModel = input.deployModel;
|
|
8945
9503
|
}
|
|
9504
|
+
if (input.releaseTrack) {
|
|
9505
|
+
if (!isReleaseTrack(input.releaseTrack)) {
|
|
9506
|
+
throw new Error(`project set: --release-track must be one of: ${RELEASE_TRACKS.join(", ")}`);
|
|
9507
|
+
}
|
|
9508
|
+
patch.releaseTrack = input.releaseTrack;
|
|
9509
|
+
}
|
|
8946
9510
|
for (const value of input.vars) {
|
|
8947
9511
|
const eq = value.indexOf("=");
|
|
8948
|
-
if (eq
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
|
|
8958
|
-
|
|
8959
|
-
|
|
8960
|
-
|
|
8961
|
-
|
|
9512
|
+
if (eq <= 0) continue;
|
|
9513
|
+
const key = value.slice(0, eq);
|
|
9514
|
+
const raw = value.slice(eq + 1);
|
|
9515
|
+
if (!SETTABLE_VAR_KEY_SET.has(key)) {
|
|
9516
|
+
throw new Error(`project set: --var KEY "${key}" is not settable \u2014 allowed keys: ${SETTABLE_VAR_KEYS.join(", ")}`);
|
|
9517
|
+
}
|
|
9518
|
+
if (key === "projectNumber") {
|
|
9519
|
+
const n = Number(raw);
|
|
9520
|
+
if (!Number.isFinite(n)) throw new Error("project set: projectNumber must be numeric");
|
|
9521
|
+
patch[key] = n;
|
|
9522
|
+
} else if (key === "requiredRuntimeSecrets") {
|
|
9523
|
+
patch[key] = parseRuntimeSecretsVar(raw);
|
|
9524
|
+
} else if (key === "edgeDomains") {
|
|
9525
|
+
patch[key] = parseEdgeDomainsVar(raw);
|
|
9526
|
+
} else if (key === "priorityOptions") {
|
|
9527
|
+
patch[key] = parsePriorityOptionsVar(raw);
|
|
9528
|
+
} else if (key === "statusOptions") {
|
|
9529
|
+
patch[key] = parseStatusOptionsVar(raw);
|
|
9530
|
+
} else if (key === "portRange") {
|
|
9531
|
+
patch[key] = parsePortRangeVar(raw);
|
|
9532
|
+
} else if (key === "oauth") {
|
|
9533
|
+
patch[key] = parseOauthVar(raw);
|
|
9534
|
+
} else if (key === "repos") {
|
|
9535
|
+
patch[key] = parseReposVar(raw);
|
|
9536
|
+
} else if (key === "publishRequired") {
|
|
9537
|
+
patch[key] = parsePublishRequiredVar(raw);
|
|
9538
|
+
} else {
|
|
9539
|
+
patch[key] = raw;
|
|
8962
9540
|
}
|
|
8963
9541
|
}
|
|
8964
9542
|
for (const key of input.unsets) {
|
|
@@ -8972,7 +9550,7 @@ function buildProjectSetPatch(input) {
|
|
|
8972
9550
|
patch.edgeDomains = null;
|
|
8973
9551
|
}
|
|
8974
9552
|
if (Object.keys(patch).length === 0) {
|
|
8975
|
-
throw new Error("project set: nothing to set - pass --class, --project-type, --deploy-model, --var KEY=VALUE, --unset KEY, and/or --clear-web-profile");
|
|
9553
|
+
throw new Error("project set: nothing to set - pass --class, --project-type, --deploy-model, --release-track, --var KEY=VALUE, --unset KEY, and/or --clear-web-profile");
|
|
8976
9554
|
}
|
|
8977
9555
|
return patch;
|
|
8978
9556
|
}
|
|
@@ -8997,8 +9575,8 @@ function resolveKbSource(rawBase) {
|
|
|
8997
9575
|
return { owner: m[1], repo: m[2], ref: m[3] };
|
|
8998
9576
|
}
|
|
8999
9577
|
function buildKbGetArgs(src, path2) {
|
|
9000
|
-
const
|
|
9001
|
-
return ["api", `repos/${src.owner}/${src.repo}/contents/${
|
|
9578
|
+
const clean3 = path2.replace(/^\/+/, "");
|
|
9579
|
+
return ["api", `repos/${src.owner}/${src.repo}/contents/${clean3}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
|
|
9002
9580
|
}
|
|
9003
9581
|
function buildKbTreeArgs(src) {
|
|
9004
9582
|
return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
|
|
@@ -9807,7 +10385,7 @@ function authorizeBodyHasMismatch(body) {
|
|
|
9807
10385
|
}
|
|
9808
10386
|
|
|
9809
10387
|
// src/index.ts
|
|
9810
|
-
var rawExecFileP3 = (0, import_node_util6.promisify)(
|
|
10388
|
+
var rawExecFileP3 = (0, import_node_util6.promisify)(import_node_child_process7.execFile);
|
|
9811
10389
|
var DEFAULT_EXEC_TIMEOUT_MS = 1e4;
|
|
9812
10390
|
var execFileP4 = (file, args, options = {}) => (
|
|
9813
10391
|
// encoding 'utf8' guarantees string stdout/stderr at runtime; the cast pins the type because
|
|
@@ -10086,12 +10664,10 @@ async function applyVersionAutoUpdate(report, log) {
|
|
|
10086
10664
|
async function requireFreshTrainCli(commandName) {
|
|
10087
10665
|
const report = buildVersionLagReport({
|
|
10088
10666
|
currentVersion: resolveClientVersion(),
|
|
10089
|
-
repoVersion: readRepoVersion(),
|
|
10090
10667
|
releasedVersion: await fetchReleasedVersion()
|
|
10091
10668
|
});
|
|
10092
10669
|
if (report.ok) return;
|
|
10093
|
-
|
|
10094
|
-
throw new Error(`running mmi-cli ${report.currentVersion} is stale against ${target}; run doctor/update first so ${commandName} uses the current train path`);
|
|
10670
|
+
throw new Error(`running mmi-cli ${report.currentVersion} is stale against released ${report.releasedVersion}; run doctor/update first so ${commandName} uses the current train path`);
|
|
10095
10671
|
}
|
|
10096
10672
|
var consoleIo = { log: (m) => console.log(m), err: (m) => console.error(m) };
|
|
10097
10673
|
var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
|
|
@@ -10236,7 +10812,7 @@ saga.command("head-update").option("--run", "detached worker: fetch state, run t
|
|
|
10236
10812
|
if (!headGateDue(tsPath)) return;
|
|
10237
10813
|
markHeadRun(tsPath);
|
|
10238
10814
|
try {
|
|
10239
|
-
(0,
|
|
10815
|
+
(0, import_node_child_process7.spawn)(process.execPath, [process.argv[1], "saga", "head-update", "--run"], {
|
|
10240
10816
|
detached: true,
|
|
10241
10817
|
stdio: "ignore",
|
|
10242
10818
|
windowsHide: true
|
|
@@ -10456,7 +11032,7 @@ function scheduleRelatedDiscovery(o) {
|
|
|
10456
11032
|
try {
|
|
10457
11033
|
const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
|
|
10458
11034
|
if (o.repo) args.push("--repo", o.repo);
|
|
10459
|
-
(0,
|
|
11035
|
+
(0, import_node_child_process7.spawn)(process.execPath, [process.argv[1], ...args], {
|
|
10460
11036
|
detached: true,
|
|
10461
11037
|
stdio: "ignore",
|
|
10462
11038
|
windowsHide: true,
|
|
@@ -10512,7 +11088,7 @@ function openInEditor(path2) {
|
|
|
10512
11088
|
return;
|
|
10513
11089
|
}
|
|
10514
11090
|
try {
|
|
10515
|
-
(0,
|
|
11091
|
+
(0, import_node_child_process7.spawn)(editor, [path2], { stdio: "inherit" });
|
|
10516
11092
|
} catch {
|
|
10517
11093
|
console.log(`open ${path2} manually`);
|
|
10518
11094
|
}
|
|
@@ -10671,15 +11247,22 @@ function reportWrite(label, res) {
|
|
|
10671
11247
|
fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
|
|
10672
11248
|
}
|
|
10673
11249
|
var tenant = program2.command("tenant").description("tenant runtime control through Hub authority");
|
|
10674
|
-
tenant.command("control <owner/repo> <stage> <action>").description("run bounded service control (status/start/stop/restart, plus rc-only retire) for a tenant; project-admin dev/rc, master main").option("--json", "machine-readable output").action(async (repo, stage2, action) => {
|
|
11250
|
+
tenant.command("control <owner/repo> <stage> <action>").description("run bounded service control (status/start/stop/restart, plus rc-only retire and read-only verify-secrets) for a tenant; project-admin dev/rc, master main").option("--json", "machine-readable output").action(async (repo, stage2, action, o) => {
|
|
10675
11251
|
const cfg = await loadConfig();
|
|
10676
|
-
const wait = action
|
|
11252
|
+
const wait = tenantControlWait(action);
|
|
10677
11253
|
const res = await tenantControl({ repo, stage: stage2, action, wait }, registryClientDeps(cfg));
|
|
10678
11254
|
const body = res.body;
|
|
10679
11255
|
if (!res.ok && body?.category) {
|
|
10680
11256
|
console.log(JSON.stringify(body));
|
|
10681
11257
|
return fail(`tenant control ${stage2} ${action}: ${body.category}`);
|
|
10682
11258
|
}
|
|
11259
|
+
if (res.ok && action === "verify-secrets") {
|
|
11260
|
+
const { lines, failure } = renderVerifySecrets(res.body);
|
|
11261
|
+
if (o.json) console.log(JSON.stringify(res.body));
|
|
11262
|
+
else lines.forEach((l) => console.log(l));
|
|
11263
|
+
if (failure) return fail(`tenant control ${stage2} verify-secrets: ${failure}`);
|
|
11264
|
+
return;
|
|
11265
|
+
}
|
|
10683
11266
|
reportWrite("tenant control", res);
|
|
10684
11267
|
});
|
|
10685
11268
|
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) => {
|
|
@@ -10691,6 +11274,31 @@ tenant.command("redeploy <owner/repo> <stage>").description("re-dispatch the cen
|
|
|
10691
11274
|
return fail(`tenant redeploy: ${e.message}`);
|
|
10692
11275
|
}
|
|
10693
11276
|
});
|
|
11277
|
+
tenant.command("sweep-rc").description("discover (and optionally retire) running rc tenant runtimes across tenant-containers \u2014 orphan cleanup after a failed post-release retire (#942)").option("--retire", "retire every running rc runtime found (requires --yes) \u2014 WARNING: tears down a legitimately-staged rc too").option("--yes", "confirm the destructive --retire").option("--json", "machine-readable output").action(async (o) => {
|
|
11278
|
+
if (o.retire && !o.yes) {
|
|
11279
|
+
return fail("tenant sweep-rc --retire is destructive (it tears down EVERY running rc, including one legitimately staged between /rcand and /release) \u2014 re-run with --yes to confirm");
|
|
11280
|
+
}
|
|
11281
|
+
const cfg = await loadConfig();
|
|
11282
|
+
const cdeps = registryClientDeps(cfg);
|
|
11283
|
+
try {
|
|
11284
|
+
const result = await sweepRcOrphans({
|
|
11285
|
+
listProjects: () => fetchProjectsList(cdeps),
|
|
11286
|
+
status: async (repo) => {
|
|
11287
|
+
const res = await tenantControl({ repo, stage: "rc", action: "status", wait: true }, cdeps);
|
|
11288
|
+
const b = res.body;
|
|
11289
|
+
return { serviceState: b?.serviceState ?? "unknown" };
|
|
11290
|
+
},
|
|
11291
|
+
retire: async (repo) => {
|
|
11292
|
+
const res = await tenantControl({ repo, stage: "rc", action: "retire", wait: true }, cdeps);
|
|
11293
|
+
const b = res.body;
|
|
11294
|
+
return { ok: res.ok, category: b?.category, reason: b?.reason };
|
|
11295
|
+
}
|
|
11296
|
+
}, { retire: !!o.retire });
|
|
11297
|
+
return printLine(o.json ? JSON.stringify(result) : renderSweep(result));
|
|
11298
|
+
} catch (e) {
|
|
11299
|
+
return fail(`tenant sweep-rc: ${e.message}`);
|
|
11300
|
+
}
|
|
11301
|
+
});
|
|
10694
11302
|
async function resolveDnsBounded(host, timeoutMs = 3e3) {
|
|
10695
11303
|
const { lookup } = await import("node:dns/promises");
|
|
10696
11304
|
const probe = lookup(host).then(() => true).catch((e) => dnsErrorToResolution(e?.code));
|
|
@@ -10859,7 +11467,7 @@ project.command("attest [owner/repo]").description("attest this repo's app-owned
|
|
|
10859
11467
|
const res = await attestAppGaps(slugOf(target), repo, registryClientDeps(cfg));
|
|
10860
11468
|
reportWrite("project attest", res);
|
|
10861
11469
|
});
|
|
10862
|
-
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("--
|
|
11470
|
+
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("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).option("--var <KEY=VALUE...>", settableVarHelp()).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) => {
|
|
10863
11471
|
const cfg = await loadConfig();
|
|
10864
11472
|
let target;
|
|
10865
11473
|
try {
|
|
@@ -10874,6 +11482,7 @@ project.command("set [owner/repo]").description("MASTER-ONLY: upsert a project M
|
|
|
10874
11482
|
class: o.class,
|
|
10875
11483
|
projectType: o.projectType,
|
|
10876
11484
|
deployModel: o.deployModel,
|
|
11485
|
+
releaseTrack: o.releaseTrack,
|
|
10877
11486
|
vars: rawValues("--var"),
|
|
10878
11487
|
unsets: rawValues("--unset"),
|
|
10879
11488
|
clearWebProfile: Boolean(o.clearWebProfile)
|
|
@@ -11151,7 +11760,7 @@ function teardownWorktreeStage(worktreePath) {
|
|
|
11151
11760
|
}
|
|
11152
11761
|
});
|
|
11153
11762
|
}
|
|
11154
|
-
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) => {
|
|
11763
|
+
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)").option("--auto", "enable auto-merge \u2014 merge once the base-branch policy is satisfied (use for policy-gated repos)").action(async (number, o) => {
|
|
11155
11764
|
const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
|
|
11156
11765
|
const repoArgs = o.repo ? ["--repo", o.repo] : [];
|
|
11157
11766
|
const headRef = (await execFileP4("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
|
|
@@ -11162,7 +11771,7 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
|
|
|
11162
11771
|
const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
|
|
11163
11772
|
let remoteDeleteAttempted = false;
|
|
11164
11773
|
let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
|
|
11165
|
-
await execFileP4("gh",
|
|
11774
|
+
await execFileP4("gh", buildPrMergeArgs({ number, repoArgs, method, auto: o.auto }), { timeout: GH_MUTATION_TIMEOUT_MS }).catch((e) => {
|
|
11166
11775
|
const message = String(e.message || "");
|
|
11167
11776
|
if (/already been merged/i.test(message)) {
|
|
11168
11777
|
remoteNotAttemptedReason = "pr-already-merged";
|
|
@@ -11170,8 +11779,18 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
|
|
|
11170
11779
|
}
|
|
11171
11780
|
const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
|
|
11172
11781
|
if (note) throw new Error(`gh pr merge ${number}: ${note}`);
|
|
11782
|
+
if (!o.auto && basePolicyBlocksImmediateMerge(message)) {
|
|
11783
|
+
throw new Error(`gh pr merge ${number}: the base-branch policy blocks an immediate merge \u2014 re-run with --auto to merge once required checks pass.`);
|
|
11784
|
+
}
|
|
11173
11785
|
if (!/used by worktree|cannot delete branch/i.test(message)) throw e;
|
|
11174
11786
|
});
|
|
11787
|
+
if (o.auto) {
|
|
11788
|
+
const state = (await execFileP4("gh", ["pr", "view", number, ...repoArgs, "--json", "state", "--jq", ".state"], { timeout: GC_GH_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
|
|
11789
|
+
if (state !== "MERGED") {
|
|
11790
|
+
console.log(JSON.stringify({ mergeStatus: "auto-merge-enqueued", pr: number, branch: headRef, state: state || "unknown" }));
|
|
11791
|
+
return;
|
|
11792
|
+
}
|
|
11793
|
+
}
|
|
11175
11794
|
if (!remoteNotAttemptedReason) remoteDeleteAttempted = true;
|
|
11176
11795
|
const remoteBranch = repoArgs.length ? buildRemoteBranchCleanupReport(headRef, {
|
|
11177
11796
|
attempted: false,
|
|
@@ -11191,7 +11810,11 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
|
|
|
11191
11810
|
beforeWorktrees,
|
|
11192
11811
|
startingPath,
|
|
11193
11812
|
execGit: async (args) => (await execFileP4("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
|
|
11194
|
-
teardownWorktreeStage
|
|
11813
|
+
teardownWorktreeStage,
|
|
11814
|
+
// Hardened fallback when retried `git worktree remove` still hits a Windows file lock (#967).
|
|
11815
|
+
// `fs.rm` recursive removes a junction as a link (it does not traverse into the target) and its
|
|
11816
|
+
// own maxRetries/retryDelay rides out a handle that an indexer/antivirus releases a moment later.
|
|
11817
|
+
removeWorktreeDir: async (worktreePath) => (0, import_promises2.rm)(worktreePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
|
11195
11818
|
});
|
|
11196
11819
|
console.log(JSON.stringify(buildPrMergeResultPayload({
|
|
11197
11820
|
number,
|
|
@@ -11370,6 +11993,19 @@ function reportedStageUrl(res, result) {
|
|
|
11370
11993
|
return result.port != null ? stageUrlForPort(result.port) : res.derived.url;
|
|
11371
11994
|
}
|
|
11372
11995
|
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) => {
|
|
11996
|
+
const cfg = await loadConfig();
|
|
11997
|
+
const reg = registryClientDeps(cfg);
|
|
11998
|
+
const slug = slugOf(repo);
|
|
11999
|
+
const read = await fetchProjectBySlugChecked(slug, reg);
|
|
12000
|
+
const decision = decidePortRange({ metaReadOk: read.ok, metaPortRange: read.ok ? metaPortRange(read.project) : null });
|
|
12001
|
+
if (decision.action === "fail") {
|
|
12002
|
+
return fail(`port-range: ${decision.reason}${read.ok ? "" : ` (${read.error})`}`);
|
|
12003
|
+
}
|
|
12004
|
+
if (decision.action === "return") {
|
|
12005
|
+
const [start2, end2] = decision.range;
|
|
12006
|
+
printLine(o.json ? JSON.stringify({ repo, portRange: [start2, end2], source: "meta" }) : `${repo}: stage.portRange [${start2}, ${end2}]`);
|
|
12007
|
+
return;
|
|
12008
|
+
}
|
|
11373
12009
|
const path2 = (0, import_node_path8.join)(process.cwd(), "infra", "port-ranges.json");
|
|
11374
12010
|
const allocate = async (seed) => {
|
|
11375
12011
|
const { stdout } = await execFileP4("node", [(0, import_node_path8.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
|
|
@@ -11377,8 +12013,16 @@ program2.command("port-range <repo>").description("assign (idempotently) + print
|
|
|
11377
12013
|
if (!Array.isArray(parsed.range) || parsed.range.length !== 2) throw new Error("port-ddb: no range in output");
|
|
11378
12014
|
return parsed.range;
|
|
11379
12015
|
};
|
|
11380
|
-
const { range: [start, end] } = await ensurePortRangeAtomic(repo, path2, allocate);
|
|
11381
|
-
|
|
12016
|
+
const { range: [start, end], source } = await ensurePortRangeAtomic(repo, path2, allocate);
|
|
12017
|
+
const write = await upsertProject(slug, { portRange: { start, end } }, reg);
|
|
12018
|
+
if (!write.ok && source === "ddb") {
|
|
12019
|
+
return fail(`port-range: block [${start}, ${end}] was allocated (cursor advanced) but NOT recorded in the registry META (${write.error ?? `HTTP ${write.status}`}) \u2014 fix auth/connectivity and retry so the block is persisted; do not re-run blind`);
|
|
12020
|
+
}
|
|
12021
|
+
if (o.json) {
|
|
12022
|
+
printLine(JSON.stringify({ repo, portRange: [start, end], source: "allocated", persisted: write.ok, ...write.ok ? {} : { persistError: write.error ?? `HTTP ${write.status}` } }));
|
|
12023
|
+
} else {
|
|
12024
|
+
printLine(`${repo}: stage.portRange [${start}, ${end}]${write.ok ? "" : ` (META not persisted: ${write.error ?? `HTTP ${write.status}`})`}`);
|
|
12025
|
+
}
|
|
11382
12026
|
});
|
|
11383
12027
|
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) => {
|
|
11384
12028
|
const res = await resolveStage();
|
|
@@ -11491,17 +12135,32 @@ program2.command("stage-live").description("explain that remote rc/live environm
|
|
|
11491
12135
|
var GH_TRAIN_TIMEOUT_MS = 3e4;
|
|
11492
12136
|
var GH_RUN_WATCH_TIMEOUT_MS = 20 * 6e4;
|
|
11493
12137
|
var NODE_PREPARE_TIMEOUT_MS = 10 * 6e4;
|
|
12138
|
+
var NPM_TRAIN_TIMEOUT_MS = 6e4;
|
|
11494
12139
|
function trainApplyDeps() {
|
|
11495
12140
|
return {
|
|
11496
12141
|
run: async (file, args) => {
|
|
11497
|
-
const timeout = file === "node" && args[1] === "prepare" ? NODE_PREPARE_TIMEOUT_MS : file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
|
|
11498
|
-
return (await execFileP4(file, args, { timeout })).stdout;
|
|
12142
|
+
const timeout = file === "node" && args[1] === "prepare" ? NODE_PREPARE_TIMEOUT_MS : file === "npm" ? NPM_TRAIN_TIMEOUT_MS : file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
|
|
12143
|
+
return isWin && file === "npm" ? (await execFileP4("cmd.exe", ["/c", "npm", ...args], { timeout })).stdout : (await execFileP4(file, args, { timeout })).stdout;
|
|
11499
12144
|
},
|
|
11500
12145
|
runSelf: async (args) => (await execFileP4(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
|
|
11501
12146
|
trainAuthority: async (repo) => {
|
|
11502
12147
|
const verdict = await fetchTrainAuthority(repo, registryClientDeps(await loadConfig()));
|
|
11503
12148
|
return verdict.ok ? { ok: true, role: verdict.authority.role, train: verdict.authority.train } : verdict;
|
|
11504
12149
|
},
|
|
12150
|
+
// Hub-App-authority dispatch of the central tenant deploy (#953) — the Hub fires the
|
|
12151
|
+
// workflow_dispatch with its App token, so the caller needs no MMI-Hub Actions write.
|
|
12152
|
+
dispatchTenantDeploy: async ({ repo, stage: stage2, ref }) => {
|
|
12153
|
+
const res = await tenantDeploy({ repo, stage: stage2, ref }, registryClientDeps(await loadConfig()));
|
|
12154
|
+
if (!res.ok) {
|
|
12155
|
+
const detail = res.body?.error ?? res.error ?? `HTTP ${res.status}`;
|
|
12156
|
+
throw new Error(`tenant deploy dispatch failed: ${detail}`);
|
|
12157
|
+
}
|
|
12158
|
+
},
|
|
12159
|
+
// Hotfix-coverage guard (#958): runs against the local clone via real git. manifestPaths exempts the
|
|
12160
|
+
// release version fold (#976) — a main-only commit touching ONLY the root package manifest is the
|
|
12161
|
+
// fold's version metadata, which the candidate replaces with its own. (The Hub's wider distribution
|
|
12162
|
+
// set never reaches this guard: the Hub is direct-track.)
|
|
12163
|
+
hotfixCoverage: (input) => checkHotfixCoverage({ ...input, manifestPaths: ["package.json", "package-lock.json"] }),
|
|
11505
12164
|
// Slack release announcement (#883): Hub-only + best-effort inside announceRelease itself.
|
|
11506
12165
|
announce: (args) => announceRelease({
|
|
11507
12166
|
run: async (file, cmdArgs) => (await execFileP4(file, cmdArgs, { timeout: GH_TRAIN_TIMEOUT_MS })).stdout,
|
|
@@ -11516,14 +12175,6 @@ function trainApplyDeps() {
|
|
|
11516
12175
|
}
|
|
11517
12176
|
};
|
|
11518
12177
|
}
|
|
11519
|
-
function trainPrepDeps() {
|
|
11520
|
-
return {
|
|
11521
|
-
run: async (file, args) => {
|
|
11522
|
-
const timeout = file === "node" && args[1] === "prepare" ? NODE_PREPARE_TIMEOUT_MS : GIT_TIMEOUT_MS;
|
|
11523
|
-
return (await execFileP4(file, args, { timeout })).stdout;
|
|
11524
|
-
}
|
|
11525
|
-
};
|
|
11526
|
-
}
|
|
11527
12178
|
function formatWorkflowRun(r) {
|
|
11528
12179
|
const ref = r.runUrl ?? (r.runId != null ? String(r.runId) : "unresolved");
|
|
11529
12180
|
return `${r.workflow} ${ref} ${r.conclusion.toUpperCase()}`;
|
|
@@ -11539,6 +12190,7 @@ function renderDeployLine(d) {
|
|
|
11539
12190
|
}
|
|
11540
12191
|
function renderTrainApply(commandName, r) {
|
|
11541
12192
|
let base = `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
|
|
12193
|
+
if (r.versionFold) base = `${base}; ${r.versionFold}`;
|
|
11542
12194
|
if (r.resumeNote) base = `${base}; ${r.resumeNote}`;
|
|
11543
12195
|
if (r.rcRetirement) base = `${base}; rc retirement: ${r.rcRetirement.toUpperCase()} (${r.rcRetirementNote ?? ""})`;
|
|
11544
12196
|
return r.announceNote ? `${base}; announce: ${r.announceNote}` : base;
|
|
@@ -11554,37 +12206,20 @@ async function resolveRcandPlanTargets() {
|
|
|
11554
12206
|
return { plannedReleaseError: e.message, existingRcReleaseError: e.message };
|
|
11555
12207
|
}
|
|
11556
12208
|
}
|
|
11557
|
-
function renderTrainPrep(r) {
|
|
11558
|
-
if (!r.applied) {
|
|
11559
|
-
return `mmi-cli train prep: target ${r.target}; run ${r.command} to prepare and stage locked distribution files`;
|
|
11560
|
-
}
|
|
11561
|
-
return [
|
|
11562
|
-
`mmi-cli train prep: prepared ${r.target}`,
|
|
11563
|
-
` - staged locked files: ${r.stagedFiles.join(", ")}`,
|
|
11564
|
-
" - stopped before promotion; no push, merge, tag, release, or deploy was run",
|
|
11565
|
-
` - next: ${r.command}`
|
|
11566
|
-
].join("\n");
|
|
11567
|
-
}
|
|
11568
|
-
var trainCmd = program2.command("train").description("release train helpers that stop before promotion");
|
|
11569
|
-
trainCmd.command("prep").description("prepare and stage the Hub distribution bump for the next release train").option("--json", "machine-readable output").option("--apply", "run release-distribution prepare, exact-file stage, verify, then stop").action(async (o) => {
|
|
11570
|
-
try {
|
|
11571
|
-
await requireFreshTrainCli("train prep");
|
|
11572
|
-
const result = await runTrainPrep(trainPrepDeps(), { apply: o.apply });
|
|
11573
|
-
printLine(o.json ? JSON.stringify(result, null, 2) : renderTrainPrep(result));
|
|
11574
|
-
} catch (e) {
|
|
11575
|
-
fail(`train prep: ${e.message}`);
|
|
11576
|
-
}
|
|
11577
|
-
});
|
|
11578
12209
|
for (const commandName of ["rcand", "release"]) {
|
|
11579
|
-
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the deploy/publish workflow runs and report their outcomes").option("--apply", "execute the guarded master-only train after explicit approval").option("--announce-summary-file <path>", "release only: agent-curated summary lines for the Hub Slack announcement (#883)").action(async (o) => {
|
|
12210
|
+
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the deploy/publish workflow runs and report their outcomes").option("--apply", "execute the guarded master-only train after explicit approval").option("--announce-summary-file <path>", "release only: agent-curated summary lines for the Hub Slack announcement (#883)").option("--ack <shas>", "release only: comma-separated dev shas a human verified are in the candidate, overriding the hotfix-coverage guard for a conflicted port whose -x trailer was lost (#958)").action(async (o) => {
|
|
11580
12211
|
try {
|
|
11581
12212
|
await requireFreshTrainCli(commandName);
|
|
11582
12213
|
} catch (e) {
|
|
11583
12214
|
return fail(`${commandName}: ${e.message}`);
|
|
11584
12215
|
}
|
|
12216
|
+
if (o.ack && commandName !== "release") {
|
|
12217
|
+
return fail("--ack applies only to release: it overrides the rc -> main hotfix-coverage guard, which rcand does not run");
|
|
12218
|
+
}
|
|
11585
12219
|
if (o.apply) {
|
|
11586
12220
|
try {
|
|
11587
|
-
const
|
|
12221
|
+
const ack = (o.ack ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
12222
|
+
const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch, announceSummaryFile: o.announceSummaryFile, ack });
|
|
11588
12223
|
return printLine(o.json ? JSON.stringify(result, null, 2) : renderTrainApply(commandName, result));
|
|
11589
12224
|
} catch (e) {
|
|
11590
12225
|
return fail(`${commandName}: ${e.message}`);
|
|
@@ -11610,7 +12245,7 @@ function renderHotfixRelease(r) {
|
|
|
11610
12245
|
...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.url ? ` (${run.url})` : ""}`),
|
|
11611
12246
|
` - ${r.verifyNote}`,
|
|
11612
12247
|
...r.announceNote ? [` - announce: ${r.announceNote}`] : [],
|
|
11613
|
-
` - next: mmi-cli hotfix status ${r.tag} (no back-merge \u2014 development already has the fix;
|
|
12248
|
+
` - next: mmi-cli hotfix status ${r.tag} (no back-merge \u2014 development already has the fix; the next /release back-merge aligns the version manifests)`
|
|
11614
12249
|
].join("\n");
|
|
11615
12250
|
}
|
|
11616
12251
|
function renderHotfixStatus(r) {
|
|
@@ -11618,7 +12253,7 @@ function renderHotfixStatus(r) {
|
|
|
11618
12253
|
`mmi-cli hotfix status: ${r.tag} on ${r.repo} \u2014 ${r.state}`,
|
|
11619
12254
|
` - branch: ${r.branchExists ? "pushed" : "absent"} \xB7 PR: ${r.pr ? `#${r.pr.number} ${r.pr.state}` : "none"} \xB7 tag: ${r.tagPushed ? "pushed" : "absent"} \xB7 Release: ${r.releaseExists ? "exists" : "absent"}`,
|
|
11620
12255
|
...r.runs.map((run) => ` - ${run.workflow}: ${run.conclusion}${run.url ? ` (${run.url})` : ""}`),
|
|
11621
|
-
` - npm @mutmutco/cli: ${r.npmVersion}
|
|
12256
|
+
` - npm @mutmutco/cli: ${r.npmVersion}`,
|
|
11622
12257
|
` - next: ${r.next}`
|
|
11623
12258
|
].join("\n");
|
|
11624
12259
|
}
|
|
@@ -11683,15 +12318,16 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
|
|
|
11683
12318
|
return null;
|
|
11684
12319
|
}
|
|
11685
12320
|
}
|
|
11686
|
-
});
|
|
12321
|
+
}, resolveReleaseTrack(meta));
|
|
11687
12322
|
console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
|
|
11688
12323
|
if (!report.ok) process.exitCode = 1;
|
|
11689
12324
|
});
|
|
11690
|
-
bootstrap.command("apply <repo>").description("idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").option("--class <class>", "deployable | content", "deployable").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path)`).option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo) => {
|
|
12325
|
+
bootstrap.command("apply <repo>").description("idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").option("--class <class>", "deployable | content", "deployable").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path)`).option("--release-track <track>", `${RELEASE_TRACKS.join(" | ")} (branch topology; direct skips rc)`).option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo) => {
|
|
11691
12326
|
const o = {
|
|
11692
12327
|
class: rawValue("--class", "deployable"),
|
|
11693
12328
|
projectType: rawValue("--project-type", ""),
|
|
11694
12329
|
deployModel: rawValue("--deploy-model", ""),
|
|
12330
|
+
releaseTrack: rawValue("--release-track", ""),
|
|
11695
12331
|
execute: rawFlag("--execute"),
|
|
11696
12332
|
json: rawFlag("--json")
|
|
11697
12333
|
};
|
|
@@ -11710,10 +12346,19 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
11710
12346
|
const gh = async (args) => execFileP4("gh", args, { timeout: 2e4 });
|
|
11711
12347
|
const readFile2 = (p) => (0, import_node_fs7.existsSync)(p) ? (0, import_node_fs7.readFileSync)(p, "utf8") : null;
|
|
11712
12348
|
const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
|
|
11713
|
-
const
|
|
12349
|
+
const rawVars = {};
|
|
11714
12350
|
for (const value of rawValues("--var")) {
|
|
11715
12351
|
const eq = value.indexOf("=");
|
|
11716
|
-
if (eq > 0)
|
|
12352
|
+
if (eq > 0) rawVars[value.slice(0, eq)] = value.slice(eq + 1);
|
|
12353
|
+
}
|
|
12354
|
+
const vars = withDerivedRepoVars(rawVars, parsedRepo, o.class);
|
|
12355
|
+
if (vars.PROJECT_ID) {
|
|
12356
|
+
try {
|
|
12357
|
+
const r = await gh(boardFieldsQueryArgs(vars.PROJECT_ID));
|
|
12358
|
+
const boardVars = extractBoardFieldVars(JSON.parse(r.stdout));
|
|
12359
|
+
for (const [k, v] of Object.entries(boardVars)) if (vars[k] == null) vars[k] = v;
|
|
12360
|
+
} catch {
|
|
12361
|
+
}
|
|
11717
12362
|
}
|
|
11718
12363
|
const actions = [];
|
|
11719
12364
|
const applied = [];
|
|
@@ -11739,22 +12384,12 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
11739
12384
|
exists = false;
|
|
11740
12385
|
}
|
|
11741
12386
|
}
|
|
11742
|
-
const
|
|
12387
|
+
const planned = planSeedAction(resolved, exists);
|
|
12388
|
+
const isBlock = resolved.source === "managed-block";
|
|
12389
|
+
const content = planned.action === "create" || planned.action === "update" ? isBlock ? upsertManagedGitignoreBlock(remoteContent).content : resolveSeedContent(resolved, vars, readFile2) : null;
|
|
12390
|
+
const action = reconcileSeedAction(planned, content, isBlock);
|
|
11743
12391
|
actions.push(action);
|
|
11744
12392
|
if (o.execute && (action.action === "create" || action.action === "update")) {
|
|
11745
|
-
const isBlock = resolved.source === "managed-block";
|
|
11746
|
-
const content = isBlock ? upsertManagedGitignoreBlock(remoteContent).content : resolveSeedContent(resolved, vars, readFile2);
|
|
11747
|
-
if (content == null) {
|
|
11748
|
-
applied.push(`skip ${resolved.target} (no resolvable content)`);
|
|
11749
|
-
continue;
|
|
11750
|
-
}
|
|
11751
|
-
if (!isBlock) {
|
|
11752
|
-
const missing = missingPlaceholders(content);
|
|
11753
|
-
if (missing.length) {
|
|
11754
|
-
applied.push(`skip ${resolved.target} (unfilled: ${missing.join(", ")} \u2014 pass --var)`);
|
|
11755
|
-
continue;
|
|
11756
|
-
}
|
|
11757
|
-
}
|
|
11758
12393
|
await gh(contentPutArgs(repo, resolved.target, content, baseBranch, action.action === "update" ? sha : void 0));
|
|
11759
12394
|
applied.push(`${action.action} ${resolved.target}`);
|
|
11760
12395
|
}
|
|
@@ -11768,13 +12403,23 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
11768
12403
|
applied.push(`label ${l.name} (failed)`);
|
|
11769
12404
|
}
|
|
11770
12405
|
}
|
|
12406
|
+
for (const name of labelsToPrune(manifest.labels.map((l) => l.name))) {
|
|
12407
|
+
try {
|
|
12408
|
+
await gh(["label", "delete", name, "-R", repo, "--yes"]);
|
|
12409
|
+
applied.push(`label ${name} (pruned)`);
|
|
12410
|
+
} catch (e) {
|
|
12411
|
+
if (/not found/i.test(e.message ?? "")) continue;
|
|
12412
|
+
applied.push(`label ${name} (prune failed)`);
|
|
12413
|
+
}
|
|
12414
|
+
}
|
|
11771
12415
|
}
|
|
11772
12416
|
const ddbWrites = [];
|
|
11773
12417
|
let registerPayload;
|
|
11774
12418
|
try {
|
|
11775
12419
|
registerPayload = buildRegisterPayload(repo, o.class, vars, {
|
|
11776
12420
|
projectType: o.projectType || void 0,
|
|
11777
|
-
deployModel: o.deployModel || void 0
|
|
12421
|
+
deployModel: o.deployModel || void 0,
|
|
12422
|
+
releaseTrack: o.releaseTrack || void 0
|
|
11778
12423
|
});
|
|
11779
12424
|
} catch (e) {
|
|
11780
12425
|
return fail(`bootstrap apply: ${e.message}`);
|
|
@@ -11790,7 +12435,83 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
|
|
|
11790
12435
|
applied.push(`ddb register ${registerPayload.slug} (failed: ${why})`);
|
|
11791
12436
|
}
|
|
11792
12437
|
}
|
|
11793
|
-
|
|
12438
|
+
let fanoutPrUrl;
|
|
12439
|
+
if (o.execute) {
|
|
12440
|
+
const fanoutEntry = {
|
|
12441
|
+
repo: parsedRepo.name,
|
|
12442
|
+
slug,
|
|
12443
|
+
projectId: vars.PROJECT_ID || void 0,
|
|
12444
|
+
wikiRepo: vars.WIKI_REPO || parsedRepo.fullName,
|
|
12445
|
+
branch: baseBranch,
|
|
12446
|
+
cls: o.class,
|
|
12447
|
+
name: vars.NAME || parsedRepo.name
|
|
12448
|
+
};
|
|
12449
|
+
const readHubFile = async (path2) => {
|
|
12450
|
+
const r = await gh(["api", `repos/${HUB_REPO}/contents/${enc(path2)}?ref=development`]);
|
|
12451
|
+
const parsed = JSON.parse(r.stdout);
|
|
12452
|
+
if (parsed.encoding !== "base64" || typeof parsed.content !== "string" || !parsed.sha) {
|
|
12453
|
+
throw new Error(`could not read ${HUB_REPO}/${path2}`);
|
|
12454
|
+
}
|
|
12455
|
+
return { content: Buffer.from(parsed.content, "base64").toString("utf8"), sha: parsed.sha };
|
|
12456
|
+
};
|
|
12457
|
+
try {
|
|
12458
|
+
const fanoutFile = await readHubFile(".github/fanout-targets.json");
|
|
12459
|
+
const projectsFile = await readHubFile("projects.json");
|
|
12460
|
+
const plan2 = planFanoutRegistration(fanoutFile.content, projectsFile.content, fanoutEntry);
|
|
12461
|
+
if (!plan2.changed) {
|
|
12462
|
+
applied.push(`fanout: already registered (${parsedRepo.name})`);
|
|
12463
|
+
} else {
|
|
12464
|
+
const branchName = `bootstrap-register-fanout-${slug}`;
|
|
12465
|
+
const headSha = (await gh(["api", `repos/${HUB_REPO}/git/ref/heads/development`, "--jq", ".object.sha"])).stdout.trim();
|
|
12466
|
+
try {
|
|
12467
|
+
await gh(["api", "-X", "POST", `repos/${HUB_REPO}/git/refs`, "-f", `ref=refs/heads/${branchName}`, "-f", `sha=${headSha}`]);
|
|
12468
|
+
} catch (e) {
|
|
12469
|
+
if (!/Reference already exists|already exists/i.test(String(e.message ?? ""))) throw e;
|
|
12470
|
+
}
|
|
12471
|
+
const branchFileSha = async (path2) => {
|
|
12472
|
+
try {
|
|
12473
|
+
const r = await gh(["api", `repos/${HUB_REPO}/contents/${enc(path2)}?ref=${branchName}`, "--jq", ".sha"]);
|
|
12474
|
+
return r.stdout.trim() || void 0;
|
|
12475
|
+
} catch (e) {
|
|
12476
|
+
if (/404|Not Found/i.test(String(e.message ?? ""))) return void 0;
|
|
12477
|
+
throw e;
|
|
12478
|
+
}
|
|
12479
|
+
};
|
|
12480
|
+
const fanoutBranchSha = await branchFileSha(".github/fanout-targets.json");
|
|
12481
|
+
const projectsBranchSha = await branchFileSha("projects.json");
|
|
12482
|
+
await gh(contentPutArgs(HUB_REPO, ".github/fanout-targets.json", plan2.fanoutTargets, branchName, fanoutBranchSha));
|
|
12483
|
+
await gh(contentPutArgs(HUB_REPO, "projects.json", plan2.projects, branchName, projectsBranchSha));
|
|
12484
|
+
const openPrs = await gh(["pr", "list", "--repo", HUB_REPO, "--head", branchName, "--base", "development", "--state", "open", "--json", "number,url"]);
|
|
12485
|
+
const prDecision = decideFanoutPrAction(JSON.parse(openPrs.stdout || "[]"));
|
|
12486
|
+
if (prDecision.action === "reuse") {
|
|
12487
|
+
fanoutPrUrl = prDecision.url;
|
|
12488
|
+
} else {
|
|
12489
|
+
const created = await ghCreate([
|
|
12490
|
+
"pr",
|
|
12491
|
+
"create",
|
|
12492
|
+
"--repo",
|
|
12493
|
+
HUB_REPO,
|
|
12494
|
+
"--base",
|
|
12495
|
+
"development",
|
|
12496
|
+
"--head",
|
|
12497
|
+
branchName,
|
|
12498
|
+
"--title",
|
|
12499
|
+
`bootstrap: register ${parsedRepo.name} for org-spine fanout`,
|
|
12500
|
+
"--body",
|
|
12501
|
+
`Auto-opened by \`mmi-cli bootstrap apply --execute ${repo}\` (#933): adds ${parsedRepo.name} to projects.json + .github/fanout-targets.json so the spine fans out to it.`
|
|
12502
|
+
]);
|
|
12503
|
+
fanoutPrUrl = created.url;
|
|
12504
|
+
}
|
|
12505
|
+
await gh(["pr", "merge", fanoutPrUrl, "--repo", HUB_REPO, "--auto", "--squash"]).catch((e) => {
|
|
12506
|
+
if (!/already/i.test(String(e.message ?? ""))) throw e;
|
|
12507
|
+
});
|
|
12508
|
+
applied.push(`fanout: PR ${fanoutPrUrl} (auto-merge enabled)`);
|
|
12509
|
+
}
|
|
12510
|
+
} catch (e) {
|
|
12511
|
+
return fail(`bootstrap apply: fanout registration failed: ${e.message}`);
|
|
12512
|
+
}
|
|
12513
|
+
}
|
|
12514
|
+
if (o.json) console.log(JSON.stringify({ repo, class: o.class, execute: o.execute, actions, applied, ddbWrites, fanoutPrUrl }, null, 2));
|
|
11794
12515
|
else {
|
|
11795
12516
|
console.log(renderSeedPlan(actions));
|
|
11796
12517
|
if (o.execute) console.log(`
|
|
@@ -12196,7 +12917,7 @@ async function runDoctor(opts, io = consoleIo) {
|
|
|
12196
12917
|
io.log(gaps.length ? `
|
|
12197
12918
|
${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
|
|
12198
12919
|
}
|
|
12199
|
-
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, Cursor Team Marketplace plugin install), print fixes, and report per-surface versions + update recipes").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 (read-only inspection \u2014 performs no repairs)").option("--apply", "perform the same auto-repairs as the interactive run (combine with --json for a machine-readable repair run)").option("--no-repo-writes", "env/plugin repairs only \u2014 never mutate the repo working tree; report pending managed .gitignore repairs with the follow-up command (for
|
|
12920
|
+
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, Cursor Team Marketplace plugin install), print fixes, and report per-surface versions + update recipes").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 (read-only inspection \u2014 performs no repairs)").option("--apply", "perform the same auto-repairs as the interactive run (combine with --json for a machine-readable repair run)").option("--no-repo-writes", "env/plugin repairs only \u2014 never mutate the repo working tree; report pending managed .gitignore repairs with the follow-up command (for train preflights)").action((opts) => (
|
|
12200
12921
|
// Commander maps `--no-repo-writes` to `repoWrites: false`; translate to the explicit `noRepoWrites` flag.
|
|
12201
12922
|
runDoctor({ ...opts, noRepoWrites: opts.repoWrites === false })
|
|
12202
12923
|
));
|
|
@@ -12207,7 +12928,7 @@ program2.command("session-start").description("run the SessionStart verbs (rules
|
|
|
12207
12928
|
} catch (e) {
|
|
12208
12929
|
console.error(`[mmi-hook] saga session failed: ${e.message}`);
|
|
12209
12930
|
}
|
|
12210
|
-
spawnDetachedSelf(["docs", "sync", "--quiet"], { spawn:
|
|
12931
|
+
spawnDetachedSelf(["docs", "sync", "--quiet"], { spawn: import_node_child_process7.spawn, execPath: process.execPath, scriptPath: process.argv[1] });
|
|
12211
12932
|
const { parallel, sequential } = buildSessionStartPlan({
|
|
12212
12933
|
rulesSync: (io) => runRulesSync({ quiet: true }, io),
|
|
12213
12934
|
sagaShow: (io) => runSagaShow({ quiet: true }, io),
|