@mutmutco/cli 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.cjs +191 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ mmi-cli doctor --json
|
|
|
43
43
|
- `mmi-cli pr create` and `pr merge` create PRs and land them with branch/worktree cleanup; `mmi-cli gc` dry-runs cleanup of merged/closed PR branches + stale tracking refs.
|
|
44
44
|
- `mmi-cli board read|claim|show|move|done|backfill-priority` reads and moves GitHub Project work.
|
|
45
45
|
- `mmi-cli stage`, `stage start`, `stage stop`, `stage run`, and `port-range <repo>` manage the local gitignored stage and its port block; `stage-live` explains that remote rc/live move only via `/rcand` · `/release` · `/hotfix`.
|
|
46
|
-
- `mmi-cli
|
|
46
|
+
- `mmi-cli rcand`, `release`, and `hotfix` render guarded train plans; the train triggers the Hub's central tenant deployer, so product repos carry no deploy file.
|
|
47
47
|
- `mmi-cli bootstrap`, `bootstrap verify`, and `bootstrap apply` plan, audit, and seed repo onboarding.
|
|
48
48
|
- `mmi-cli access audit` checks collaborator roles and train-branch allowlists.
|
|
49
49
|
- `mmi-cli doctor` checks GitHub auth, repo config, CLI availability, the per-project plugin install record, and stale plugin/cache state, auto-repairing the safe gaps.
|
package/dist/index.cjs
CHANGED
|
@@ -4288,23 +4288,29 @@ function stagePlan(stage2 = {}) {
|
|
|
4288
4288
|
function stageLivePlan() {
|
|
4289
4289
|
return [
|
|
4290
4290
|
{ label: "stage-live is not an org command; /stage is local only", command: "mmi-cli stage run --apply" },
|
|
4291
|
-
{ label: "remote rc/live environments move through the gated promotion train", command: "mmi-cli
|
|
4291
|
+
{ label: "remote rc/live environments move through the gated promotion train", command: "mmi-cli rcand && mmi-cli release && mmi-cli hotfix", gated: true }
|
|
4292
4292
|
];
|
|
4293
4293
|
}
|
|
4294
4294
|
function trainPlan(command) {
|
|
4295
|
-
if (command === "
|
|
4295
|
+
if (command === "rcand") {
|
|
4296
4296
|
return [
|
|
4297
|
-
{ label: "verify
|
|
4297
|
+
{ label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
|
|
4298
|
+
{ label: "verify current branch is development", gated: true },
|
|
4299
|
+
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
4300
|
+
{ label: "preflight required rc secret names", command: "mmi-cli secrets preflight --stage rc --repo <owner/repo>", gated: true },
|
|
4298
4301
|
{ label: "merge development to rc", gated: true },
|
|
4299
|
-
{ label: "deploy rc", gated: true }
|
|
4302
|
+
{ label: "dispatch central tenant deploy for rc", command: "gh workflow run tenant-deploy.yml --repo mutmutco/MMI-Hub -f slug=<slug> -f repo=<owner/repo> -f ref=rc -f stage=rc", gated: true }
|
|
4300
4303
|
];
|
|
4301
4304
|
}
|
|
4302
4305
|
if (command === "release") {
|
|
4303
4306
|
return [
|
|
4304
|
-
{ label: "verify
|
|
4307
|
+
{ label: "verify operator is a master-admin org owner", command: "gh api orgs/<owner>/memberships/<login> --jq .role", gated: true },
|
|
4308
|
+
{ label: "verify current branch is rc", gated: true },
|
|
4309
|
+
{ label: "verify registry META for this project", command: "mmi-cli project get <owner/repo>", gated: true },
|
|
4310
|
+
{ label: "preflight required main secret names", command: "mmi-cli secrets preflight --stage main --repo <owner/repo>", gated: true },
|
|
4305
4311
|
{ label: "merge rc to main", gated: true },
|
|
4306
4312
|
{ label: "tag release and publish GitHub Release", gated: true },
|
|
4307
|
-
{ label: "deploy
|
|
4313
|
+
{ label: "dispatch central tenant deploy for main", command: "gh workflow run tenant-deploy.yml --repo mutmutco/MMI-Hub -f slug=<slug> -f repo=<owner/repo> -f ref=main -f stage=main", gated: true },
|
|
4308
4314
|
{ label: "roll development forward", gated: true }
|
|
4309
4315
|
];
|
|
4310
4316
|
}
|
|
@@ -4628,6 +4634,111 @@ async function runStage(config = {}, opts = {}) {
|
|
|
4628
4634
|
return { ...started, action: "run", message: `built and ${started.message}` };
|
|
4629
4635
|
}
|
|
4630
4636
|
|
|
4637
|
+
// src/train-apply.ts
|
|
4638
|
+
function clean(out) {
|
|
4639
|
+
return out.trim();
|
|
4640
|
+
}
|
|
4641
|
+
function requireValue(value, label) {
|
|
4642
|
+
if (!value) throw new Error(`${label} could not be resolved`);
|
|
4643
|
+
return value;
|
|
4644
|
+
}
|
|
4645
|
+
function ensurePositiveCount(out, emptyMessage) {
|
|
4646
|
+
const count = Number.parseInt(out.trim(), 10);
|
|
4647
|
+
if (!Number.isFinite(count)) throw new Error(`could not parse ahead count: ${out.trim() || "(empty)"}`);
|
|
4648
|
+
if (count <= 0) throw new Error(emptyMessage);
|
|
4649
|
+
}
|
|
4650
|
+
async function buildTrainApplyContext(deps) {
|
|
4651
|
+
const repo = requireValue(clean(await deps.run("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"])), "repo");
|
|
4652
|
+
const [owner, name] = repo.split("/");
|
|
4653
|
+
if (!owner || !name) throw new Error(`repo must be owner/name, got ${repo}`);
|
|
4654
|
+
const login = requireValue(clean(await deps.run("gh", ["api", "user", "--jq", ".login"])), "GitHub login");
|
|
4655
|
+
const role = clean(await deps.run("gh", ["api", `orgs/${owner}/memberships/${login}`, "--jq", ".role"]));
|
|
4656
|
+
if (role !== "admin") {
|
|
4657
|
+
throw new Error(`${commandAuthorityLabel(owner)} is master-admin only; @${login} is ${role || "not an org admin"}`);
|
|
4658
|
+
}
|
|
4659
|
+
return {
|
|
4660
|
+
repo,
|
|
4661
|
+
owner,
|
|
4662
|
+
slug: name.toLowerCase(),
|
|
4663
|
+
login
|
|
4664
|
+
};
|
|
4665
|
+
}
|
|
4666
|
+
function commandAuthorityLabel(owner) {
|
|
4667
|
+
return `${owner} release train`;
|
|
4668
|
+
}
|
|
4669
|
+
async function requireCleanTree(deps) {
|
|
4670
|
+
const status = await deps.run("git", ["status", "--porcelain"]);
|
|
4671
|
+
if (status.trim()) throw new Error("working tree must be clean before train apply");
|
|
4672
|
+
}
|
|
4673
|
+
async function requireBranch(deps, branch) {
|
|
4674
|
+
const current = clean(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
|
|
4675
|
+
if (current !== branch) throw new Error(`must run from ${branch}, currently on ${current || "(unknown)"}`);
|
|
4676
|
+
}
|
|
4677
|
+
async function dispatchDeploy(deps, ctx, stage2, ref) {
|
|
4678
|
+
await deps.run("gh", [
|
|
4679
|
+
"workflow",
|
|
4680
|
+
"run",
|
|
4681
|
+
"tenant-deploy.yml",
|
|
4682
|
+
"--repo",
|
|
4683
|
+
"mutmutco/MMI-Hub",
|
|
4684
|
+
"-f",
|
|
4685
|
+
`slug=${ctx.slug}`,
|
|
4686
|
+
"-f",
|
|
4687
|
+
`repo=${ctx.repo}`,
|
|
4688
|
+
"-f",
|
|
4689
|
+
`ref=${ref}`,
|
|
4690
|
+
"-f",
|
|
4691
|
+
`stage=${stage2}`
|
|
4692
|
+
]);
|
|
4693
|
+
}
|
|
4694
|
+
async function preflight(deps, ctx, stage2) {
|
|
4695
|
+
await deps.runSelf(["project", "get", ctx.repo]);
|
|
4696
|
+
await deps.runSelf(["secrets", "preflight", "--stage", stage2, "--repo", ctx.repo]);
|
|
4697
|
+
}
|
|
4698
|
+
async function runTrainApply(command, deps) {
|
|
4699
|
+
const ctx = await buildTrainApplyContext(deps);
|
|
4700
|
+
await requireCleanTree(deps);
|
|
4701
|
+
await deps.run("git", ["fetch", "origin"]);
|
|
4702
|
+
if (command === "rcand") {
|
|
4703
|
+
await requireBranch(deps, "development");
|
|
4704
|
+
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
4705
|
+
ensurePositiveCount(
|
|
4706
|
+
await deps.run("git", ["rev-list", "--count", "origin/rc..origin/development"]),
|
|
4707
|
+
"nothing to promote: origin/development is not ahead of origin/rc"
|
|
4708
|
+
);
|
|
4709
|
+
await preflight(deps, ctx, "rc");
|
|
4710
|
+
await deps.run("git", ["checkout", "rc"]);
|
|
4711
|
+
await deps.run("git", ["pull", "--ff-only", "origin", "rc"]);
|
|
4712
|
+
await deps.run("git", ["merge", "development", "--no-edit"]);
|
|
4713
|
+
const tag2 = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "rc"])), "rc tag");
|
|
4714
|
+
await deps.run("git", ["tag", tag2]);
|
|
4715
|
+
await deps.run("git", ["push", "origin", "rc"]);
|
|
4716
|
+
await deps.run("git", ["push", "origin", tag2]);
|
|
4717
|
+
await dispatchDeploy(deps, ctx, "rc", "rc");
|
|
4718
|
+
return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2 };
|
|
4719
|
+
}
|
|
4720
|
+
await requireBranch(deps, "rc");
|
|
4721
|
+
ensurePositiveCount(
|
|
4722
|
+
await deps.run("git", ["rev-list", "--count", "origin/main..origin/rc"]),
|
|
4723
|
+
"nothing to release: origin/rc is not ahead of origin/main"
|
|
4724
|
+
);
|
|
4725
|
+
await preflight(deps, ctx, "main");
|
|
4726
|
+
await deps.run("git", ["checkout", "main"]);
|
|
4727
|
+
await deps.run("git", ["pull", "--ff-only", "origin", "main"]);
|
|
4728
|
+
await deps.run("git", ["merge", "rc", "--no-edit"]);
|
|
4729
|
+
const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
|
|
4730
|
+
await deps.run("git", ["tag", tag]);
|
|
4731
|
+
await deps.run("git", ["push", "origin", "main"]);
|
|
4732
|
+
await deps.run("git", ["push", "origin", tag]);
|
|
4733
|
+
await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
|
|
4734
|
+
await dispatchDeploy(deps, ctx, "main", "main");
|
|
4735
|
+
await deps.run("git", ["checkout", "development"]);
|
|
4736
|
+
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
4737
|
+
await deps.run("git", ["merge", "main", "--no-edit"]);
|
|
4738
|
+
await deps.run("git", ["push", "origin", "development"]);
|
|
4739
|
+
return { ...ctx, command, stage: "main", ref: "main", tag };
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4631
4742
|
// src/port-registry.ts
|
|
4632
4743
|
var import_node_fs3 = require("node:fs");
|
|
4633
4744
|
var BLOCK = 100;
|
|
@@ -5373,8 +5484,8 @@ function resolveKbSource(rawBase) {
|
|
|
5373
5484
|
return { owner: m[1], repo: m[2], ref: m[3] };
|
|
5374
5485
|
}
|
|
5375
5486
|
function buildKbGetArgs(src, path) {
|
|
5376
|
-
const
|
|
5377
|
-
return ["api", `repos/${src.owner}/${src.repo}/contents/${
|
|
5487
|
+
const clean2 = path.replace(/^\/+/, "");
|
|
5488
|
+
return ["api", `repos/${src.owner}/${src.repo}/contents/${clean2}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
|
|
5378
5489
|
}
|
|
5379
5490
|
function buildKbTreeArgs(src) {
|
|
5380
5491
|
return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
|
|
@@ -5686,6 +5797,46 @@ async function secretsList(deps, opts) {
|
|
|
5686
5797
|
const { secrets: secrets2 } = await res.json();
|
|
5687
5798
|
deps.log(formatSecretList(secrets2 ?? []));
|
|
5688
5799
|
}
|
|
5800
|
+
var DEFAULT_RUNTIME_SECRET_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
|
|
5801
|
+
function stringList(v) {
|
|
5802
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string" && isValidSecretKey(x)) : [];
|
|
5803
|
+
}
|
|
5804
|
+
function requiredRuntimeSecretNames(stage2, contract) {
|
|
5805
|
+
const extra = Array.isArray(contract) ? stringList(contract) : stringList(contract?.[stage2]);
|
|
5806
|
+
return [.../* @__PURE__ */ new Set([...DEFAULT_RUNTIME_SECRET_NAMES, ...extra])];
|
|
5807
|
+
}
|
|
5808
|
+
function stageKey(stage2, key) {
|
|
5809
|
+
return key.includes("/") ? key : `${stage2}/${key}`;
|
|
5810
|
+
}
|
|
5811
|
+
async function secretsPreflight(deps, opts) {
|
|
5812
|
+
const repo = await targetRepo(deps, opts);
|
|
5813
|
+
const qs = new URLSearchParams({ repo }).toString();
|
|
5814
|
+
let res;
|
|
5815
|
+
try {
|
|
5816
|
+
res = await deps.fetch(`${deps.apiUrl}/secrets/list?${qs}`, {
|
|
5817
|
+
method: "GET",
|
|
5818
|
+
headers: await deps.headers(),
|
|
5819
|
+
signal: AbortSignal.timeout(TIMEOUT_MS2)
|
|
5820
|
+
});
|
|
5821
|
+
} catch (e) {
|
|
5822
|
+
deps.err(`secrets preflight: ${e.message}`);
|
|
5823
|
+
return false;
|
|
5824
|
+
}
|
|
5825
|
+
if (!res.ok) {
|
|
5826
|
+
deps.err(`secrets preflight failed: HTTP ${res.status}${await readErr(res)}`);
|
|
5827
|
+
return false;
|
|
5828
|
+
}
|
|
5829
|
+
const { secrets: secrets2 } = await res.json();
|
|
5830
|
+
const present = new Set((secrets2 ?? []).map((s) => s.key));
|
|
5831
|
+
const required = opts.required.map((key) => stageKey(opts.stage, key));
|
|
5832
|
+
const missing = required.filter((key) => !present.has(key));
|
|
5833
|
+
if (missing.length) {
|
|
5834
|
+
deps.log(`missing ${missing.join(", ")}`);
|
|
5835
|
+
return false;
|
|
5836
|
+
}
|
|
5837
|
+
deps.log(`all required ${opts.stage} secret names are present`);
|
|
5838
|
+
return true;
|
|
5839
|
+
}
|
|
5689
5840
|
async function secretsGet(deps, key, opts) {
|
|
5690
5841
|
if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
5691
5842
|
const repo = await targetRepo(deps, opts);
|
|
@@ -6516,6 +6667,22 @@ async function withSecrets(run) {
|
|
|
6516
6667
|
var secrets = program2.command("secrets").description("two-tier project secrets \u2014 self-serve your repo dev/* tier; org tier is master-gated");
|
|
6517
6668
|
secrets.command("where").description("print where this repo\u2019s secrets live \u2014 the two-tier vault layout + well-known keys (no values)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((o) => withSecrets((d) => secretsWhere(d, o)));
|
|
6518
6669
|
secrets.command("list").description("list secret NAMES + tier for this repo (never values)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((o) => withSecrets((d) => secretsList(d, o)));
|
|
6670
|
+
secrets.command("preflight").description("check required stage secret names for a deploy/train without reading values").requiredOption("--stage <dev|rc|main>", "stage to check").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--required <KEY...>", "required keys; bare keys are scoped under --stage").action(async (o) => {
|
|
6671
|
+
if (!["dev", "rc", "main"].includes(o.stage)) {
|
|
6672
|
+
return fail("secrets preflight: --stage must be dev, rc, or main");
|
|
6673
|
+
}
|
|
6674
|
+
const cfg = await loadConfig();
|
|
6675
|
+
if (!cfg.sagaApiUrl) {
|
|
6676
|
+
fail("secrets: sagaApiUrl not configured in .mmi/config.json (this repo is not bootstrapped)");
|
|
6677
|
+
return;
|
|
6678
|
+
}
|
|
6679
|
+
const d = makeSecretsDeps(cfg);
|
|
6680
|
+
const repo = o.repo ?? `mutmutco/${await d.slug()}`;
|
|
6681
|
+
const meta = await fetchProjectBySlug(slugOf(repo), registryClientDeps(cfg));
|
|
6682
|
+
const required = o.required?.length ? o.required : requiredRuntimeSecretNames(o.stage, meta?.requiredRuntimeSecrets);
|
|
6683
|
+
const ok = await secretsPreflight(d, { repo: o.repo, stage: o.stage, required });
|
|
6684
|
+
if (!ok) process.exitCode = 1;
|
|
6685
|
+
});
|
|
6519
6686
|
secrets.command("get <key>").description("print one secret value over TLS (prints once, raw \u2014 do not log/paste it)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsGet(d, key, o)));
|
|
6520
6687
|
secrets.command("set <key>").description("write/rotate a secret; value is read from stdin (never an argument)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsSet(d, key, o)));
|
|
6521
6688
|
secrets.command("edit <key>").description("alias for set \u2014 replace a secret value (read from stdin)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsEdit(d, key, o)));
|
|
@@ -6918,13 +7085,25 @@ stage.command("run").description("force-stop previous stage, build, start, and h
|
|
|
6918
7085
|
}
|
|
6919
7086
|
});
|
|
6920
7087
|
program2.command("stage-live").description("explain that remote rc/live environments use /rcand, /release, and /hotfix; /stage is local only").option("--json", "machine-readable output").option("--apply", "always refused; there is no stage-live mutation path").action((o) => {
|
|
6921
|
-
if (o.apply) return fail("stage-live: not an org command; use mmi-cli stage for local tests, or the gated
|
|
7088
|
+
if (o.apply) return fail("stage-live: not an org command; use mmi-cli stage for local tests, or the gated rcand/release/hotfix train for remote environments");
|
|
6922
7089
|
const steps = stageLivePlan();
|
|
6923
7090
|
console.log(o.json ? JSON.stringify({ command: "stage-live", steps }, null, 2) : renderSteps("mmi-cli stage-live: not an org command", steps));
|
|
6924
7091
|
});
|
|
6925
|
-
for (const commandName of ["
|
|
6926
|
-
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit approval`).option("--json", "machine-readable output").option("--apply", "reserved
|
|
6927
|
-
if (o.apply)
|
|
7092
|
+
for (const commandName of ["rcand", "release", "hotfix"]) {
|
|
7093
|
+
program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--apply", commandName === "hotfix" ? "reserved; hotfix uses the /hotfix skill PR path" : "execute the guarded master-only train after explicit approval").action(async (o) => {
|
|
7094
|
+
if (o.apply) {
|
|
7095
|
+
if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
|
|
7096
|
+
try {
|
|
7097
|
+
const result = await runTrainApply(commandName, {
|
|
7098
|
+
run: async (file, args) => (await execFileP3(file, args, { timeout: file === "gh" ? 3e4 : GIT_TIMEOUT_MS })).stdout,
|
|
7099
|
+
runSelf: async (args) => (await execFileP3(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout
|
|
7100
|
+
});
|
|
7101
|
+
const message = `mmi-cli ${commandName}: applied ${result.repo} ${result.stage} train at ${result.tag}; dispatched ${result.ref} deploy`;
|
|
7102
|
+
return printLine(o.json ? JSON.stringify(result, null, 2) : message);
|
|
7103
|
+
} catch (e) {
|
|
7104
|
+
return fail(`${commandName}: ${e.message}`);
|
|
7105
|
+
}
|
|
7106
|
+
}
|
|
6928
7107
|
const steps = trainPlan(commandName);
|
|
6929
7108
|
console.log(o.json ? JSON.stringify({ command: commandName, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps));
|
|
6930
7109
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mutmutco/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "MMI Future CLI — delivers the org rules (whole-file), plus saga and KB access. The cross-IDE engine the plugin's SessionStart hook drives.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|