@mutmutco/cli 2.13.0 → 2.14.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 +165 -13
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -5945,7 +5945,10 @@ async function startStage(config = {}, opts = {}) {
|
|
|
5945
5945
|
const child = (0, import_node_child_process5.spawn)(up, {
|
|
5946
5946
|
cwd,
|
|
5947
5947
|
shell: true,
|
|
5948
|
-
|
|
5948
|
+
// POSIX-only: the process group exists for the group-kill in stopStage. On win32 teardown is
|
|
5949
|
+
// `taskkill /T /F` (no group needed), and detached+shell defeats windowsHide — every spawn would
|
|
5950
|
+
// flash a Windows Terminal window (0x800700e8) on dev machines.
|
|
5951
|
+
detached: process.platform !== "win32",
|
|
5949
5952
|
windowsHide: true,
|
|
5950
5953
|
stdio: "ignore",
|
|
5951
5954
|
env: { ...process.env, ...stagePort != null ? { STAGE_PORT: String(stagePort) } : {}, ...extraEnv }
|
|
@@ -6040,6 +6043,41 @@ async function verifyHubDistributionVersion(deps, model, releaseTag) {
|
|
|
6040
6043
|
if (model !== "hub-serverless") return;
|
|
6041
6044
|
await deps.run("node", ["scripts/release-distribution.mjs", "verify", releaseTag, "--skip-npm-view"]);
|
|
6042
6045
|
}
|
|
6046
|
+
var ORG_SPINE_FILES = ["AGENTS.md", "CLAUDE.md", "GEMINI.md", ".claude/settings.json"];
|
|
6047
|
+
function isSpinePath(path2) {
|
|
6048
|
+
return ORG_SPINE_FILES.includes(path2);
|
|
6049
|
+
}
|
|
6050
|
+
async function predictMergeConflicts(deps, ours, theirs) {
|
|
6051
|
+
try {
|
|
6052
|
+
await deps.run("git", ["merge-tree", "--write-tree", "--name-only", "--no-messages", ours, theirs]);
|
|
6053
|
+
return [];
|
|
6054
|
+
} catch (e) {
|
|
6055
|
+
const out = String(e.stdout ?? "");
|
|
6056
|
+
const files = out.split("\n").map((s) => s.trim()).filter(Boolean).slice(1);
|
|
6057
|
+
if (files.length === 0) {
|
|
6058
|
+
throw new Error(`could not preflight the ${theirs} -> ${ours} merge (git merge-tree failed: ${e.message ?? e})`);
|
|
6059
|
+
}
|
|
6060
|
+
return files;
|
|
6061
|
+
}
|
|
6062
|
+
}
|
|
6063
|
+
async function mergeRcWithSpineResolution(deps) {
|
|
6064
|
+
try {
|
|
6065
|
+
await deps.run("git", ["merge", "rc", "--no-edit"]);
|
|
6066
|
+
return;
|
|
6067
|
+
} catch {
|
|
6068
|
+
}
|
|
6069
|
+
const unmerged = (await deps.run("git", ["diff", "--name-only", "--diff-filter=U"])).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
6070
|
+
const nonSpine = unmerged.filter((f) => !isSpinePath(f));
|
|
6071
|
+
if (unmerged.length === 0 || nonSpine.length > 0) {
|
|
6072
|
+
await deps.run("git", ["merge", "--abort"]);
|
|
6073
|
+
throw new Error(
|
|
6074
|
+
unmerged.length === 0 ? "rc -> main merge failed without conflicted paths \u2014 merge aborted; inspect the repo state and rerun" : `rc -> main merge conflicts on non-spine path(s): ${nonSpine.join(", ")} \u2014 merge aborted (the train is misaligned; reconcile main and rc via an approved alignment PR, then rerun release)`
|
|
6075
|
+
);
|
|
6076
|
+
}
|
|
6077
|
+
await deps.run("git", ["checkout", "--theirs", "--", ...unmerged]);
|
|
6078
|
+
await deps.run("git", ["add", "--", ...unmerged]);
|
|
6079
|
+
await deps.run("git", ["commit", "--no-edit"]);
|
|
6080
|
+
}
|
|
6043
6081
|
function ensurePositiveCount(out, emptyMessage) {
|
|
6044
6082
|
const count = Number.parseInt(out.trim(), 10);
|
|
6045
6083
|
if (!Number.isFinite(count)) throw new Error(`could not parse ahead count: ${out.trim() || "(empty)"}`);
|
|
@@ -6191,9 +6229,21 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6191
6229
|
"nothing to release: origin/rc is not ahead of origin/main"
|
|
6192
6230
|
);
|
|
6193
6231
|
const deployModel = await preflight(deps, ctx, "main");
|
|
6232
|
+
const predicted = await predictMergeConflicts(deps, "origin/main", "origin/rc");
|
|
6233
|
+
const predictedNonSpine = predicted.filter((f) => !isSpinePath(f));
|
|
6234
|
+
if (predictedNonSpine.length > 0) {
|
|
6235
|
+
throw new Error(
|
|
6236
|
+
`rc -> main merge would conflict on non-spine path(s): ${predictedNonSpine.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.`
|
|
6237
|
+
);
|
|
6238
|
+
}
|
|
6239
|
+
const releasedRcSha = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
|
|
6194
6240
|
await deps.run("git", ["checkout", "main"]);
|
|
6195
6241
|
await deps.run("git", ["pull", "--ff-only", "origin", "main"]);
|
|
6196
|
-
|
|
6242
|
+
if (predicted.length === 0) {
|
|
6243
|
+
await deps.run("git", ["merge", "rc", "--no-edit"]);
|
|
6244
|
+
} else {
|
|
6245
|
+
await mergeRcWithSpineResolution(deps);
|
|
6246
|
+
}
|
|
6197
6247
|
const tag = requireValue(clean(await deps.run("node", ["scripts/next-version.mjs", "release"])), "release tag");
|
|
6198
6248
|
await verifyHubDistributionVersion(deps, deployModel, tag);
|
|
6199
6249
|
await deps.run("git", ["tag", tag]);
|
|
@@ -6201,11 +6251,62 @@ async function runTrainApply(command, deps, options = {}) {
|
|
|
6201
6251
|
await deps.run("git", ["push", "origin", tag]);
|
|
6202
6252
|
await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
|
|
6203
6253
|
const d = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch);
|
|
6254
|
+
const retirement = await retireRcRuntime(deps, ctx, deployModel, d.deployStatus, releasedRcSha);
|
|
6204
6255
|
await deps.run("git", ["checkout", "development"]);
|
|
6205
6256
|
await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
|
|
6206
6257
|
await deps.run("git", ["merge", "main", "--no-edit"]);
|
|
6207
6258
|
await deps.run("git", ["push", "origin", "development"]);
|
|
6208
|
-
return {
|
|
6259
|
+
return {
|
|
6260
|
+
...ctx,
|
|
6261
|
+
command,
|
|
6262
|
+
stage: "main",
|
|
6263
|
+
ref: "main",
|
|
6264
|
+
tag,
|
|
6265
|
+
deployModel,
|
|
6266
|
+
promoted: true,
|
|
6267
|
+
dispatch: d.note,
|
|
6268
|
+
runId: d.runId,
|
|
6269
|
+
runUrl: d.runUrl,
|
|
6270
|
+
deployStatus: d.deployStatus,
|
|
6271
|
+
rcRetirement: retirement.status,
|
|
6272
|
+
rcRetirementNote: retirement.note
|
|
6273
|
+
};
|
|
6274
|
+
}
|
|
6275
|
+
async function retireRcRuntime(deps, ctx, model, deployStatus, releasedRcSha) {
|
|
6276
|
+
if (model !== "tenant-container") {
|
|
6277
|
+
return { status: "not-applicable", note: `${model} has no co-resident rc runtime to retire` };
|
|
6278
|
+
}
|
|
6279
|
+
if (deployStatus === "failure") {
|
|
6280
|
+
return { status: "skipped", note: "prod deploy failed \u2014 rc runtime left untouched" };
|
|
6281
|
+
}
|
|
6282
|
+
if (deployStatus !== "success") {
|
|
6283
|
+
return {
|
|
6284
|
+
status: "skipped",
|
|
6285
|
+
note: `prod deploy outcome unconfirmed (run without --watch) \u2014 rc runtime left untouched; after verifying prod, retire with: mmi-cli tenant control ${ctx.repo} rc retire`
|
|
6286
|
+
};
|
|
6287
|
+
}
|
|
6288
|
+
try {
|
|
6289
|
+
await deps.run("git", ["fetch", "origin", "rc"]);
|
|
6290
|
+
const rcNow = clean(await deps.run("git", ["rev-parse", "origin/rc"]));
|
|
6291
|
+
if (rcNow !== releasedRcSha) {
|
|
6292
|
+
return {
|
|
6293
|
+
status: "skipped",
|
|
6294
|
+
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`
|
|
6295
|
+
};
|
|
6296
|
+
}
|
|
6297
|
+
const out = await deps.runSelf(["tenant", "control", ctx.repo, "rc", "retire"]);
|
|
6298
|
+
let commandId = "";
|
|
6299
|
+
try {
|
|
6300
|
+
commandId = String(JSON.parse(out).commandId ?? "");
|
|
6301
|
+
} catch {
|
|
6302
|
+
}
|
|
6303
|
+
return {
|
|
6304
|
+
status: "retired",
|
|
6305
|
+
note: `rc runtime retired (tenant control retire${commandId ? `, command ${commandId}` : ""}) \u2014 registry coords kept; /rcand or tenant redeploy recreates rc next cycle`
|
|
6306
|
+
};
|
|
6307
|
+
} catch (e) {
|
|
6308
|
+
return { status: "failed", note: `rc retirement failed (the release itself succeeded): ${e.message}` };
|
|
6309
|
+
}
|
|
6209
6310
|
}
|
|
6210
6311
|
async function runTenantRedeploy(deps, options) {
|
|
6211
6312
|
const { stage: stage2 } = options;
|
|
@@ -8117,14 +8218,16 @@ async function secretsRequest(deps, key, opts) {
|
|
|
8117
8218
|
deps.log(body.notified === false ? "admin notification not sent" : "admin notification sent");
|
|
8118
8219
|
return true;
|
|
8119
8220
|
}
|
|
8120
|
-
async function
|
|
8121
|
-
if (!isValidSecretKey(key))
|
|
8122
|
-
|
|
8123
|
-
|
|
8221
|
+
async function putSecret(deps, key, value, opts) {
|
|
8222
|
+
if (!isValidSecretKey(key)) {
|
|
8223
|
+
deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
8224
|
+
return false;
|
|
8225
|
+
}
|
|
8124
8226
|
if (!value) {
|
|
8125
|
-
deps.err(
|
|
8126
|
-
return;
|
|
8227
|
+
deps.err(`secrets set: empty value for ${key} \u2014 aborted (nothing written)`);
|
|
8228
|
+
return false;
|
|
8127
8229
|
}
|
|
8230
|
+
const repo = await targetRepo(deps, opts);
|
|
8128
8231
|
const res = await deps.fetch(`${deps.apiUrl}/secrets/set`, {
|
|
8129
8232
|
method: "POST",
|
|
8130
8233
|
headers: await deps.headers({ "content-type": "application/json" }),
|
|
@@ -8135,9 +8238,19 @@ async function secretsSet(deps, key, opts) {
|
|
|
8135
8238
|
deps.err(
|
|
8136
8239
|
res.status === 403 ? `secrets set: not authorized to write ${key} (HTTP 403)${await readErr(res)}` : `secrets set failed: HTTP ${res.status}${await readErr(res)}`
|
|
8137
8240
|
);
|
|
8138
|
-
return;
|
|
8241
|
+
return false;
|
|
8139
8242
|
}
|
|
8140
8243
|
deps.log(`set ${key} (${classifyTier(await vaultSlug(deps, opts), key)} tier)`);
|
|
8244
|
+
return true;
|
|
8245
|
+
}
|
|
8246
|
+
async function secretsSet(deps, key, opts) {
|
|
8247
|
+
if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
|
|
8248
|
+
const value = await deps.readSecretValue(`value for ${key} (input hidden; will not be echoed): `);
|
|
8249
|
+
if (!value) {
|
|
8250
|
+
deps.err("secrets set: empty value \u2014 aborted (nothing written)");
|
|
8251
|
+
return;
|
|
8252
|
+
}
|
|
8253
|
+
await putSecret(deps, key, value, opts);
|
|
8141
8254
|
}
|
|
8142
8255
|
async function secretsEdit(deps, key, opts) {
|
|
8143
8256
|
return secretsSet(deps, key, opts);
|
|
@@ -8241,6 +8354,22 @@ function expectedRedirectUris(cfg) {
|
|
|
8241
8354
|
function oauthSsmKeys() {
|
|
8242
8355
|
return SSM_ENVS.flatMap((env) => SSM_NAMES.map((name) => `${env}/${name}`));
|
|
8243
8356
|
}
|
|
8357
|
+
function parseOauthClientJson(input) {
|
|
8358
|
+
let parsed;
|
|
8359
|
+
try {
|
|
8360
|
+
parsed = JSON.parse(input);
|
|
8361
|
+
} catch {
|
|
8362
|
+
throw new Error('not valid JSON \u2014 pipe the Google client JSON (the Console "Download JSON" file)');
|
|
8363
|
+
}
|
|
8364
|
+
const root = parsed ?? {};
|
|
8365
|
+
const obj = root.web ?? root.installed ?? parsed;
|
|
8366
|
+
const clientId = typeof obj?.client_id === "string" ? obj.client_id.trim() : "";
|
|
8367
|
+
const clientSecret = typeof obj?.client_secret === "string" ? obj.client_secret.trim() : "";
|
|
8368
|
+
if (!clientId || !clientSecret) {
|
|
8369
|
+
throw new Error("missing client_id or client_secret in the JSON");
|
|
8370
|
+
}
|
|
8371
|
+
return { clientId, clientSecret };
|
|
8372
|
+
}
|
|
8244
8373
|
function parseOauthConfig(mmiConfig, slug) {
|
|
8245
8374
|
const rawUnknown = mmiConfig?.oauth;
|
|
8246
8375
|
if (rawUnknown === void 0) throw new Error(`oauth is not configured for ${slug}`);
|
|
@@ -9112,7 +9241,7 @@ function reportWrite(label, res) {
|
|
|
9112
9241
|
fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
|
|
9113
9242
|
}
|
|
9114
9243
|
var tenant = program2.command("tenant").description("tenant runtime control through Hub authority");
|
|
9115
|
-
tenant.command("control <owner/repo> <stage> <action>").description("run bounded service control (status/start/stop/restart) for a tenant; project-admin dev/rc, master main").option("--json", "machine-readable output").action(async (repo, stage2, action) => {
|
|
9244
|
+
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) => {
|
|
9116
9245
|
const cfg = await loadConfig();
|
|
9117
9246
|
const res = await tenantControl({ repo, stage: stage2, action }, registryClientDeps(cfg));
|
|
9118
9247
|
reportWrite("tenant control", res);
|
|
@@ -9344,7 +9473,29 @@ oauth.command("plan", { isDefault: true }).description("print the canonical JS o
|
|
|
9344
9473
|
console.log(`
|
|
9345
9474
|
SSM cred params (under /mmi-future/${slug}/):`);
|
|
9346
9475
|
ssm.forEach((k) => console.log(` ${k}`));
|
|
9347
|
-
console.log("\nProvision/repair the Console client per docs/Guides/oauth-provision.md; creds
|
|
9476
|
+
console.log("\nProvision/repair the Console client per docs/Guides/oauth-provision.md; store creds with `mmi-cli oauth set-creds`.");
|
|
9477
|
+
});
|
|
9478
|
+
oauth.command("set-creds").description('store the OAuth client into the canonical {dev,rc,main}/GOOGLE_CLIENT_* SSM keys (pipe the Console "Download JSON" on stdin)').option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (o) => {
|
|
9479
|
+
const raw = await readStdin();
|
|
9480
|
+
if (!raw.trim()) {
|
|
9481
|
+
return fail("oauth set-creds: pipe the Google client JSON on stdin \u2014 e.g.\n mmi-cli oauth set-creds --repo <owner/repo> < client.json");
|
|
9482
|
+
}
|
|
9483
|
+
let creds;
|
|
9484
|
+
try {
|
|
9485
|
+
creds = parseOauthClientJson(raw);
|
|
9486
|
+
} catch (e) {
|
|
9487
|
+
return fail(`oauth set-creds: ${e.message}`);
|
|
9488
|
+
}
|
|
9489
|
+
await withSecrets(async (d) => {
|
|
9490
|
+
for (const key of oauthSsmKeys()) {
|
|
9491
|
+
const value = key.endsWith("GOOGLE_CLIENT_ID") ? creds.clientId : creds.clientSecret;
|
|
9492
|
+
if (!await putSecret(d, key, value, { repo: o.repo })) {
|
|
9493
|
+
process.exitCode = 1;
|
|
9494
|
+
return;
|
|
9495
|
+
}
|
|
9496
|
+
}
|
|
9497
|
+
console.log(`OAuth client stored in all ${oauthSsmKeys().length} canonical keys. Run \`mmi-cli oauth verify\` to confirm the client is port-agnostic.`);
|
|
9498
|
+
});
|
|
9348
9499
|
});
|
|
9349
9500
|
oauth.command("verify").description("probe Google authorize with an arbitrary port (:9123) to confirm the client is port-agnostic").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--client-id <id>", "OAuth client_id (else read dev/GOOGLE_CLIENT_ID from SSM)").option("--json", "machine-readable output").action(async (o) => {
|
|
9350
9501
|
const cfg = await loadConfig();
|
|
@@ -9895,7 +10046,8 @@ function renderDeployLine(d) {
|
|
|
9895
10046
|
return parts.join("; ");
|
|
9896
10047
|
}
|
|
9897
10048
|
function renderTrainApply(commandName, r) {
|
|
9898
|
-
|
|
10049
|
+
const base = `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
|
|
10050
|
+
return r.rcRetirement ? `${base}; rc retirement: ${r.rcRetirement.toUpperCase()} (${r.rcRetirementNote ?? ""})` : base;
|
|
9899
10051
|
}
|
|
9900
10052
|
function renderTenantRedeploy(r) {
|
|
9901
10053
|
return `mmi-cli tenant redeploy: ${r.repo} ${r.stage} (ref=${r.ref}) [${r.deployModel}]; ${renderDeployLine(r)}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mutmutco/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.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",
|