@mushi-mushi/cli 0.31.0 → 0.33.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/LICENSE +1 -1
- package/README.md +1 -1
- package/dist/{chunk-RQO3VNO7.js → chunk-KPIX6ACN.js} +1 -1
- package/dist/{chunk-JFMM7FGM.js → chunk-VAFSOAIO.js} +2 -1
- package/dist/chunk-W3DJZBHX.js +8 -0
- package/dist/chunk-WO52D547.js +10 -0
- package/dist/{connect-GDCWQXKH.js → connect-34OTSTBW.js} +2 -2
- package/dist/{connect-DKPQB4UI.js → connect-G4B4A36H.js} +2 -2
- package/dist/index.js +2460 -247
- package/dist/init.js +3 -3
- package/dist/{version-XYJRQQMM.js → version-N7HPHS4Y.js} +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-B5J64VJ4.js +0 -8
- package/dist/chunk-KSEZHXYC.js +0 -10
package/dist/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
ensureClientId,
|
|
23
23
|
fetchIngestSetup,
|
|
24
24
|
fmtDate,
|
|
25
|
+
getAbortSignal,
|
|
25
26
|
installSignalHandlers,
|
|
26
27
|
isMcpClient,
|
|
27
28
|
listProfiles,
|
|
@@ -54,11 +55,11 @@ import {
|
|
|
54
55
|
syncKeychainAfterKeyChange,
|
|
55
56
|
trySaveKeyToKeychain,
|
|
56
57
|
writeMcpServerEntry
|
|
57
|
-
} from "./chunk-
|
|
58
|
+
} from "./chunk-VAFSOAIO.js";
|
|
58
59
|
import {
|
|
59
60
|
MUSHI_CLI_VERSION,
|
|
60
61
|
MUSHI_MCP_PIN_SPEC
|
|
61
|
-
} from "./chunk-
|
|
62
|
+
} from "./chunk-WO52D547.js";
|
|
62
63
|
import {
|
|
63
64
|
CLOUD_API_ENDPOINT,
|
|
64
65
|
CLOUD_MCP_OAUTH_URL,
|
|
@@ -166,7 +167,7 @@ async function pollDeviceToken(endpoint, deviceCode) {
|
|
|
166
167
|
var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
167
168
|
var deviceAuthRetryDelayMs = 1e3;
|
|
168
169
|
async function waitForCliToken(endpoint, session, opts = {}) {
|
|
169
|
-
const
|
|
170
|
+
const sleep2 = opts.sleep ?? defaultSleep;
|
|
170
171
|
const now = opts.now ?? Date.now;
|
|
171
172
|
let intervalMs = (session.interval || 5) * 1e3;
|
|
172
173
|
const deadline = now() + (session.expires_in || 600) * 1e3;
|
|
@@ -175,7 +176,7 @@ async function waitForCliToken(endpoint, session, opts = {}) {
|
|
|
175
176
|
let firstPoll = true;
|
|
176
177
|
while (now() < deadline) {
|
|
177
178
|
if (!firstPoll) {
|
|
178
|
-
await
|
|
179
|
+
await sleep2(intervalMs);
|
|
179
180
|
}
|
|
180
181
|
firstPoll = false;
|
|
181
182
|
const outcome = await pollDeviceToken(endpoint, session.device_code);
|
|
@@ -394,19 +395,19 @@ function printPostLoginBanner(opts) {
|
|
|
394
395
|
if (message) console.log(message);
|
|
395
396
|
}
|
|
396
397
|
var MAX_CONSECUTIVE_ERRORS = 5;
|
|
397
|
-
async function pollUntilApproved(endpoint, deviceData,
|
|
398
|
-
const
|
|
399
|
-
const now =
|
|
398
|
+
async function pollUntilApproved(endpoint, deviceData, deps2) {
|
|
399
|
+
const sleep2 = deps2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
400
|
+
const now = deps2.now ?? Date.now;
|
|
400
401
|
let pollIntervalMs = (deviceData.interval ?? 5) * 1e3;
|
|
401
402
|
const deadline = now() + (deviceData.expires_in ?? 600) * 1e3;
|
|
402
403
|
let consecutiveErrors = 0;
|
|
403
404
|
let firstPoll = true;
|
|
404
405
|
while (now() < deadline) {
|
|
405
406
|
if (!firstPoll) {
|
|
406
|
-
await
|
|
407
|
+
await sleep2(pollIntervalMs);
|
|
407
408
|
}
|
|
408
409
|
firstPoll = false;
|
|
409
|
-
const outcome = await
|
|
410
|
+
const outcome = await deps2.poll(endpoint, deviceData.device_code);
|
|
410
411
|
switch (outcome.status) {
|
|
411
412
|
case "approved":
|
|
412
413
|
return { status: "approved", cliToken: outcome.cliToken };
|
|
@@ -435,15 +436,15 @@ async function resolveProjectChoice(projectsList, ask) {
|
|
|
435
436
|
if (projectsList.length > 0) {
|
|
436
437
|
console.log("");
|
|
437
438
|
console.log(" Your projects:");
|
|
438
|
-
projectsList.forEach((
|
|
439
|
-
console.log(` ${i + 1}. ${
|
|
439
|
+
projectsList.forEach((p4, i) => {
|
|
440
|
+
console.log(` ${i + 1}. ${p4.name} (${p4.id})`);
|
|
440
441
|
});
|
|
441
442
|
console.log(` ${projectsList.length + 1}. Create a new project`);
|
|
442
443
|
console.log("");
|
|
443
444
|
const choice = await ask(` Pick a project [1-${projectsList.length + 1}]: `);
|
|
444
|
-
const
|
|
445
|
-
if (
|
|
446
|
-
const picked = projectsList[
|
|
445
|
+
const num3 = parseInt(choice, 10);
|
|
446
|
+
if (num3 >= 1 && num3 <= projectsList.length) {
|
|
447
|
+
const picked = projectsList[num3 - 1];
|
|
447
448
|
return { kind: "picked", id: picked.id, name: picked.name };
|
|
448
449
|
}
|
|
449
450
|
}
|
|
@@ -463,7 +464,7 @@ function defaultProjectName(packageName, cwd) {
|
|
|
463
464
|
}
|
|
464
465
|
function chooseProjectNonInteractive(projects, preferredName) {
|
|
465
466
|
const wanted = slugify(preferredName);
|
|
466
|
-
const match = projects.find((
|
|
467
|
+
const match = projects.find((p4) => slugify(p4.name) === wanted || p4.slug !== void 0 && slugify(p4.slug) === wanted);
|
|
467
468
|
return match ? { kind: "existing", id: match.id, name: match.name } : { kind: "new", name: preferredName };
|
|
468
469
|
}
|
|
469
470
|
async function runLogin(opts = {}) {
|
|
@@ -554,7 +555,7 @@ error: Could not start login session: ${err instanceof Error ? err.message : Str
|
|
|
554
555
|
if (process.stdin.isTTY) {
|
|
555
556
|
const { createInterface } = await import("readline");
|
|
556
557
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
557
|
-
const ask = (
|
|
558
|
+
const ask = (q2) => new Promise((resolve9) => rl.question(q2, (a) => resolve9(a.trim())));
|
|
558
559
|
choice = await resolveProjectChoice(projectsList, ask);
|
|
559
560
|
rl.close();
|
|
560
561
|
} else {
|
|
@@ -782,13 +783,13 @@ function isFileSafe(path) {
|
|
|
782
783
|
}
|
|
783
784
|
}
|
|
784
785
|
function getFrameworkFromPkg(pkg) {
|
|
785
|
-
const
|
|
786
|
+
const deps2 = {
|
|
786
787
|
...pkg.dependencies ?? {},
|
|
787
788
|
...pkg.devDependencies ?? {},
|
|
788
789
|
...pkg.peerDependencies ?? {}
|
|
789
790
|
};
|
|
790
791
|
for (const dep of Object.keys(FRAMEWORK_DEPS)) {
|
|
791
|
-
if (dep in
|
|
792
|
+
if (dep in deps2) return FRAMEWORK_DEPS[dep];
|
|
792
793
|
}
|
|
793
794
|
return void 0;
|
|
794
795
|
}
|
|
@@ -909,11 +910,11 @@ async function checkCliAuthPath(config, doFetch = globalThis.fetch) {
|
|
|
909
910
|
async function checkSdkInstall(cwd) {
|
|
910
911
|
try {
|
|
911
912
|
const { readFile: readFile3 } = await import("fs/promises");
|
|
912
|
-
const { join:
|
|
913
|
-
const root =
|
|
914
|
-
const pkgPath =
|
|
913
|
+
const { join: join13, resolve: resolve9 } = await import("path");
|
|
914
|
+
const root = resolve9(cwd);
|
|
915
|
+
const pkgPath = join13(root, "package.json");
|
|
915
916
|
const pkg = JSON.parse(await readFile3(pkgPath, "utf8"));
|
|
916
|
-
const
|
|
917
|
+
const deps2 = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
917
918
|
const sdks = [
|
|
918
919
|
"@mushi-mushi/react",
|
|
919
920
|
"@mushi-mushi/web",
|
|
@@ -925,11 +926,11 @@ async function checkSdkInstall(cwd) {
|
|
|
925
926
|
"@mushi-mushi/capacitor",
|
|
926
927
|
"@mushi-mushi/node"
|
|
927
928
|
];
|
|
928
|
-
const installed = sdks.filter((s) =>
|
|
929
|
+
const installed = sdks.filter((s) => deps2[s]);
|
|
929
930
|
return {
|
|
930
931
|
name: "SDK installed in this repo",
|
|
931
932
|
ok: installed.length > 0,
|
|
932
|
-
detail: installed.length > 0 ? installed.map((s) => `${s}@${
|
|
933
|
+
detail: installed.length > 0 ? installed.map((s) => `${s}@${deps2[s]}`).join(", ") : "No @mushi-mushi/* package in package.json \u2014 run `npx mushi-mushi` to install"
|
|
933
934
|
};
|
|
934
935
|
} catch {
|
|
935
936
|
return null;
|
|
@@ -1121,17 +1122,17 @@ async function checkHostAppWiring(cwd) {
|
|
|
1121
1122
|
const checks = [];
|
|
1122
1123
|
try {
|
|
1123
1124
|
const { readFile: readFile3, access } = await import("fs/promises");
|
|
1124
|
-
const { join:
|
|
1125
|
-
const root =
|
|
1126
|
-
const pkgPath =
|
|
1125
|
+
const { join: join13, resolve: resolve9 } = await import("path");
|
|
1126
|
+
const root = resolve9(cwd);
|
|
1127
|
+
const pkgPath = join13(root, "package.json");
|
|
1127
1128
|
const pkg = JSON.parse(await readFile3(pkgPath, "utf8"));
|
|
1128
|
-
const
|
|
1129
|
-
const isCapHybrid = Boolean(
|
|
1129
|
+
const deps2 = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
1130
|
+
const isCapHybrid = Boolean(deps2["@capacitor/core"] && deps2["react"]);
|
|
1130
1131
|
const envCandidates = [".env.local", ".env"];
|
|
1131
1132
|
let envContent = "";
|
|
1132
1133
|
for (const f of envCandidates) {
|
|
1133
1134
|
try {
|
|
1134
|
-
envContent = await readFile3(
|
|
1135
|
+
envContent = await readFile3(join13(root, f), "utf8");
|
|
1135
1136
|
break;
|
|
1136
1137
|
} catch {
|
|
1137
1138
|
}
|
|
@@ -1146,9 +1147,9 @@ async function checkHostAppWiring(cwd) {
|
|
|
1146
1147
|
detail: hasProjectId && hasApiKey ? "VITE_/MUSHI_ project id + API key found" : "Run `mushi connect --write-env` or add VITE_MUSHI_PROJECT_ID + VITE_MUSHI_API_KEY"
|
|
1147
1148
|
});
|
|
1148
1149
|
let mcpFound = null;
|
|
1149
|
-
for (const rel of [".mcp.json",
|
|
1150
|
+
for (const rel of [".mcp.json", join13(".cursor", "mcp.json")]) {
|
|
1150
1151
|
try {
|
|
1151
|
-
await access(
|
|
1152
|
+
await access(join13(root, rel));
|
|
1152
1153
|
mcpFound = rel;
|
|
1153
1154
|
break;
|
|
1154
1155
|
} catch {
|
|
@@ -1160,7 +1161,7 @@ async function checkHostAppWiring(cwd) {
|
|
|
1160
1161
|
detail: mcpFound ? `${mcpFound} present` : "No .mcp.json (Claude Code) or .cursor/mcp.json (Cursor) \u2014 run `npx mushi-mushi setup --ide claude` or `--ide cursor` for two-way reporter replies"
|
|
1161
1162
|
});
|
|
1162
1163
|
if (isCapHybrid) {
|
|
1163
|
-
const hasWebSdk = Boolean(
|
|
1164
|
+
const hasWebSdk = Boolean(deps2["@mushi-mushi/web"] || deps2["@mushi-mushi/react"]);
|
|
1164
1165
|
checks.push({
|
|
1165
1166
|
name: "[host] Capacitor hybrid \u2014 WebView SDK",
|
|
1166
1167
|
ok: hasWebSdk,
|
|
@@ -1169,7 +1170,7 @@ async function checkHostAppWiring(cwd) {
|
|
|
1169
1170
|
checks.push({
|
|
1170
1171
|
name: "[host] Capacitor native plugin (optional)",
|
|
1171
1172
|
ok: true,
|
|
1172
|
-
detail:
|
|
1173
|
+
detail: deps2["@mushi-mushi/capacitor"] ? `@mushi-mushi/capacitor@${deps2["@mushi-mushi/capacitor"]} installed` : "Optional: @mushi-mushi/capacitor for native shell parity \u2014 WebView SDK covers most flows"
|
|
1173
1174
|
});
|
|
1174
1175
|
}
|
|
1175
1176
|
} catch {
|
|
@@ -1190,7 +1191,7 @@ var HASH_ROUTER_PATTERNS = [
|
|
|
1190
1191
|
var HASH_TEMPLATE_PATTERN = /['"`]\/?#\//;
|
|
1191
1192
|
async function scanDirForPattern(dir, patterns, maxDepth = 6, maxFiles = 2e3) {
|
|
1192
1193
|
const { readdir: readdir2, readFile: readFile3 } = await import("fs/promises");
|
|
1193
|
-
const { join:
|
|
1194
|
+
const { join: join13 } = await import("path");
|
|
1194
1195
|
let count = 0;
|
|
1195
1196
|
async function walk2(d, depth) {
|
|
1196
1197
|
if (depth > maxDepth || count > maxFiles) return null;
|
|
@@ -1202,7 +1203,7 @@ async function scanDirForPattern(dir, patterns, maxDepth = 6, maxFiles = 2e3) {
|
|
|
1202
1203
|
}
|
|
1203
1204
|
for (const e of entries) {
|
|
1204
1205
|
if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue;
|
|
1205
|
-
const full =
|
|
1206
|
+
const full = join13(d, e.name);
|
|
1206
1207
|
if (e.isDirectory()) {
|
|
1207
1208
|
const found = await walk2(full, depth + 1);
|
|
1208
1209
|
if (found) return found;
|
|
@@ -1210,19 +1211,19 @@ async function scanDirForPattern(dir, patterns, maxDepth = 6, maxFiles = 2e3) {
|
|
|
1210
1211
|
count++;
|
|
1211
1212
|
try {
|
|
1212
1213
|
const src = await readFile3(full, "utf8");
|
|
1213
|
-
if (patterns.some((
|
|
1214
|
+
if (patterns.some((p4) => p4.test(src))) return full;
|
|
1214
1215
|
} catch {
|
|
1215
1216
|
}
|
|
1216
1217
|
}
|
|
1217
1218
|
}
|
|
1218
1219
|
return null;
|
|
1219
1220
|
}
|
|
1220
|
-
const { resolve:
|
|
1221
|
+
const { resolve: resolve9 } = await import("path");
|
|
1221
1222
|
for (const sub of ["src", "app", "pages", "lib", "components"]) {
|
|
1222
|
-
const found = await walk2(
|
|
1223
|
+
const found = await walk2(join13(resolve9(dir), sub), 0);
|
|
1223
1224
|
if (found) return found;
|
|
1224
1225
|
}
|
|
1225
|
-
return walk2(
|
|
1226
|
+
return walk2(resolve9(dir), 0);
|
|
1226
1227
|
}
|
|
1227
1228
|
async function checkHashRouterCapture(cwd) {
|
|
1228
1229
|
const hashFile = await scanDirForPattern(cwd, HASH_ROUTER_PATTERNS);
|
|
@@ -1255,13 +1256,13 @@ function normalizeProjectPath(path) {
|
|
|
1255
1256
|
}
|
|
1256
1257
|
async function collectMcpSources(root, home) {
|
|
1257
1258
|
const { readFile: readFile3 } = await import("fs/promises");
|
|
1258
|
-
const { join:
|
|
1259
|
+
const { join: join13 } = await import("path");
|
|
1259
1260
|
const sources = [];
|
|
1260
1261
|
const invalid = [];
|
|
1261
1262
|
const plainFiles = [
|
|
1262
|
-
{ path:
|
|
1263
|
-
{ path:
|
|
1264
|
-
{ path:
|
|
1263
|
+
{ path: join13(root, ".mcp.json"), claudeCode: true },
|
|
1264
|
+
{ path: join13(root, ".cursor", "mcp.json"), claudeCode: false },
|
|
1265
|
+
{ path: join13(home, ".cursor", "mcp.json"), claudeCode: false }
|
|
1265
1266
|
];
|
|
1266
1267
|
for (const file of plainFiles) {
|
|
1267
1268
|
let raw;
|
|
@@ -1278,7 +1279,7 @@ async function collectMcpSources(root, home) {
|
|
|
1278
1279
|
invalid.push(file.path);
|
|
1279
1280
|
}
|
|
1280
1281
|
}
|
|
1281
|
-
const claudeJsonPath =
|
|
1282
|
+
const claudeJsonPath = join13(home, ".claude.json");
|
|
1282
1283
|
try {
|
|
1283
1284
|
const parsed = JSON.parse(await readFile3(claudeJsonPath, "utf8"));
|
|
1284
1285
|
if (isJsonObject(parsed)) {
|
|
@@ -1349,9 +1350,9 @@ function assessMushiEntry(source, name, entry, cliKeySaved) {
|
|
|
1349
1350
|
}
|
|
1350
1351
|
async function checkMcpConfig(config, cwd, doFetch = globalThis.fetch, home) {
|
|
1351
1352
|
const checks = [];
|
|
1352
|
-
const { resolve:
|
|
1353
|
+
const { resolve: resolve9 } = await import("path");
|
|
1353
1354
|
const { homedir } = await import("os");
|
|
1354
|
-
const root =
|
|
1355
|
+
const root = resolve9(cwd);
|
|
1355
1356
|
const { sources, invalid } = await collectMcpSources(root, home ?? homedir());
|
|
1356
1357
|
if (invalid.length > 0) {
|
|
1357
1358
|
checks.push({
|
|
@@ -1475,12 +1476,12 @@ async function checkOnboardingStatus(config, _consoleBase, cwd, doFetch = global
|
|
|
1475
1476
|
}
|
|
1476
1477
|
try {
|
|
1477
1478
|
const { readFile: readFile3 } = await import("fs/promises");
|
|
1478
|
-
const { join:
|
|
1479
|
-
const root =
|
|
1479
|
+
const { join: join13, resolve: resolve9 } = await import("path");
|
|
1480
|
+
const root = resolve9(cwd);
|
|
1480
1481
|
let envContent = "";
|
|
1481
1482
|
for (const f of [".env.local", ".env"]) {
|
|
1482
1483
|
try {
|
|
1483
|
-
envContent = await readFile3(
|
|
1484
|
+
envContent = await readFile3(join13(root, f), "utf8");
|
|
1484
1485
|
break;
|
|
1485
1486
|
} catch {
|
|
1486
1487
|
}
|
|
@@ -1546,7 +1547,7 @@ async function checkOnboardingStatus(config, _consoleBase, cwd, doFetch = global
|
|
|
1546
1547
|
}
|
|
1547
1548
|
async function checkVersionDrift(cwd, doFetch) {
|
|
1548
1549
|
const checks = [];
|
|
1549
|
-
const { MUSHI_CLI_VERSION: MUSHI_CLI_VERSION2 } = await import("./version-
|
|
1550
|
+
const { MUSHI_CLI_VERSION: MUSHI_CLI_VERSION2 } = await import("./version-N7HPHS4Y.js");
|
|
1550
1551
|
if (MUSHI_CLI_VERSION2 === "0.0.0-dev") return checks;
|
|
1551
1552
|
const parse2 = (v) => {
|
|
1552
1553
|
const m = v.replace(/^[~^]/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
@@ -1576,10 +1577,10 @@ async function checkVersionDrift(cwd, doFetch) {
|
|
|
1576
1577
|
}
|
|
1577
1578
|
try {
|
|
1578
1579
|
const { readFile: readFile3 } = await import("fs/promises");
|
|
1579
|
-
const { join:
|
|
1580
|
-
const pkg = JSON.parse(await readFile3(
|
|
1581
|
-
const
|
|
1582
|
-
const candidates = Object.entries(
|
|
1580
|
+
const { join: join13 } = await import("path");
|
|
1581
|
+
const pkg = JSON.parse(await readFile3(join13(cwd, "package.json"), "utf8"));
|
|
1582
|
+
const deps2 = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1583
|
+
const candidates = Object.entries(deps2).filter(([name]) => name.startsWith("@mushi-mushi/")).filter(([, v]) => /^[~^]?\d/.test(v));
|
|
1583
1584
|
const drifted = (await Promise.all(
|
|
1584
1585
|
candidates.map(async ([name, v]) => {
|
|
1585
1586
|
try {
|
|
@@ -1833,11 +1834,11 @@ async function runInitAudit(cwd) {
|
|
|
1833
1834
|
}
|
|
1834
1835
|
}
|
|
1835
1836
|
function maybeSuggestAudit(pkg) {
|
|
1836
|
-
const
|
|
1837
|
+
const deps2 = {
|
|
1837
1838
|
...pkg?.dependencies,
|
|
1838
1839
|
...pkg?.devDependencies
|
|
1839
1840
|
};
|
|
1840
|
-
if (Object.keys(
|
|
1841
|
+
if (Object.keys(deps2).some((d) => d.startsWith("@mushi-mushi/"))) {
|
|
1841
1842
|
p.log.info(
|
|
1842
1843
|
"A Mushi SDK is already installed here. To health-check the existing setup instead, run `npx mushi-mushi --audit`."
|
|
1843
1844
|
);
|
|
@@ -1988,12 +1989,12 @@ async function resolveSavedCredentials(saved, endpoint, probe) {
|
|
|
1988
1989
|
}
|
|
1989
1990
|
return { kind: "unusable", reason: describeUnsafeSdkKey(cliProbe, endpoint) };
|
|
1990
1991
|
}
|
|
1991
|
-
async function mintWizardKeys(
|
|
1992
|
+
async function mintWizardKeys(deps2, args) {
|
|
1992
1993
|
let projectId2;
|
|
1993
1994
|
let createdName;
|
|
1994
1995
|
let sdkKey = null;
|
|
1995
1996
|
if (args.target.kind === "new") {
|
|
1996
|
-
const created = await
|
|
1997
|
+
const created = await deps2.createProject(args.endpoint, args.cliToken, args.target.name, {
|
|
1997
1998
|
scopes: SDK_KEY_SCOPES
|
|
1998
1999
|
});
|
|
1999
2000
|
projectId2 = created.id;
|
|
@@ -2003,7 +2004,7 @@ async function mintWizardKeys(deps, args) {
|
|
|
2003
2004
|
projectId2 = args.target.projectId;
|
|
2004
2005
|
}
|
|
2005
2006
|
if (!sdkKey) {
|
|
2006
|
-
sdkKey = await
|
|
2007
|
+
sdkKey = await deps2.mintProjectKey(args.endpoint, args.cliToken, projectId2, {
|
|
2007
2008
|
scopes: SDK_KEY_SCOPES,
|
|
2008
2009
|
label: SDK_KEY_LABEL
|
|
2009
2010
|
});
|
|
@@ -2012,7 +2013,7 @@ async function mintWizardKeys(deps, args) {
|
|
|
2012
2013
|
let cliKeyError;
|
|
2013
2014
|
if (args.withCliKey) {
|
|
2014
2015
|
try {
|
|
2015
|
-
cliKey = await
|
|
2016
|
+
cliKey = await deps2.mintProjectKey(args.endpoint, args.cliToken, projectId2, {
|
|
2016
2017
|
scopes: CLI_KEY_SCOPES,
|
|
2017
2018
|
label: CLI_KEY_LABEL
|
|
2018
2019
|
});
|
|
@@ -2287,7 +2288,7 @@ async function installPackages(pm, packages, cwd) {
|
|
|
2287
2288
|
function runCommand(pm, packages, cwd) {
|
|
2288
2289
|
const verb = pm === "npm" ? "install" : "add";
|
|
2289
2290
|
const command = process.platform === "win32" ? `${pm}.cmd` : pm;
|
|
2290
|
-
return new Promise((
|
|
2291
|
+
return new Promise((resolve9, reject) => {
|
|
2291
2292
|
const child = spawn(command, [verb, ...packages], {
|
|
2292
2293
|
stdio: "inherit",
|
|
2293
2294
|
shell: false,
|
|
@@ -2296,7 +2297,7 @@ function runCommand(pm, packages, cwd) {
|
|
|
2296
2297
|
});
|
|
2297
2298
|
child.on("error", reject);
|
|
2298
2299
|
child.on("exit", (code) => {
|
|
2299
|
-
if (code === 0)
|
|
2300
|
+
if (code === 0) resolve9();
|
|
2300
2301
|
else reject(new Error(`${pm} exited with code ${code ?? "null"}`));
|
|
2301
2302
|
});
|
|
2302
2303
|
});
|
|
@@ -2470,7 +2471,7 @@ async function maybeOfferConnect(credentials, options, _consoleBase) {
|
|
|
2470
2471
|
if (p.isCancel(answer) || !answer) return;
|
|
2471
2472
|
const endpoint = resolveCloudEndpoint(options.endpoint);
|
|
2472
2473
|
try {
|
|
2473
|
-
const { runConnect: runConnect2 } = await import("./connect-
|
|
2474
|
+
const { runConnect: runConnect2 } = await import("./connect-G4B4A36H.js");
|
|
2474
2475
|
const saved = loadConfig();
|
|
2475
2476
|
const result = await runConnect2(
|
|
2476
2477
|
{
|
|
@@ -2673,7 +2674,7 @@ var MIGRATE_CATALOG = [
|
|
|
2673
2674
|
"@sentry/angular",
|
|
2674
2675
|
"@sentry/react-native",
|
|
2675
2676
|
"@sentry/capacitor"
|
|
2676
|
-
].some((
|
|
2677
|
+
].some((p4) => d.has(p4))
|
|
2677
2678
|
},
|
|
2678
2679
|
...Object.entries(COMPETITOR_PACKAGES).map(([slug, pkgs]) => ({
|
|
2679
2680
|
slug,
|
|
@@ -2682,7 +2683,7 @@ var MIGRATE_CATALOG = [
|
|
|
2682
2683
|
category: "competitor",
|
|
2683
2684
|
status: "published",
|
|
2684
2685
|
detectionLabel: pkgs[0],
|
|
2685
|
-
match: (d) => pkgs.some((
|
|
2686
|
+
match: (d) => pkgs.some((p4) => d.has(p4))
|
|
2686
2687
|
}))
|
|
2687
2688
|
];
|
|
2688
2689
|
function titleForCompetitor(slug) {
|
|
@@ -2701,8 +2702,8 @@ function titleForCompetitor(slug) {
|
|
|
2701
2702
|
return slug;
|
|
2702
2703
|
}
|
|
2703
2704
|
}
|
|
2704
|
-
function detectMigrations(
|
|
2705
|
-
return catalog.filter((g) => g.status === "published" && g.match?.(
|
|
2705
|
+
function detectMigrations(deps2, catalog = MIGRATE_CATALOG) {
|
|
2706
|
+
return catalog.filter((g) => g.status === "published" && g.match?.(deps2)).map((g) => ({ guide: g, url: `${DOCS_BASE}/migrations/${g.slug}` }));
|
|
2706
2707
|
}
|
|
2707
2708
|
function depsFromPackageJson(pkg) {
|
|
2708
2709
|
if (!pkg) return /* @__PURE__ */ new Set();
|
|
@@ -2722,8 +2723,8 @@ function runMigrate(opts = {}) {
|
|
|
2722
2723
|
);
|
|
2723
2724
|
return { matches: [] };
|
|
2724
2725
|
}
|
|
2725
|
-
const
|
|
2726
|
-
const matches = detectMigrations(
|
|
2726
|
+
const deps2 = depsFromPackageJson(pkg);
|
|
2727
|
+
const matches = detectMigrations(deps2);
|
|
2727
2728
|
if (opts.json) {
|
|
2728
2729
|
log3(
|
|
2729
2730
|
JSON.stringify(
|
|
@@ -2943,7 +2944,80 @@ function registerDeployCommands(program2) {
|
|
|
2943
2944
|
});
|
|
2944
2945
|
}
|
|
2945
2946
|
|
|
2947
|
+
// src/command-helpers.ts
|
|
2948
|
+
function orgSegment(org) {
|
|
2949
|
+
if (!org || org === "current") return "current";
|
|
2950
|
+
return encodeURIComponent(requireUuid(org, "organization id"));
|
|
2951
|
+
}
|
|
2952
|
+
function dieOrgError(result) {
|
|
2953
|
+
const { code } = result.error;
|
|
2954
|
+
if (code === "ORG_REQUIRED") {
|
|
2955
|
+
const orgs = Array.isArray(result.error["organizations"]) ? result.error["organizations"] : [];
|
|
2956
|
+
if (orgs.length > 0) {
|
|
2957
|
+
process.stderr.write("\n Pick one and pass it with --org:\n");
|
|
2958
|
+
for (const o of orgs) {
|
|
2959
|
+
process.stderr.write(` --org ${String(o.id)} ${typeof o.name === "string" ? o.name : ""}
|
|
2960
|
+
`);
|
|
2961
|
+
}
|
|
2962
|
+
process.stderr.write("\n");
|
|
2963
|
+
}
|
|
2964
|
+
} else if (code.endsWith("_NEEDS_ACCOUNT_KEY")) {
|
|
2965
|
+
process.stderr.write(
|
|
2966
|
+
"\n Team-wide views need an account-level key, not a key bound to one project.\n Mint one in the console (Connect \u2192 MCP \u2192 account key), then:\n\n MUSHI_API_KEY=<account key> mushi <command>\n\n"
|
|
2967
|
+
);
|
|
2968
|
+
}
|
|
2969
|
+
die(result);
|
|
2970
|
+
}
|
|
2971
|
+
function requireYes(yes, action) {
|
|
2972
|
+
if (yes) return;
|
|
2973
|
+
throw new MushiCliError(
|
|
2974
|
+
"E_INVALID_INPUT",
|
|
2975
|
+
`${action} Re-run with --yes to go ahead.`,
|
|
2976
|
+
"nothing was sent"
|
|
2977
|
+
);
|
|
2978
|
+
}
|
|
2979
|
+
function resolveProjectId(flag, configured) {
|
|
2980
|
+
const raw = flag ?? configured;
|
|
2981
|
+
if (!raw) {
|
|
2982
|
+
throw new MushiCliError(
|
|
2983
|
+
"E_PROJECT_MISSING",
|
|
2984
|
+
"Project ID not configured.",
|
|
2985
|
+
"pass --project-id <uuid> or run `mushi login --project-id <uuid>`"
|
|
2986
|
+
);
|
|
2987
|
+
}
|
|
2988
|
+
return requireUuid(raw, "project id");
|
|
2989
|
+
}
|
|
2990
|
+
function oneLine(text2, max = 80) {
|
|
2991
|
+
const flat = String(text2 ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
2992
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2946
2995
|
// src/commands/reports.ts
|
|
2996
|
+
var FILE_REPORT_SEVERITIES = ["critical", "high", "medium", "low"];
|
|
2997
|
+
function buildFileReportBody(flags) {
|
|
2998
|
+
const title = (flags.title ?? "").trim();
|
|
2999
|
+
const description = (flags.description ?? "").trim();
|
|
3000
|
+
if (title.length < 3 || title.length > 200) {
|
|
3001
|
+
throw new MushiCliError("E_INVALID_INPUT", "--title must be 3 to 200 characters.");
|
|
3002
|
+
}
|
|
3003
|
+
if (description.length < 1 || description.length > 1e4) {
|
|
3004
|
+
throw new MushiCliError("E_INVALID_INPUT", "--description must be 1 to 10000 characters.");
|
|
3005
|
+
}
|
|
3006
|
+
const body = { title, description };
|
|
3007
|
+
if (flags.severity !== void 0) {
|
|
3008
|
+
const severity = flags.severity.trim().toLowerCase();
|
|
3009
|
+
if (!FILE_REPORT_SEVERITIES.includes(severity)) {
|
|
3010
|
+
throw new MushiCliError("E_INVALID_INPUT", `--severity must be one of: ${FILE_REPORT_SEVERITIES.join(", ")}.`);
|
|
3011
|
+
}
|
|
3012
|
+
body["severity"] = severity;
|
|
3013
|
+
}
|
|
3014
|
+
if (flags.component !== void 0) {
|
|
3015
|
+
const component = flags.component.trim();
|
|
3016
|
+
if (component.length > 200) throw new MushiCliError("E_INVALID_INPUT", "--component is at most 200 characters.");
|
|
3017
|
+
if (component) body["component"] = component;
|
|
3018
|
+
}
|
|
3019
|
+
return body;
|
|
3020
|
+
}
|
|
2947
3021
|
function registerReportsCommands(program2) {
|
|
2948
3022
|
const reports = program2.command("reports").description("Manage bug reports");
|
|
2949
3023
|
reports.command("list").description("List recent reports for the current project").option("--limit <n>", "Max results (1\u2013100)", "20").option("--status <status>", "Filter by status: new|triaged|in_progress|resolved|dismissed").option("--severity <severity>", "Filter by severity: critical|high|medium|low").option("--search <query>", "Full-text search in summary and description").option("--json", "Output as JSON (alias for -o json)").addHelpText("after", `
|
|
@@ -3229,6 +3303,30 @@ Examples:
|
|
|
3229
3303
|
}
|
|
3230
3304
|
});
|
|
3231
3305
|
});
|
|
3306
|
+
reports.command("file").description("File a report by hand (a backlog item, a bug you found yourself); it is triaged like any other report").requiredOption("--title <text>", "One-line title (3-200 characters)").requiredOption("--description <text>", "What happens, steps, what you expected (up to 10000 characters)").option("--severity <severity>", "Your hint: critical|high|medium|low (triage may refine it)").option("--component <name>", "Area of the app, e.g. checkout").option("--project <id>", "Project ID (defaults to the configured project)").option("--project-id <id>", "Same as --project").option("--json", "Output as JSON (alias for -o json)").addHelpText("after", `
|
|
3307
|
+
Needs a key with the mcp:write scope (or an account key that reaches the app).
|
|
3308
|
+
At most 30 reports an hour per app.
|
|
3309
|
+
|
|
3310
|
+
Examples:
|
|
3311
|
+
mushi reports file --title "Checkout adds tax twice" --description "Cart of 2 items shows tax x2 on /checkout"
|
|
3312
|
+
mushi reports file --title "Bump sharp to 0.34" --description "..." --severity low --component build --project <uuid>`).action(async (opts) => {
|
|
3313
|
+
const body = buildFileReportBody(opts);
|
|
3314
|
+
const config = requireConfig();
|
|
3315
|
+
const projectId2 = resolveProjectId(opts.project ?? opts.projectId, config.projectId);
|
|
3316
|
+
const result = await apiCall(`/v1/admin/projects/${projectId2}/reports`, config, {
|
|
3317
|
+
method: "POST",
|
|
3318
|
+
body: JSON.stringify(body)
|
|
3319
|
+
});
|
|
3320
|
+
if (!result.ok) die(result);
|
|
3321
|
+
printResult(result.data, {
|
|
3322
|
+
json: opts.json,
|
|
3323
|
+
render(d) {
|
|
3324
|
+
console.log(`\u2713 Filed report ${d.reportId}`);
|
|
3325
|
+
console.log(` ${d.title}`);
|
|
3326
|
+
console.log(` Triage runs now; see it with: mushi reports show ${d.reportId}`);
|
|
3327
|
+
}
|
|
3328
|
+
});
|
|
3329
|
+
});
|
|
3232
3330
|
}
|
|
3233
3331
|
|
|
3234
3332
|
// src/commands/feedback.ts
|
|
@@ -3400,11 +3498,11 @@ async function findMapFiles(dir) {
|
|
|
3400
3498
|
return results;
|
|
3401
3499
|
}
|
|
3402
3500
|
function fileHash(path) {
|
|
3403
|
-
return new Promise((
|
|
3501
|
+
return new Promise((resolve9, reject) => {
|
|
3404
3502
|
const hash = createHash("sha256");
|
|
3405
3503
|
const stream = createReadStream(path);
|
|
3406
3504
|
stream.on("data", (chunk) => hash.update(chunk));
|
|
3407
|
-
stream.on("end", () =>
|
|
3505
|
+
stream.on("end", () => resolve9(hash.digest("hex")));
|
|
3408
3506
|
stream.on("error", reject);
|
|
3409
3507
|
});
|
|
3410
3508
|
}
|
|
@@ -3724,6 +3822,20 @@ async function writeProjectBootstrapFiles(opts) {
|
|
|
3724
3822
|
}
|
|
3725
3823
|
|
|
3726
3824
|
// src/commands/project.ts
|
|
3825
|
+
async function persistProjectCreate(opts) {
|
|
3826
|
+
const config = loadConfig(opts.configPath);
|
|
3827
|
+
config.apiKey = opts.apiKey;
|
|
3828
|
+
config.endpoint = opts.endpoint;
|
|
3829
|
+
config.projectId = opts.projectId;
|
|
3830
|
+
config.consoleUrl = opts.consoleUrl;
|
|
3831
|
+
saveConfig(config, opts.configPath);
|
|
3832
|
+
return writeProjectBootstrapFiles({
|
|
3833
|
+
cwd: opts.cwd,
|
|
3834
|
+
endpoint: opts.endpoint,
|
|
3835
|
+
projectId: opts.projectId,
|
|
3836
|
+
apiKey: opts.apiKey
|
|
3837
|
+
});
|
|
3838
|
+
}
|
|
3727
3839
|
function registerProjectCommands(program2) {
|
|
3728
3840
|
const project = program2.command("project").description("Project management");
|
|
3729
3841
|
project.command("create").description("Create or select a Mushi project via browser sign-in, then write config files").option("--name <name>", "Project name for a new project (skips the prompt)").option("--no-browser", "Print the verification URL instead of opening the browser").option("--endpoint <url>", "Override API endpoint (self-hosted)").addHelpText("after", `
|
|
@@ -3777,7 +3889,7 @@ error: Could not start browser sign-in: ${err instanceof Error ? err.message : S
|
|
|
3777
3889
|
const projectsList = await listProjects(endpoint, cliToken);
|
|
3778
3890
|
const { createInterface } = await import("readline");
|
|
3779
3891
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3780
|
-
const ask = (
|
|
3892
|
+
const ask = (q2) => new Promise((resolve9) => rl.question(q2, (a) => resolve9(a.trim())));
|
|
3781
3893
|
let chosen;
|
|
3782
3894
|
let projectId2;
|
|
3783
3895
|
let projectName;
|
|
@@ -3791,9 +3903,9 @@ error: Could not start browser sign-in: ${err instanceof Error ? err.message : S
|
|
|
3791
3903
|
console.log(` ${projectsList.length + 1}. Create a new project`);
|
|
3792
3904
|
console.log("");
|
|
3793
3905
|
const choice = await ask(` Pick a project [1-${projectsList.length + 1}]: `);
|
|
3794
|
-
const
|
|
3795
|
-
if (
|
|
3796
|
-
chosen = projectsList[
|
|
3906
|
+
const num3 = parseInt(choice, 10);
|
|
3907
|
+
if (num3 >= 1 && num3 <= projectsList.length) {
|
|
3908
|
+
chosen = projectsList[num3 - 1];
|
|
3797
3909
|
projectId2 = chosen.id;
|
|
3798
3910
|
projectName = chosen.name;
|
|
3799
3911
|
}
|
|
@@ -3827,16 +3939,11 @@ error: Could not create project: ${err instanceof Error ? err.message : String(e
|
|
|
3827
3939
|
process.stderr.write("\nerror: Could not obtain an SDK key for the project. Run `mushi login` and try again.\n");
|
|
3828
3940
|
process.exit(1);
|
|
3829
3941
|
}
|
|
3830
|
-
const
|
|
3831
|
-
config.apiKey = apiKey;
|
|
3832
|
-
config.endpoint = endpoint;
|
|
3833
|
-
config.projectId = projectId2;
|
|
3834
|
-
config.consoleUrl = consoleBase;
|
|
3835
|
-
saveConfig(config);
|
|
3836
|
-
const { envUpdated, mcpUpdated } = await writeProjectBootstrapFiles({
|
|
3942
|
+
const { envUpdated, mcpUpdated } = await persistProjectCreate({
|
|
3837
3943
|
endpoint,
|
|
3838
3944
|
projectId: projectId2,
|
|
3839
|
-
apiKey
|
|
3945
|
+
apiKey,
|
|
3946
|
+
consoleUrl: consoleBase
|
|
3840
3947
|
});
|
|
3841
3948
|
console.log(`
|
|
3842
3949
|
OK ${envUpdated ? "Updated" : "Created"} .env.local`);
|
|
@@ -3999,7 +4106,7 @@ If you are not signed in yet, setup runs browser sign-in first, then writes the
|
|
|
3999
4106
|
);
|
|
4000
4107
|
if (res.ok) {
|
|
4001
4108
|
const body = await res.json();
|
|
4002
|
-
const project = body?.data?.projects?.find((
|
|
4109
|
+
const project = body?.data?.projects?.find((p4) => p4.id === config.projectId);
|
|
4003
4110
|
slug = project?.name ? slugifyProjectName(project.name) : config.projectId?.slice(0, 8) ?? "mushi";
|
|
4004
4111
|
} else {
|
|
4005
4112
|
slug = config.projectId?.slice(0, 8) ?? "mushi";
|
|
@@ -4047,11 +4154,11 @@ If you are not signed in yet, setup runs browser sign-in first, then writes the
|
|
|
4047
4154
|
if (note2) console.log(note2);
|
|
4048
4155
|
if (target.format === "mcp-json") {
|
|
4049
4156
|
if (allProjectsList && allProjectsList.length > 0) {
|
|
4050
|
-
for (const
|
|
4051
|
-
const pServerName = buildMcpServerName({ projectId:
|
|
4157
|
+
for (const p4 of allProjectsList) {
|
|
4158
|
+
const pServerName = buildMcpServerName({ projectId: p4.id, projectName: p4.name ?? void 0 });
|
|
4052
4159
|
const pBlock = buildMcpServerBlock({
|
|
4053
4160
|
endpoint: config.endpoint,
|
|
4054
|
-
projectId:
|
|
4161
|
+
projectId: p4.id,
|
|
4055
4162
|
apiKey: config.apiKey,
|
|
4056
4163
|
client: ide,
|
|
4057
4164
|
inlineKey
|
|
@@ -4063,7 +4170,7 @@ If you are not signed in yet, setup runs browser sign-in first, then writes the
|
|
|
4063
4170
|
if (opts.dryRun) {
|
|
4064
4171
|
console.log(`[dry-run] Would add ${allProjectsList.length} mushi-* entries to ${configPath}`);
|
|
4065
4172
|
} else {
|
|
4066
|
-
console.log(`\u2713 Added ${allProjectsList.length} mushi-* server entries (${allProjectsList.map((
|
|
4173
|
+
console.log(`\u2713 Added ${allProjectsList.length} mushi-* server entries (${allProjectsList.map((p4) => p4.name ?? p4.id.slice(0, 8)).join(", ")})`);
|
|
4067
4174
|
if (!inlineKey) printKeyExportHint(ide);
|
|
4068
4175
|
}
|
|
4069
4176
|
} else {
|
|
@@ -4211,7 +4318,7 @@ If you are not signed in yet, setup runs browser sign-in first, then writes the
|
|
|
4211
4318
|
const { createInterface } = await import("readline");
|
|
4212
4319
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
4213
4320
|
const answer = await new Promise(
|
|
4214
|
-
(
|
|
4321
|
+
(resolve9) => rl.question(" Upgrade this key now via browser sign-in? [Y/n] ", (a) => resolve9(a.trim().toLowerCase()))
|
|
4215
4322
|
);
|
|
4216
4323
|
rl.close();
|
|
4217
4324
|
if (answer === "" || answer === "y" || answer === "yes") {
|
|
@@ -4343,6 +4450,45 @@ async function resolveTargetRepoId(repo, cfg) {
|
|
|
4343
4450
|
`linked repos: ${names}`
|
|
4344
4451
|
);
|
|
4345
4452
|
}
|
|
4453
|
+
var GATE_MARK = {
|
|
4454
|
+
pass: ["\u2713", "pass"],
|
|
4455
|
+
fail: ["\u2717", "fail"],
|
|
4456
|
+
pending: ["\u2026", "pending"],
|
|
4457
|
+
unavailable: ["\u2013", "unavailable"],
|
|
4458
|
+
not_set_up: ["\u25CB", "not set up"]
|
|
4459
|
+
};
|
|
4460
|
+
var AUTO_MERGE_WORDS = {
|
|
4461
|
+
none: "not merged by Mushi",
|
|
4462
|
+
eligible: "eligible, waiting for the next run",
|
|
4463
|
+
merged: "merged by Mushi",
|
|
4464
|
+
skipped: "skipped",
|
|
4465
|
+
failed: "failed"
|
|
4466
|
+
};
|
|
4467
|
+
var SIGNAL_WORDS = {
|
|
4468
|
+
ci_failed_on_merge: "CI failed on the merge commit",
|
|
4469
|
+
report_reopened: "the report was reopened",
|
|
4470
|
+
new_report_in_changed_file: "a new report names a file this fix changed"
|
|
4471
|
+
};
|
|
4472
|
+
function renderVerification(v) {
|
|
4473
|
+
const lines = [`Head ${v.headSha ? v.headSha.slice(0, 7) : "unknown"}: ${v.passed ? "every gate passes" : "not verified yet"}`];
|
|
4474
|
+
for (const g of v.gates ?? []) {
|
|
4475
|
+
const [glyph, word] = GATE_MARK[g.status] ?? ["?", g.status];
|
|
4476
|
+
lines.push(` ${glyph} ${word.padEnd(11)} ${g.label}`);
|
|
4477
|
+
if (g.detail && g.status !== "pass") lines.push(` ${g.detail}`);
|
|
4478
|
+
}
|
|
4479
|
+
const am = v.autoMerge;
|
|
4480
|
+
if (am) lines.push(`Automatic merge: ${AUTO_MERGE_WORDS[am.state] ?? am.state}${am.detail ? `. ${am.detail}` : ""}`);
|
|
4481
|
+
for (const r of v.regressions ?? []) {
|
|
4482
|
+
const signal = String(r["signal"] ?? "");
|
|
4483
|
+
const revertUrl = r["revertPrUrl"] ?? r["revert_pr_url"];
|
|
4484
|
+
const revertState = String(r["revertState"] ?? r["revert_state"] ?? "none");
|
|
4485
|
+
const detail = typeof r["detail"] === "string" && r["detail"] ? `: ${r["detail"]}` : "";
|
|
4486
|
+
lines.push(`Regression: ${SIGNAL_WORDS[signal] ?? signal}${detail}`);
|
|
4487
|
+
if (revertUrl) lines.push(` Revert PR: ${revertUrl}`);
|
|
4488
|
+
else if (revertState === "not_safe") lines.push(" No revert PR: later commits changed the same files. Revert by hand.");
|
|
4489
|
+
}
|
|
4490
|
+
return lines;
|
|
4491
|
+
}
|
|
4346
4492
|
function registerFixCommands(program2) {
|
|
4347
4493
|
const fixCmd = program2.command("fix").description("Dispatch an agentic fix for a report");
|
|
4348
4494
|
fixCmd.argument("<reportId>", "Report UUID to fix").option(
|
|
@@ -4521,6 +4667,9 @@ Examples:
|
|
|
4521
4667
|
"--method <method>",
|
|
4522
4668
|
"GitHub merge method: squash (default), merge, or rebase",
|
|
4523
4669
|
"squash"
|
|
4670
|
+
).option(
|
|
4671
|
+
"--override",
|
|
4672
|
+
'Not available here: the CLI signs in with an API key, and only a person in the Mushi console can override an Opus "hold" (with a reason)'
|
|
4524
4673
|
).option("--json", "Machine-readable JSON output").action(async (fixId, opts) => {
|
|
4525
4674
|
const config = loadConfig();
|
|
4526
4675
|
if (!config.apiKey) {
|
|
@@ -4531,6 +4680,12 @@ Examples:
|
|
|
4531
4680
|
console.error("No endpoint configured. Run `mushi init`");
|
|
4532
4681
|
process.exit(1);
|
|
4533
4682
|
}
|
|
4683
|
+
if (opts.override) {
|
|
4684
|
+
console.error(
|
|
4685
|
+
'Overriding an Opus "hold" needs a person in the Mushi console: open the fix, read the findings, and choose Merge anyway with a reason. The CLI signs in with an API key, which cannot override (ADR 0030).'
|
|
4686
|
+
);
|
|
4687
|
+
process.exit(1);
|
|
4688
|
+
}
|
|
4534
4689
|
const method = opts.method;
|
|
4535
4690
|
if (!["squash", "merge", "rebase"].includes(method)) {
|
|
4536
4691
|
console.error("--method must be squash, merge, or rebase");
|
|
@@ -4545,6 +4700,9 @@ Examples:
|
|
|
4545
4700
|
console.log(JSON.stringify({ ok: false, error: result.error }, null, 2));
|
|
4546
4701
|
} else {
|
|
4547
4702
|
console.error("Merge failed:", result.error.message);
|
|
4703
|
+
if (result.error.code === "REVIEW_HOLD") {
|
|
4704
|
+
console.error(`Read the review: mushi fixes review ${fixId} --show. To merge anyway, override the hold in the Mushi console (with a reason).`);
|
|
4705
|
+
}
|
|
4548
4706
|
}
|
|
4549
4707
|
process.exit(1);
|
|
4550
4708
|
}
|
|
@@ -4558,6 +4716,78 @@ Examples:
|
|
|
4558
4716
|
} else {
|
|
4559
4717
|
console.log(`Merged successfully. Report status: ${data.reportStatus ?? "fixed"}`);
|
|
4560
4718
|
}
|
|
4719
|
+
if (data.review) {
|
|
4720
|
+
console.log(`${data.review.label}${data.review.overridden ? " (overridden)" : ""}`);
|
|
4721
|
+
}
|
|
4722
|
+
});
|
|
4723
|
+
function printReview(review) {
|
|
4724
|
+
console.log(review.label);
|
|
4725
|
+
if (review.reason) console.log(review.reason);
|
|
4726
|
+
if (review.summary) console.log(`
|
|
4727
|
+
${review.summary}`);
|
|
4728
|
+
for (const f of review.findings) {
|
|
4729
|
+
const where = f.file === "(general)" ? "" : ` ${f.file}${f.line ? `:${f.line}` : ""}`;
|
|
4730
|
+
console.log(` - ${f.severity}${where}: ${f.issue} Fix: ${f.suggested_fix}`);
|
|
4731
|
+
}
|
|
4732
|
+
for (const r of review.removesBehavior) console.log(` - removes: ${r}`);
|
|
4733
|
+
if (review.needsHumanDecision) console.log(`Needs your decision: ${review.needsHumanDecision}`);
|
|
4734
|
+
if (review.commentUrl) console.log(`On GitHub: ${review.commentUrl}`);
|
|
4735
|
+
}
|
|
4736
|
+
fixes.command("review <fixId>").description("Re-run the Opus review of a fix PR (ADR 0030), or show the current one with --show").option("--show", "Show the current review instead of starting a new one").option("--json", "Machine-readable JSON output").action(async (fixId, opts) => {
|
|
4737
|
+
const config = loadConfig();
|
|
4738
|
+
if (!config.apiKey) {
|
|
4739
|
+
console.error("Run `mushi login` first");
|
|
4740
|
+
process.exit(1);
|
|
4741
|
+
}
|
|
4742
|
+
if (!config.endpoint) {
|
|
4743
|
+
console.error("No endpoint configured. Run `mushi init`");
|
|
4744
|
+
process.exit(1);
|
|
4745
|
+
}
|
|
4746
|
+
const result = opts.show ? await apiCall(`/v1/admin/fixes/${fixId}/timeline`, config) : await apiCall(`/v1/admin/fixes/${fixId}/review`, config, { method: "POST" });
|
|
4747
|
+
if (!result.ok) {
|
|
4748
|
+
if (opts.json) console.log(JSON.stringify({ ok: false, error: result.error }, null, 2));
|
|
4749
|
+
else console.error(opts.show ? "Could not read the review:" : "Review not started:", result.error.message);
|
|
4750
|
+
process.exit(1);
|
|
4751
|
+
}
|
|
4752
|
+
const review = result.data?.review;
|
|
4753
|
+
if (opts.json) {
|
|
4754
|
+
console.log(JSON.stringify({ ok: true, review: review ?? null }, null, 2));
|
|
4755
|
+
return;
|
|
4756
|
+
}
|
|
4757
|
+
if (!opts.show) console.log("Review queued. It takes a few minutes; check with --show.");
|
|
4758
|
+
if (review) printReview(review);
|
|
4759
|
+
});
|
|
4760
|
+
fixes.command("verify <fixId>").description("Show the verified-merge gates for a fix PR's current head (ADR 0035), or re-check them with --rerun").option("--rerun", "Re-check the gates now, then show them").option("--json", "Machine-readable JSON output").addHelpText("after", `
|
|
4761
|
+
Gates: Opus review, CI, a test that fails without the fix, no tests weakened,
|
|
4762
|
+
a small change, and a second review. Mushi merges on its own only when every
|
|
4763
|
+
gate passes and Verified merge is on for the project.
|
|
4764
|
+
Docs: https://kensaur.us/mushi-mushi/docs/admin/verified-merge`).action(async (fixId, opts) => {
|
|
4765
|
+
requireUuid(fixId, "fixId");
|
|
4766
|
+
const config = requireConfig();
|
|
4767
|
+
const json2 = outputIsJson(opts.json);
|
|
4768
|
+
if (opts.rerun) {
|
|
4769
|
+
const started = await apiCall(`/v1/admin/fixes/${fixId}/verify`, config, { method: "POST" });
|
|
4770
|
+
if (!started.ok) {
|
|
4771
|
+
if (json2) console.log(JSON.stringify({ ok: false, error: started.error }, null, 2));
|
|
4772
|
+
else console.error("Re-check not started:", started.error.message);
|
|
4773
|
+
process.exit(1);
|
|
4774
|
+
}
|
|
4775
|
+
}
|
|
4776
|
+
const result = await apiCall(`/v1/admin/fixes/${fixId}/verification`, config);
|
|
4777
|
+
if (!result.ok) {
|
|
4778
|
+
if (json2) console.log(JSON.stringify({ ok: false, error: result.error }, null, 2));
|
|
4779
|
+
else console.error("Could not read the verification:", result.error.message);
|
|
4780
|
+
process.exit(1);
|
|
4781
|
+
}
|
|
4782
|
+
if (json2) {
|
|
4783
|
+
console.log(JSON.stringify({ ok: true, verification: result.data ?? null }, null, 2));
|
|
4784
|
+
return;
|
|
4785
|
+
}
|
|
4786
|
+
if (!result.data) {
|
|
4787
|
+
console.log("No verification for this fix yet. Mushi checks open agent PRs every 10 minutes after their review.");
|
|
4788
|
+
return;
|
|
4789
|
+
}
|
|
4790
|
+
for (const line of renderVerification(result.data)) console.log(line);
|
|
4561
4791
|
});
|
|
4562
4792
|
fixes.command("refresh-ci <fixId>").description("Pull latest GitHub Actions check-run status for a fix attempt (on-demand ci-sync)").option("--json", "Machine-readable JSON output").action(async (fixId, opts) => {
|
|
4563
4793
|
const config = loadConfig();
|
|
@@ -4588,6 +4818,33 @@ Examples:
|
|
|
4588
4818
|
const updated = data.check_run_updated_at ? new Date(data.check_run_updated_at).toISOString() : "\u2014";
|
|
4589
4819
|
console.log(`CI status: ${status} \xB7 conclusion: ${conclusion} \xB7 updated: ${updated}`);
|
|
4590
4820
|
});
|
|
4821
|
+
fixes.command("rerun-ci <fixId>").description("Re-run the failed or cancelled CI runs on a fix PR's latest commit (for example after the test machine stopped)").option("--json", "Machine-readable JSON output").action(async (fixId, opts) => {
|
|
4822
|
+
const config = loadConfig();
|
|
4823
|
+
if (!config.apiKey) {
|
|
4824
|
+
console.error("Run `mushi login` first");
|
|
4825
|
+
process.exit(1);
|
|
4826
|
+
}
|
|
4827
|
+
if (!config.endpoint) {
|
|
4828
|
+
console.error("No endpoint configured. Run `mushi init`");
|
|
4829
|
+
process.exit(1);
|
|
4830
|
+
}
|
|
4831
|
+
const result = await apiCall(
|
|
4832
|
+
`/v1/admin/fixes/${fixId}/ci/rerun`,
|
|
4833
|
+
config,
|
|
4834
|
+
{ method: "POST" }
|
|
4835
|
+
);
|
|
4836
|
+
if (!result.ok) {
|
|
4837
|
+
if (opts.json) console.log(JSON.stringify({ ok: false, error: result.error }, null, 2));
|
|
4838
|
+
else console.error("Re-run failed:", result.error.message);
|
|
4839
|
+
process.exit(1);
|
|
4840
|
+
}
|
|
4841
|
+
const runs = result.data?.rerun ?? [];
|
|
4842
|
+
if (opts.json) {
|
|
4843
|
+
console.log(JSON.stringify({ ok: true, ...result.data }, null, 2));
|
|
4844
|
+
return;
|
|
4845
|
+
}
|
|
4846
|
+
console.log(`Re-ran ${runs.length} run${runs.length === 1 ? "" : "s"}: ${runs.map((r) => `#${r.runId}`).join(", ")}`);
|
|
4847
|
+
});
|
|
4591
4848
|
}
|
|
4592
4849
|
|
|
4593
4850
|
// src/nudge.ts
|
|
@@ -4640,24 +4897,24 @@ var PRESETS = {
|
|
|
4640
4897
|
}
|
|
4641
4898
|
};
|
|
4642
4899
|
function renderNudgeSnippet(opts) {
|
|
4643
|
-
const
|
|
4644
|
-
const dwellMs =
|
|
4645
|
-
const firstMs =
|
|
4900
|
+
const p4 = { ...PRESETS[opts.phase], ...opts.overrides };
|
|
4901
|
+
const dwellMs = p4.pageDwellMinutes > 0 ? `${p4.pageDwellMinutes} * 60 * 1000` : null;
|
|
4902
|
+
const firstMs = p4.firstSessionSeconds > 0 ? `${p4.firstSessionSeconds} * 1000` : null;
|
|
4646
4903
|
const proactiveLines = [];
|
|
4647
|
-
if (
|
|
4648
|
-
if (
|
|
4649
|
-
if (
|
|
4650
|
-
if (
|
|
4651
|
-
if (dwellMs) proactiveLines.push(` pageDwell: { thresholdMs: ${dwellMs} }, // ${
|
|
4652
|
-
if (firstMs) proactiveLines.push(` firstSession: { delayMs: ${firstMs} }, // welcome new users after ${
|
|
4904
|
+
if (p4.rageClick) proactiveLines.push(` rageClick: true,`);
|
|
4905
|
+
if (p4.longTask) proactiveLines.push(` longTask: true,`);
|
|
4906
|
+
if (p4.apiCascade) proactiveLines.push(` apiCascade: true,`);
|
|
4907
|
+
if (p4.errorBoundary) proactiveLines.push(` errorBoundary: true,`);
|
|
4908
|
+
if (dwellMs) proactiveLines.push(` pageDwell: { thresholdMs: ${dwellMs} }, // ${p4.pageDwellMinutes}min on the same route`);
|
|
4909
|
+
if (firstMs) proactiveLines.push(` firstSession: { delayMs: ${firstMs} }, // welcome new users after ${p4.firstSessionSeconds}s`);
|
|
4653
4910
|
proactiveLines.push(` cooldown: {`);
|
|
4654
|
-
proactiveLines.push(` maxProactivePerSession: ${
|
|
4655
|
-
proactiveLines.push(` dismissCooldownHours: ${
|
|
4656
|
-
proactiveLines.push(` suppressAfterDismissals: ${
|
|
4911
|
+
proactiveLines.push(` maxProactivePerSession: ${p4.maxProactivePerSession},`);
|
|
4912
|
+
proactiveLines.push(` dismissCooldownHours: ${p4.dismissCooldownHours},`);
|
|
4913
|
+
proactiveLines.push(` suppressAfterDismissals: ${p4.suppressAfterDismissals},`);
|
|
4657
4914
|
proactiveLines.push(` },`);
|
|
4658
4915
|
const widgetLines = [];
|
|
4659
|
-
if (
|
|
4660
|
-
if (
|
|
4916
|
+
if (p4.featureRequestCard) widgetLines.push(` featureRequestCard: true,`);
|
|
4917
|
+
if (p4.betaMode) {
|
|
4661
4918
|
widgetLines.push(` betaMode: {`);
|
|
4662
4919
|
widgetLines.push(` enabled: true,`);
|
|
4663
4920
|
widgetLines.push(` label: 'BETA',`);
|
|
@@ -4679,23 +4936,23 @@ function renderNudgeSnippet(opts) {
|
|
|
4679
4936
|
].join("\n");
|
|
4680
4937
|
}
|
|
4681
4938
|
function renderNudgeExplainer(phase) {
|
|
4682
|
-
const
|
|
4939
|
+
const p4 = PRESETS[phase];
|
|
4683
4940
|
const lines = [];
|
|
4684
4941
|
lines.push(``);
|
|
4685
4942
|
lines.push(`Nudge preset for "${phase}" phase:`);
|
|
4686
|
-
lines.push(` - max ${
|
|
4687
|
-
lines.push(` - ${
|
|
4688
|
-
lines.push(` - suppress permanently after ${
|
|
4689
|
-
if (
|
|
4690
|
-
if (
|
|
4943
|
+
lines.push(` - max ${p4.maxProactivePerSession} proactive prompts per session`);
|
|
4944
|
+
lines.push(` - ${p4.dismissCooldownHours}h cooldown after a dismissal`);
|
|
4945
|
+
lines.push(` - suppress permanently after ${p4.suppressAfterDismissals} consecutive dismissals`);
|
|
4946
|
+
if (p4.pageDwellMinutes > 0) lines.push(` - fire page-dwell trigger after ${p4.pageDwellMinutes} continuous minutes on a route`);
|
|
4947
|
+
if (p4.firstSessionSeconds > 0) lines.push(` - welcome new users with a button pulse ${p4.firstSessionSeconds}s after init`);
|
|
4691
4948
|
lines.push(` - signals enabled: ${[
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4949
|
+
p4.rageClick && "rage-click",
|
|
4950
|
+
p4.longTask && "long-task",
|
|
4951
|
+
p4.apiCascade && "api-cascade",
|
|
4952
|
+
p4.errorBoundary && "error-boundary"
|
|
4696
4953
|
].filter(Boolean).join(", ")}`);
|
|
4697
|
-
lines.push(` - feature-request card: ${
|
|
4698
|
-
lines.push(` - beta-mode UI: ${
|
|
4954
|
+
lines.push(` - feature-request card: ${p4.featureRequestCard ? "shown" : "hidden"}`);
|
|
4955
|
+
lines.push(` - beta-mode UI: ${p4.betaMode ? "on" : "off"}`);
|
|
4699
4956
|
lines.push(``);
|
|
4700
4957
|
return lines.join("\n");
|
|
4701
4958
|
}
|
|
@@ -4750,14 +5007,14 @@ async function planUpgrade(cwd) {
|
|
|
4750
5007
|
const root = resolve2(cwd);
|
|
4751
5008
|
const pkg = readPackageJson(root);
|
|
4752
5009
|
const pm = detectPackageManager(root);
|
|
4753
|
-
const
|
|
5010
|
+
const deps2 = {
|
|
4754
5011
|
...pkg?.dependencies ?? {},
|
|
4755
5012
|
...pkg?.devDependencies ?? {}
|
|
4756
5013
|
};
|
|
4757
|
-
const installed = MUSHI_PACKAGES.filter((name) =>
|
|
5014
|
+
const installed = MUSHI_PACKAGES.filter((name) => deps2[name]);
|
|
4758
5015
|
const entries = await Promise.all(
|
|
4759
5016
|
installed.map(async (name) => {
|
|
4760
|
-
const current =
|
|
5017
|
+
const current = deps2[name] ?? "";
|
|
4761
5018
|
const currentCore = current.replace(/^[\^~>=<]*/, "");
|
|
4762
5019
|
if (!/^\d/.test(currentCore)) {
|
|
4763
5020
|
return { name, current, latest: null, willUpgrade: false };
|
|
@@ -5343,7 +5600,7 @@ function spendDownStatusLine(k) {
|
|
|
5343
5600
|
const name = `${k.label ?? "(no label)"} ${k.keyHint ?? ""}`.trim();
|
|
5344
5601
|
const expires = k.creditsExpireAt ? k.creditsExpireAt.slice(0, 10) : "not set";
|
|
5345
5602
|
const model = k.model ?? "app default";
|
|
5346
|
-
const apps = k.projects.length > 0 ? k.projects.map((
|
|
5603
|
+
const apps = k.projects.length > 0 ? k.projects.map((p4) => p4.name).join(", ") : "none";
|
|
5347
5604
|
return `${name} [${k.scope}] ${k.state} \xB7 ${k.dispatchedToday}/${k.dailyLimit} today \xB7 credits expire ${expires} \xB7 model ${model} \xB7 pays for ${apps} \u2014 ${k.id}`;
|
|
5348
5605
|
}
|
|
5349
5606
|
|
|
@@ -5376,8 +5633,9 @@ function registerKeysCommands(program2) {
|
|
|
5376
5633
|
const lastUsed = k.last_used_at ? ` used=${new Date(k.last_used_at).toISOString()}` : "";
|
|
5377
5634
|
const baseUrl = k.base_url ? ` base=${k.base_url}` : "";
|
|
5378
5635
|
const hint = k.key_hint ? ` hint=${k.key_hint}` : "";
|
|
5636
|
+
const scope = ` scope=${k.scope ?? "project"}`;
|
|
5379
5637
|
console.log(
|
|
5380
|
-
`${k.provider_slug.padEnd(14)} [${k.status}]${validation} p=${k.priority} ${k.label ?? "(no label)"}${hint}${baseUrl}${cooldown}${lastTested}${lastUsed} created=${new Date(k.created_at).toISOString()} \u2014 ${k.id}`
|
|
5638
|
+
`${k.provider_slug.padEnd(14)} [${k.status}]${validation} p=${k.priority}${scope} ${k.label ?? "(no label)"}${hint}${baseUrl}${cooldown}${lastTested}${lastUsed} created=${new Date(k.created_at).toISOString()} \u2014 ${k.id}`
|
|
5381
5639
|
);
|
|
5382
5640
|
}
|
|
5383
5641
|
for (const k of legacyKeys) {
|
|
@@ -5459,7 +5717,9 @@ function registerKeysCommands(program2) {
|
|
|
5459
5717
|
}
|
|
5460
5718
|
);
|
|
5461
5719
|
});
|
|
5462
|
-
keys.command("add").description(
|
|
5720
|
+
keys.command("add").description(
|
|
5721
|
+
"Add a new API key to this app (keys for every app in the organization, or every app you own, are added in the console)"
|
|
5722
|
+
).requiredOption("--provider <p>", "Provider: anthropic, openai, openrouter, firecrawl, browserbase, cursor, supabase").option(
|
|
5463
5723
|
"--key <k>",
|
|
5464
5724
|
"The API key value (prefer the MUSHI_BYOK_KEY env var to keep it out of shell history)"
|
|
5465
5725
|
).option("--label <l>", "Human-readable label").option("--priority <n>", "Priority (lower = higher priority)", "100").option("--base-url <url>", "Allow-listed OpenAI-compatible HTTPS base URL").action(
|
|
@@ -5872,54 +6132,6 @@ Recent runs for story ${storyId.slice(0, 8)}\u2026:
|
|
|
5872
6132
|
});
|
|
5873
6133
|
}
|
|
5874
6134
|
|
|
5875
|
-
// src/command-helpers.ts
|
|
5876
|
-
function orgSegment(org) {
|
|
5877
|
-
if (!org || org === "current") return "current";
|
|
5878
|
-
return encodeURIComponent(requireUuid(org, "organization id"));
|
|
5879
|
-
}
|
|
5880
|
-
function dieOrgError(result) {
|
|
5881
|
-
const { code } = result.error;
|
|
5882
|
-
if (code === "ORG_REQUIRED") {
|
|
5883
|
-
const orgs = Array.isArray(result.error["organizations"]) ? result.error["organizations"] : [];
|
|
5884
|
-
if (orgs.length > 0) {
|
|
5885
|
-
process.stderr.write("\n Pick one and pass it with --org:\n");
|
|
5886
|
-
for (const o of orgs) {
|
|
5887
|
-
process.stderr.write(` --org ${String(o.id)} ${typeof o.name === "string" ? o.name : ""}
|
|
5888
|
-
`);
|
|
5889
|
-
}
|
|
5890
|
-
process.stderr.write("\n");
|
|
5891
|
-
}
|
|
5892
|
-
} else if (code.endsWith("_NEEDS_ACCOUNT_KEY")) {
|
|
5893
|
-
process.stderr.write(
|
|
5894
|
-
"\n Team-wide views need an account-level key, not a key bound to one project.\n Mint one in the console (Connect \u2192 MCP \u2192 account key), then:\n\n MUSHI_API_KEY=<account key> mushi <command>\n\n"
|
|
5895
|
-
);
|
|
5896
|
-
}
|
|
5897
|
-
die(result);
|
|
5898
|
-
}
|
|
5899
|
-
function requireYes(yes, action) {
|
|
5900
|
-
if (yes) return;
|
|
5901
|
-
throw new MushiCliError(
|
|
5902
|
-
"E_INVALID_INPUT",
|
|
5903
|
-
`${action} Re-run with --yes to go ahead.`,
|
|
5904
|
-
"nothing was sent"
|
|
5905
|
-
);
|
|
5906
|
-
}
|
|
5907
|
-
function resolveProjectId(flag, configured) {
|
|
5908
|
-
const raw = flag ?? configured;
|
|
5909
|
-
if (!raw) {
|
|
5910
|
-
throw new MushiCliError(
|
|
5911
|
-
"E_PROJECT_MISSING",
|
|
5912
|
-
"Project ID not configured.",
|
|
5913
|
-
"pass --project-id <uuid> or run `mushi login --project-id <uuid>`"
|
|
5914
|
-
);
|
|
5915
|
-
}
|
|
5916
|
-
return requireUuid(raw, "project id");
|
|
5917
|
-
}
|
|
5918
|
-
function oneLine(text2, max = 80) {
|
|
5919
|
-
const flat = String(text2 ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
|
|
5920
|
-
return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
|
|
5921
|
-
}
|
|
5922
|
-
|
|
5923
6135
|
// src/commands/audit.ts
|
|
5924
6136
|
var KNOWN_GATES = [
|
|
5925
6137
|
"dead_handler",
|
|
@@ -7011,10 +7223,10 @@ var REPO_SCAN_PATHS = [
|
|
|
7011
7223
|
".github/workflows/"
|
|
7012
7224
|
];
|
|
7013
7225
|
function isRepoScanPath(path) {
|
|
7014
|
-
const
|
|
7015
|
-
if (REPO_SCAN_PATHS.includes(
|
|
7016
|
-
if (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(
|
|
7017
|
-
return /^ios\/(?:[^/]+\/)?[^/]+\.xcodeproj\/project\.pbxproj$/.test(
|
|
7226
|
+
const p4 = normalizePath(path);
|
|
7227
|
+
if (REPO_SCAN_PATHS.includes(p4)) return true;
|
|
7228
|
+
if (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(p4)) return true;
|
|
7229
|
+
return /^ios\/(?:[^/]+\/)?[^/]+\.xcodeproj\/project\.pbxproj$/.test(p4);
|
|
7018
7230
|
}
|
|
7019
7231
|
function normalizePath(path) {
|
|
7020
7232
|
return path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
@@ -7274,7 +7486,7 @@ function bundleRootsFromFlags(root, dirs) {
|
|
|
7274
7486
|
}
|
|
7275
7487
|
return [...new Set(roots)].sort();
|
|
7276
7488
|
}
|
|
7277
|
-
function scanBuiltBundles(root, readFile3 = (
|
|
7489
|
+
function scanBuiltBundles(root, readFile3 = (p4) => readFileSync3(p4, "utf8"), bundleDirs) {
|
|
7278
7490
|
const roots = bundleDirs && bundleDirs.length > 0 ? bundleRootsFromFlags(root, bundleDirs) : findBundleRoots(root);
|
|
7279
7491
|
const out = { roots, scannedFiles: 0, truncated: false, unreadable: 0, findings: [] };
|
|
7280
7492
|
const walk2 = (dir) => {
|
|
@@ -7319,7 +7531,7 @@ function scanBuiltBundles(root, readFile3 = (p3) => readFileSync3(p3, "utf8"), b
|
|
|
7319
7531
|
for (const r of roots) walk2(join5(root, r));
|
|
7320
7532
|
return out;
|
|
7321
7533
|
}
|
|
7322
|
-
function scanLocalRepo(root, readFile3 = (
|
|
7534
|
+
function scanLocalRepo(root, readFile3 = (p4) => readFileSync3(p4, "utf8"), opts = {}) {
|
|
7323
7535
|
const { files, truncated, unreadable: unreadableDirs } = listRepoFiles(root);
|
|
7324
7536
|
let unreadable = unreadableDirs;
|
|
7325
7537
|
const findings = [];
|
|
@@ -7509,8 +7721,8 @@ function toHex(c) {
|
|
|
7509
7721
|
function num(token, scale) {
|
|
7510
7722
|
const t = token.trim();
|
|
7511
7723
|
if (t === "none") return 0;
|
|
7512
|
-
const
|
|
7513
|
-
if (
|
|
7724
|
+
const pct2 = /^(-?[\d.]+)%$/.exec(t);
|
|
7725
|
+
if (pct2) return Number(pct2[1]) / 100 * scale;
|
|
7514
7726
|
const deg = /^(-?[\d.]+)(deg|turn|rad|grad)?$/.exec(t);
|
|
7515
7727
|
if (!deg) return null;
|
|
7516
7728
|
const v = Number(deg[1]);
|
|
@@ -7529,7 +7741,7 @@ function num(token, scale) {
|
|
|
7529
7741
|
function splitArgs(body) {
|
|
7530
7742
|
const [main, alpha] = body.split("/");
|
|
7531
7743
|
const parts = main.includes(",") ? main.split(",") : main.trim().split(/\s+/);
|
|
7532
|
-
const cleaned = parts.map((
|
|
7744
|
+
const cleaned = parts.map((p4) => p4.trim()).filter(Boolean);
|
|
7533
7745
|
if (alpha === void 0 && cleaned.length === 4) return { parts: cleaned.slice(0, 3), alpha: cleaned[3] };
|
|
7534
7746
|
return { parts: cleaned, alpha: alpha?.trim() ?? null };
|
|
7535
7747
|
}
|
|
@@ -7578,7 +7790,7 @@ function parseCssColor(input) {
|
|
|
7578
7790
|
if (parts.length !== 3) return null;
|
|
7579
7791
|
const a = alphaOf(alpha);
|
|
7580
7792
|
if (kind === "rgb" || kind === "rgba") {
|
|
7581
|
-
const ch = parts.map((
|
|
7793
|
+
const ch = parts.map((p4) => num(p4, 255));
|
|
7582
7794
|
if (ch.some((v) => v == null)) return null;
|
|
7583
7795
|
const [r2, g2, b2] = ch;
|
|
7584
7796
|
return { r: clamp01(r2 / 255), g: clamp01(g2 / 255), b: clamp01(b2 / 255), a };
|
|
@@ -7898,7 +8110,7 @@ function normalizeTokenSet(files) {
|
|
|
7898
8110
|
};
|
|
7899
8111
|
const norm = r ? normalizeByType(type, r.value, note2) : { value: null, display: alias ? `{${alias}} (unresolved)` : "unresolved", hex: null, px: null };
|
|
7900
8112
|
const ext = tok.ext;
|
|
7901
|
-
const
|
|
8113
|
+
const str2 = (k) => ext && typeof ext[k] === "string" ? ext[k] : null;
|
|
7902
8114
|
tokens.push({
|
|
7903
8115
|
path: tok.path,
|
|
7904
8116
|
type,
|
|
@@ -7907,9 +8119,9 @@ function normalizeTokenSet(files) {
|
|
|
7907
8119
|
hex: norm.hex,
|
|
7908
8120
|
px: norm.px,
|
|
7909
8121
|
aliasOf: alias,
|
|
7910
|
-
cssVar:
|
|
7911
|
-
ts:
|
|
7912
|
-
rn:
|
|
8122
|
+
cssVar: str2("cssVar"),
|
|
8123
|
+
ts: str2("ts"),
|
|
8124
|
+
rn: str2("rn"),
|
|
7913
8125
|
description: tok.description,
|
|
7914
8126
|
file: tok.file,
|
|
7915
8127
|
role: tok.role,
|
|
@@ -7936,13 +8148,13 @@ function declaredFontFamilies(tokens) {
|
|
|
7936
8148
|
// src/recipe/engine/recipe-glob.ts
|
|
7937
8149
|
function normalizeRepoPath(input) {
|
|
7938
8150
|
if (typeof input !== "string") return null;
|
|
7939
|
-
const
|
|
7940
|
-
if (
|
|
7941
|
-
if (
|
|
7942
|
-
if (
|
|
7943
|
-
if (/^[A-Za-z]:/.test(
|
|
7944
|
-
if (/[\u0000-\u001f\u007f]/.test(
|
|
7945
|
-
const segments =
|
|
8151
|
+
const p4 = input.trim();
|
|
8152
|
+
if (p4.length === 0 || p4.length > 512) return null;
|
|
8153
|
+
if (p4.startsWith("/") || p4.startsWith("~")) return null;
|
|
8154
|
+
if (p4.includes("\\") || p4.includes("%")) return null;
|
|
8155
|
+
if (/^[A-Za-z]:/.test(p4)) return null;
|
|
8156
|
+
if (/[\u0000-\u001f\u007f]/.test(p4)) return null;
|
|
8157
|
+
const segments = p4.split("/");
|
|
7946
8158
|
for (const seg of segments) {
|
|
7947
8159
|
if (seg === "" || seg === "." || seg === "..") return null;
|
|
7948
8160
|
}
|
|
@@ -8313,13 +8525,13 @@ function stripBraces(ref) {
|
|
|
8313
8525
|
}
|
|
8314
8526
|
function evaluateContrast(tokens, pairs) {
|
|
8315
8527
|
const byPath = new Map(tokens.map((t) => [t.path, t]));
|
|
8316
|
-
return pairs.map((
|
|
8317
|
-
const fgPath = stripBraces(
|
|
8318
|
-
const bgPath = stripBraces(
|
|
8319
|
-
const min =
|
|
8528
|
+
return pairs.map((p4) => {
|
|
8529
|
+
const fgPath = stripBraces(p4.fg);
|
|
8530
|
+
const bgPath = stripBraces(p4.bg);
|
|
8531
|
+
const min = p4.min ?? (p4.large ? 3 : 4.5);
|
|
8320
8532
|
const fg = byPath.get(fgPath);
|
|
8321
8533
|
const bg = byPath.get(bgPath);
|
|
8322
|
-
const base = { fg: fgPath, bg: bgPath, min, use:
|
|
8534
|
+
const base = { fg: fgPath, bg: bgPath, min, use: p4.use ?? null };
|
|
8323
8535
|
const fc = fg?.hex ? parseCssColor(fg.hex) : null;
|
|
8324
8536
|
const bc = bg?.hex ? parseCssColor(bg.hex) : null;
|
|
8325
8537
|
if (!fc || !bc) {
|
|
@@ -8346,7 +8558,7 @@ function contrastFindings(results, tokens, rule) {
|
|
|
8346
8558
|
}
|
|
8347
8559
|
function computeDevianceScore(input) {
|
|
8348
8560
|
const kloc = Math.max(1, input.scannedLines / 1e3);
|
|
8349
|
-
let
|
|
8561
|
+
let num3 = 0;
|
|
8350
8562
|
let den = 0;
|
|
8351
8563
|
const breakdown = input.rules.map((r) => {
|
|
8352
8564
|
const count = input.counts[r.id] ?? 0;
|
|
@@ -8368,12 +8580,12 @@ function computeDevianceScore(input) {
|
|
|
8368
8580
|
}
|
|
8369
8581
|
}
|
|
8370
8582
|
if (r.enabled && applicable) {
|
|
8371
|
-
|
|
8583
|
+
num3 += weight * penalty;
|
|
8372
8584
|
den += weight;
|
|
8373
8585
|
}
|
|
8374
8586
|
return { rule: r.id, enabled: r.enabled, applicable, severity: r.severity, weight, count, density, penalty: Math.round(penalty * 1e3) / 1e3 };
|
|
8375
8587
|
});
|
|
8376
|
-
return { score: den === 0 ? null : Math.round(100 *
|
|
8588
|
+
return { score: den === 0 ? null : Math.round(100 * num3 / den), breakdown };
|
|
8377
8589
|
}
|
|
8378
8590
|
var SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
|
|
8379
8591
|
function sortFindings(findings) {
|
|
@@ -8449,19 +8661,19 @@ function planTokenSets(declared, treePaths, directions = []) {
|
|
|
8449
8661
|
if (directions.length > 0) {
|
|
8450
8662
|
for (const d of directions) {
|
|
8451
8663
|
if (d.status !== "inactive" || d.name === activeName) continue;
|
|
8452
|
-
const files = d.tokens.map((
|
|
8664
|
+
const files = d.tokens.map((p4) => normalizeRepoPath(p4)).filter((p4) => p4 !== null);
|
|
8453
8665
|
sets.push({ name: d.name, kind: "direction", active: false, files: files.map((path) => ({ path, role: "source", generator: null })), note: d.note ?? null });
|
|
8454
8666
|
}
|
|
8455
8667
|
} else if (sameDirection) {
|
|
8456
8668
|
const base = dirs[0].base;
|
|
8457
8669
|
const siblings = /* @__PURE__ */ new Map();
|
|
8458
|
-
for (const
|
|
8459
|
-
if (!
|
|
8460
|
-
const d = directionOf(
|
|
8670
|
+
for (const p4 of treePaths) {
|
|
8671
|
+
if (!p4.startsWith(base)) continue;
|
|
8672
|
+
const d = directionOf(p4);
|
|
8461
8673
|
if (!d || d.base !== base || d.name === activeName) continue;
|
|
8462
8674
|
if (!/\.tokens\.json$|^tokens\.json$/.test(d.file)) continue;
|
|
8463
8675
|
const list = siblings.get(d.name) ?? [];
|
|
8464
|
-
list.push(
|
|
8676
|
+
list.push(p4);
|
|
8465
8677
|
siblings.set(d.name, list);
|
|
8466
8678
|
}
|
|
8467
8679
|
for (const name of [...siblings.keys()].sort()) {
|
|
@@ -8516,10 +8728,22 @@ var ALWAYS_IGNORE = [
|
|
|
8516
8728
|
"**/android/**",
|
|
8517
8729
|
"**/ios/**"
|
|
8518
8730
|
];
|
|
8731
|
+
var DEFAULT_TOKEN_DEFINITION_GLOBS = [
|
|
8732
|
+
"**/theme-tokens.css",
|
|
8733
|
+
"**/base-and-density.css",
|
|
8734
|
+
"**/vizTokens.ts",
|
|
8735
|
+
"**/tokens.ts",
|
|
8736
|
+
"**/tokens.css",
|
|
8737
|
+
"**/*.tokens.json"
|
|
8738
|
+
];
|
|
8739
|
+
function tokenDefinitionGlobs(manifest) {
|
|
8740
|
+
const declared = manifest?.design?.literalScan?.tokenDefinitions;
|
|
8741
|
+
return Array.isArray(declared) ? [...declared] : [...DEFAULT_TOKEN_DEFINITION_GLOBS];
|
|
8742
|
+
}
|
|
8519
8743
|
function selectScanFiles(entries, manifest, excludePaths, limits = SCAN_LIMITS) {
|
|
8520
8744
|
const declared = manifest?.design?.literalScan?.globs;
|
|
8521
8745
|
const globs = declared && declared.length > 0 ? [...declared] : DEFAULT_SCAN_GLOBS;
|
|
8522
|
-
const ignore = [...ALWAYS_IGNORE, ...manifest?.design?.literalScan?.ignore ?? []];
|
|
8746
|
+
const ignore = [...ALWAYS_IGNORE, ...tokenDefinitionGlobs(manifest), ...manifest?.design?.literalScan?.ignore ?? []];
|
|
8523
8747
|
const matched = entries.filter((e) => matchAny(e.path, globs) && !matchAny(e.path, ignore) && !excludePaths.includes(e.path) && e.size <= limits.maxFileBytes).sort((a, b) => a.path < b.path ? -1 : 1);
|
|
8524
8748
|
const files = [];
|
|
8525
8749
|
let bytes = 0;
|
|
@@ -8699,16 +8923,16 @@ function readContrast(raw, issues) {
|
|
|
8699
8923
|
return void 0;
|
|
8700
8924
|
}
|
|
8701
8925
|
const out = [];
|
|
8702
|
-
raw.forEach((
|
|
8703
|
-
if (!isObject(
|
|
8926
|
+
raw.forEach((p4, i) => {
|
|
8927
|
+
if (!isObject(p4) || !isString(p4.fg) || !isString(p4.bg) || !p4.fg || !p4.bg) {
|
|
8704
8928
|
issues.push({ severity: "error", message: `design.contrast[${i}] needs fg and bg token paths.` });
|
|
8705
8929
|
return;
|
|
8706
8930
|
}
|
|
8707
|
-
const pair = { fg:
|
|
8708
|
-
if (typeof
|
|
8709
|
-
else if (
|
|
8710
|
-
if (typeof
|
|
8711
|
-
if (isString(
|
|
8931
|
+
const pair = { fg: p4.fg, bg: p4.bg };
|
|
8932
|
+
if (typeof p4.min === "number" && p4.min >= 1 && p4.min <= 21) pair.min = p4.min;
|
|
8933
|
+
else if (p4.min !== void 0) issues.push({ severity: "error", message: `design.contrast[${i}].min must be a ratio from 1 to 21.` });
|
|
8934
|
+
if (typeof p4.large === "boolean") pair.large = p4.large;
|
|
8935
|
+
if (isString(p4.use)) pair.use = p4.use;
|
|
8712
8936
|
out.push(pair);
|
|
8713
8937
|
});
|
|
8714
8938
|
return out;
|
|
@@ -8756,10 +8980,14 @@ function readScanManifest(manifest) {
|
|
|
8756
8980
|
}
|
|
8757
8981
|
if (d.literalScan !== void 0) {
|
|
8758
8982
|
const ls = d.literalScan;
|
|
8759
|
-
if (!isObject(ls) || ls.globs !== void 0 && !isStringList(ls.globs) || ls.ignore !== void 0 && !isStringList(ls.ignore)) {
|
|
8760
|
-
issues.push({ severity: "error", message: "design.literalScan.globs and .
|
|
8983
|
+
if (!isObject(ls) || ls.globs !== void 0 && !isStringList(ls.globs) || ls.ignore !== void 0 && !isStringList(ls.ignore) || ls.tokenDefinitions !== void 0 && !isStringList(ls.tokenDefinitions)) {
|
|
8984
|
+
issues.push({ severity: "error", message: "design.literalScan.globs, .ignore and .tokenDefinitions must be lists of file globs." });
|
|
8761
8985
|
} else {
|
|
8762
|
-
design.literalScan = {
|
|
8986
|
+
design.literalScan = {
|
|
8987
|
+
...ls.globs ? { globs: ls.globs } : {},
|
|
8988
|
+
...ls.ignore ? { ignore: ls.ignore } : {},
|
|
8989
|
+
...ls.tokenDefinitions ? { tokenDefinitions: ls.tokenDefinitions } : {}
|
|
8990
|
+
};
|
|
8763
8991
|
}
|
|
8764
8992
|
}
|
|
8765
8993
|
const contrast = readContrast(d.contrast, issues);
|
|
@@ -8807,10 +9035,10 @@ function listRepoFiles2(root) {
|
|
|
8807
9035
|
paths = walk(root);
|
|
8808
9036
|
}
|
|
8809
9037
|
const entries = [];
|
|
8810
|
-
for (const
|
|
9038
|
+
for (const p4 of paths) {
|
|
8811
9039
|
try {
|
|
8812
|
-
const st = statSync3(join6(root,
|
|
8813
|
-
if (st.isFile()) entries.push({ path:
|
|
9040
|
+
const st = statSync3(join6(root, p4));
|
|
9041
|
+
if (st.isFile()) entries.push({ path: p4, size: st.size });
|
|
8814
9042
|
} catch {
|
|
8815
9043
|
}
|
|
8816
9044
|
}
|
|
@@ -8862,7 +9090,7 @@ function workspacePatterns(root, pkg) {
|
|
|
8862
9090
|
const out = [];
|
|
8863
9091
|
const ws = pkg?.workspaces;
|
|
8864
9092
|
const list = Array.isArray(ws) ? ws : isObject2(ws) && Array.isArray(ws.packages) ? ws.packages : [];
|
|
8865
|
-
for (const
|
|
9093
|
+
for (const p4 of list) if (typeof p4 === "string") out.push(p4);
|
|
8866
9094
|
const yaml = readCapped(root, "pnpm-workspace.yaml");
|
|
8867
9095
|
if (yaml) {
|
|
8868
9096
|
let inPackages = false;
|
|
@@ -8876,7 +9104,7 @@ function workspacePatterns(root, pkg) {
|
|
|
8876
9104
|
if (item) out.push(item[1]);
|
|
8877
9105
|
}
|
|
8878
9106
|
}
|
|
8879
|
-
return out.map((
|
|
9107
|
+
return out.map((p4) => p4.trim().replace(/^\.\//, "").replace(/\/+$/, "")).filter(Boolean);
|
|
8880
9108
|
}
|
|
8881
9109
|
function scriptRunner(files) {
|
|
8882
9110
|
if (files.has("pnpm-lock.yaml")) return "pnpm";
|
|
@@ -8919,25 +9147,25 @@ function starterManifest(root) {
|
|
|
8919
9147
|
const files = tracked.length ? tracked : walk(root);
|
|
8920
9148
|
const fileSet = new Set(files);
|
|
8921
9149
|
const pkg = readJson(join6(root, "package.json"));
|
|
8922
|
-
const
|
|
9150
|
+
const deps2 = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
|
|
8923
9151
|
const rootScripts = Object.entries(pkg?.scripts ?? {}).filter((e) => typeof e[1] === "string");
|
|
8924
|
-
const native = files.some((f) => /^capacitor\.config\.(ts|js|json)$/.test(f)) || "expo" in
|
|
8925
|
-
const platforms = ["web", ...files.some((f) => f.startsWith("android/")) || "expo" in
|
|
9152
|
+
const native = files.some((f) => /^capacitor\.config\.(ts|js|json)$/.test(f)) || "expo" in deps2 || "react-native" in deps2 || files.some((f) => f.startsWith("android/") || f.startsWith("ios/"));
|
|
9153
|
+
const platforms = ["web", ...files.some((f) => f.startsWith("android/")) || "expo" in deps2 ? ["android"] : [], ...files.some((f) => f.startsWith("ios/")) || "expo" in deps2 ? ["ios"] : []];
|
|
8926
9154
|
const archived = files.filter((f) => f.endsWith("/ARCHIVED.md")).map((f) => f.slice(0, -"/ARCHIVED.md".length));
|
|
8927
9155
|
const isArchived = (f) => archived.some((d) => f.startsWith(`${d}/`));
|
|
8928
9156
|
const live = (f) => !FIXTURE_DIR.test(f) && !isArchived(f);
|
|
8929
9157
|
const tokenPaths = files.filter((f) => live(f) && (/\.tokens\.json$/.test(f) || /(^|\/)dtcg\/[^/]+\.json$/.test(f))).slice(0, 20);
|
|
8930
9158
|
const workflows = files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f));
|
|
8931
9159
|
const hasSource = (dir, ext) => files.some((f) => f.startsWith(`${dir}/`) && live(f) && ext.test(f));
|
|
8932
|
-
const rootIsApp = files.some((f) => /^(next|vite)\.config\.(js|mjs|cjs|ts|mts)$/.test(f)) || "expo" in
|
|
9160
|
+
const rootIsApp = files.some((f) => /^(next|vite)\.config\.(js|mjs|cjs|ts|mts)$/.test(f)) || "expo" in deps2 || rootScripts.some(([name]) => name === "dev" || name === "build");
|
|
8933
9161
|
const rootDirs = rootIsApp ? ROOT_APP_DIRS.filter((d) => hasSource(d, /\.(ts|tsx|css)$/)) : [];
|
|
8934
9162
|
const patterns = workspacePatterns(root, pkg);
|
|
8935
9163
|
let packageRoots;
|
|
8936
9164
|
if (patterns.length) {
|
|
8937
|
-
const include = patterns.filter((
|
|
8938
|
-
const exclude = patterns.filter((
|
|
9165
|
+
const include = patterns.filter((p4) => !p4.startsWith("!"));
|
|
9166
|
+
const exclude = patterns.filter((p4) => p4.startsWith("!")).map((p4) => p4.slice(1));
|
|
8939
9167
|
const pkgDirs = [...new Set(files.filter((f) => f.endsWith("/package.json") && !f.includes("node_modules/")).map((f) => f.slice(0, -"/package.json".length)))];
|
|
8940
|
-
packageRoots = pkgDirs.filter((d) => !isArchived(`${d}/`) && include.some((
|
|
9168
|
+
packageRoots = pkgDirs.filter((d) => !isArchived(`${d}/`) && include.some((p4) => matchGlob(d, p4)) && !exclude.some((p4) => matchGlob(d, p4))).sort().flatMap((d) => hasSource(`${d}/src`, /\.(tsx|css)$/) ? [`${d}/src`] : hasSource(d, /\.(tsx|css)$/) ? [d] : []);
|
|
8941
9169
|
} else {
|
|
8942
9170
|
packageRoots = [...new Set(files.filter((f) => live(f) && /^(apps|packages)\/[^/]+\/src\/.+\.(tsx|css)$/.test(f)).map((f) => f.split("/").slice(0, 3).join("/")))];
|
|
8943
9171
|
}
|
|
@@ -8972,7 +9200,7 @@ function starterManifest(root) {
|
|
|
8972
9200
|
}))
|
|
8973
9201
|
};
|
|
8974
9202
|
}
|
|
8975
|
-
const envExample = ENV_EXAMPLES.find((
|
|
9203
|
+
const envExample = ENV_EXAMPLES.find((p4) => existsSync3(join6(root, p4)));
|
|
8976
9204
|
if (envExample) manifest.env = { environments: ["production"], required: [], example: envExample };
|
|
8977
9205
|
const sourceDirs = tokens.filter((t) => t.role === "source").map((t) => `${t.path.split("/").slice(0, -1).join("/") || "."}/**`);
|
|
8978
9206
|
manifest.change = { allowPaths: [MANIFEST, ...new Set(sourceDirs)] };
|
|
@@ -9010,11 +9238,11 @@ function checkRecipe(root) {
|
|
|
9010
9238
|
const manifest = parsed;
|
|
9011
9239
|
files[MANIFEST] = text2;
|
|
9012
9240
|
const change = isObject2(manifest.change) ? manifest.change : {};
|
|
9013
|
-
for (const
|
|
9014
|
-
if (
|
|
9015
|
-
issues.push({ severity: "error", message: `change.allowPaths entry "${
|
|
9241
|
+
for (const p4 of asStringList(change.allowPaths)) {
|
|
9242
|
+
if (p4.startsWith(".github") || /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|[^/]*\.lock)$/.test(p4)) {
|
|
9243
|
+
issues.push({ severity: "error", message: `change.allowPaths entry "${p4}" points at files Mushi never writes (workflows, lockfiles).`, path: MANIFEST });
|
|
9016
9244
|
}
|
|
9017
|
-
if (/(^|\/)\.env(?!\.example)/.test(
|
|
9245
|
+
if (/(^|\/)\.env(?!\.example)/.test(p4)) issues.push({ severity: "error", message: `change.allowPaths entry "${p4}" would let a PR touch env files.`, path: MANIFEST });
|
|
9018
9246
|
}
|
|
9019
9247
|
const shaped = readScanManifest(manifest);
|
|
9020
9248
|
issues.push(...shaped.issues);
|
|
@@ -9029,14 +9257,14 @@ function checkRecipe(root) {
|
|
|
9029
9257
|
const judged = judgingSet(tokens);
|
|
9030
9258
|
const tokenCount = judged?.tokens.length ?? 0;
|
|
9031
9259
|
let pushedBytes = text2.length;
|
|
9032
|
-
const addPush = (
|
|
9033
|
-
if (files[
|
|
9034
|
-
const f = read(
|
|
9260
|
+
const addPush = (p4) => {
|
|
9261
|
+
if (files[p4] !== void 0 || Object.keys(files).length >= PUSH_MAX_FILES) return;
|
|
9262
|
+
const f = read(p4, PUSH_MAX_FILE_BYTES);
|
|
9035
9263
|
if (f.kind !== "file" || pushedBytes + f.text.length > PUSH_MAX_TOTAL_BYTES) return;
|
|
9036
|
-
files[
|
|
9264
|
+
files[p4] = f.text;
|
|
9037
9265
|
pushedBytes += f.text.length;
|
|
9038
9266
|
};
|
|
9039
|
-
for (const
|
|
9267
|
+
for (const p4 of tokenFilePaths(tokens)) addPush(p4);
|
|
9040
9268
|
const design = isObject2(manifest.design) ? manifest.design : {};
|
|
9041
9269
|
for (const c of Array.isArray(design.css) ? design.css : []) {
|
|
9042
9270
|
const rel = isObject2(c) && typeof c.path === "string" ? normalizeRepoPath(c.path) : null;
|
|
@@ -9047,9 +9275,9 @@ function checkRecipe(root) {
|
|
|
9047
9275
|
if (judged) {
|
|
9048
9276
|
const pick = selectScanFiles(tree, scanManifest, [...tokenFilePaths(tokens), MANIFEST]);
|
|
9049
9277
|
const texts = /* @__PURE__ */ new Map();
|
|
9050
|
-
for (const
|
|
9051
|
-
const f = read(
|
|
9052
|
-
texts.set(
|
|
9278
|
+
for (const p4 of pick.files) {
|
|
9279
|
+
const f = read(p4, Number.POSITIVE_INFINITY);
|
|
9280
|
+
texts.set(p4, f.kind === "file" ? f.text : null);
|
|
9053
9281
|
}
|
|
9054
9282
|
const result = computeDeviance({ manifest: scanManifest, tokens }, texts);
|
|
9055
9283
|
findings = result.findings;
|
|
@@ -9623,7 +9851,7 @@ function registerPortfolioCommands(program2) {
|
|
|
9623
9851
|
groups.command("set <groupId> [projectIds...]").description("Set exactly which apps are in a group (team owners and admins); no ids empties it").option(ORG_HELP, ORG_DESC).action(async (groupId, projectIds, opts) => {
|
|
9624
9852
|
const config = requireConfig();
|
|
9625
9853
|
const gid = requireUuid(groupId, "group id");
|
|
9626
|
-
const ids = (projectIds ?? []).map((
|
|
9854
|
+
const ids = (projectIds ?? []).map((p4) => requireUuid(p4, "project id"));
|
|
9627
9855
|
const result = await apiCall(
|
|
9628
9856
|
`/v1/admin/orgs/${orgSegment(opts.org)}/project-groups/${gid}/projects`,
|
|
9629
9857
|
config,
|
|
@@ -10425,16 +10653,16 @@ function registerRepoCommands(program2) {
|
|
|
10425
10653
|
const projectId2 = resolveProjectId(opts.projectId, config.projectId);
|
|
10426
10654
|
const preview = await apiCall(`/v1/admin/projects/${projectId2}/codebase/diagram/publish-preview`, config);
|
|
10427
10655
|
if (!preview.ok) die(preview);
|
|
10428
|
-
const
|
|
10429
|
-
if (!
|
|
10430
|
-
throw new MushiCliError("E_INVALID_INPUT",
|
|
10656
|
+
const p4 = preview.data;
|
|
10657
|
+
if (!p4.can_publish) {
|
|
10658
|
+
throw new MushiCliError("E_INVALID_INPUT", p4.publish_blocked_reason ?? "This diagram cannot be published.", "publish it from the console instead");
|
|
10431
10659
|
}
|
|
10432
|
-
console.log(`Will publish to ${
|
|
10660
|
+
console.log(`Will publish to ${p4.url}${p4.repo_private ? " (the repo is private on GitHub)" : ""}.`);
|
|
10433
10661
|
requireYes(opts.yes, "Publishing makes the diagram public.");
|
|
10434
10662
|
const result = await apiCall(
|
|
10435
10663
|
`/v1/admin/projects/${projectId2}/codebase/diagram/publish`,
|
|
10436
10664
|
config,
|
|
10437
|
-
{ method: "POST", body: JSON.stringify({ diagram_id:
|
|
10665
|
+
{ method: "POST", body: JSON.stringify({ diagram_id: p4.diagram_id, payload_hash: p4.payload_hash }) },
|
|
10438
10666
|
{ timeoutMs: PUBLISH_TIMEOUT_MS2 }
|
|
10439
10667
|
);
|
|
10440
10668
|
if (!result.ok) die(result);
|
|
@@ -10653,6 +10881,15 @@ function parseOnOff(raw, flag) {
|
|
|
10653
10881
|
if (v === "off" || v === "false" || v === "no") return false;
|
|
10654
10882
|
throw new MushiCliError("E_INVALID_INPUT", `${flag} must be on or off`);
|
|
10655
10883
|
}
|
|
10884
|
+
function renderFindingsDispatch(r) {
|
|
10885
|
+
if (r.action === "no_scan") return ["No finished design scan yet. Run one first (mushi recipe check --push, or the console)."];
|
|
10886
|
+
if (r.action === "budget_reached") return [`Nothing sent: ${r.reason ?? "the app's auto-fix limits are reached for today"}`];
|
|
10887
|
+
const sent = r.sent ?? [];
|
|
10888
|
+
const lines = sent.length === 0 ? ["Nothing to send: every file with findings already has an open report."] : sent.map((s) => `${s.refused ? "\u2717" : "\u2713"} ${s.file}: ${s.findings} finding(s) \u2192 report ${s.reportId}${s.refused ? ` (not dispatched: ${s.refused.message ?? s.refused.code ?? "refused"})` : ""}`);
|
|
10889
|
+
if ((r.remainingFiles ?? 0) > 0) lines.push(`${r.remainingFiles} more file(s) left; run it again to send the next batch.`);
|
|
10890
|
+
if ((r.skipped?.tokenDefinition ?? 0) > 0) lines.push(`${r.skipped.tokenDefinition} finding(s) in token-definition files were left out.`);
|
|
10891
|
+
return lines;
|
|
10892
|
+
}
|
|
10656
10893
|
function renderSettings(s) {
|
|
10657
10894
|
const lines = [
|
|
10658
10895
|
`Threshold: ${s.threshold} (the actions below fire when the design score is above it)`,
|
|
@@ -10696,6 +10933,23 @@ function registerDesignCommands(program2) {
|
|
|
10696
10933
|
console.log("Saved.");
|
|
10697
10934
|
for (const line of renderSettings(result.data)) console.log(line);
|
|
10698
10935
|
});
|
|
10936
|
+
design.command("dispatch-findings").description("Send the latest scan's design findings to an agent: one report per file, a few files per call (owners and admins; auto-fix must be on)").option("--max-files <n>", "Files to send in this call, 1-20 (default 5)").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
|
|
10937
|
+
const body = {};
|
|
10938
|
+
if (opts.maxFiles !== void 0) {
|
|
10939
|
+
const n = Number(opts.maxFiles);
|
|
10940
|
+
if (!Number.isInteger(n) || n < 1 || n > 20) throw new MushiCliError("E_INVALID_INPUT", "--max-files must be a whole number from 1 to 20");
|
|
10941
|
+
body.maxFiles = n;
|
|
10942
|
+
}
|
|
10943
|
+
const config = requireConfig();
|
|
10944
|
+
const pid = resolveProjectId(opts.projectId, config.projectId);
|
|
10945
|
+
const result = await apiCall(`/v1/admin/projects/${pid}/design/dispatch-findings`, config, { method: "POST", body: JSON.stringify(body) });
|
|
10946
|
+
if (!result.ok) die(result);
|
|
10947
|
+
if (outputIsJson(opts.json)) {
|
|
10948
|
+
console.log(JSON.stringify(result.data, null, 2));
|
|
10949
|
+
return;
|
|
10950
|
+
}
|
|
10951
|
+
for (const line of renderFindingsDispatch(result.data)) console.log(line);
|
|
10952
|
+
});
|
|
10699
10953
|
}
|
|
10700
10954
|
|
|
10701
10955
|
// src/commands/ux.ts
|
|
@@ -10714,7 +10968,7 @@ function uxCommandLine(args, env = process.env, platform = process.platform, npx
|
|
|
10714
10968
|
const local = env.MUSHI_UX_BIN?.trim();
|
|
10715
10969
|
if (local) return { cmd: process.execPath, argv: [local, ...args], shell: false };
|
|
10716
10970
|
const npxArgs = ["--yes", "--package", UX_PACKAGE, "mushi-ux", ...args];
|
|
10717
|
-
const found = (typeof npxCli === "string" ? [npxCli] : npxCli).find((
|
|
10971
|
+
const found = (typeof npxCli === "string" ? [npxCli] : npxCli).find((p4) => hasFile(p4));
|
|
10718
10972
|
if (found) return { cmd: process.execPath, argv: [found, ...npxArgs], shell: false };
|
|
10719
10973
|
if (platform === "win32") return { cmd: "npx", argv: npxArgs.map(cmdQuote), shell: true };
|
|
10720
10974
|
return { cmd: "npx", argv: npxArgs, shell: false };
|
|
@@ -10832,6 +11086,1963 @@ function registerUxRunsCommands(program2) {
|
|
|
10832
11086
|
});
|
|
10833
11087
|
}
|
|
10834
11088
|
|
|
11089
|
+
// src/commands/runner.ts
|
|
11090
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
11091
|
+
import { accessSync, chmodSync, closeSync as closeSync2, constants as fsConstants, existsSync as existsSync6, fchownSync, fstatSync as fstatSync2, ftruncateSync, mkdirSync as mkdirSync2, openSync as openSync2, readFileSync as readFileSync8, realpathSync, writeFileSync as writeFileSync5, writeSync } from "fs";
|
|
11092
|
+
import { arch as nodeArch, userInfo } from "os";
|
|
11093
|
+
import { dirname as dirname5, join as join12, resolve as resolve7 } from "path";
|
|
11094
|
+
import * as p3 from "@clack/prompts";
|
|
11095
|
+
|
|
11096
|
+
// src/runner/hooks.ts
|
|
11097
|
+
var HOOKS_DIR_NAME = "mushi-hooks";
|
|
11098
|
+
var HOOK_ENV_STARTED = "ACTIONS_RUNNER_HOOK_JOB_STARTED";
|
|
11099
|
+
var HOOK_ENV_COMPLETED = "ACTIONS_RUNNER_HOOK_JOB_COMPLETED";
|
|
11100
|
+
function shSingleQuoted(value) {
|
|
11101
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
11102
|
+
}
|
|
11103
|
+
function hookScript(opts) {
|
|
11104
|
+
if (!/^[0-9a-f]{64}$/.test(opts.hostId)) throw new Error("host id must be 64 hex characters");
|
|
11105
|
+
if (!/^https?:\/\/[^\s'"]+$/.test(opts.endpoint)) throw new Error("endpoint must be an http(s) URL");
|
|
11106
|
+
return `#!/bin/sh
|
|
11107
|
+
# Mushi runner job hook (job ${opts.phase}). Written by \`mushi runner hooks install\`.
|
|
11108
|
+
# Sends this job's memory peak, memory limit, out-of-memory kills, swap and
|
|
11109
|
+
# load to Mushi, so a job killed by the machine reads "test machine stopped",
|
|
11110
|
+
# not "your code failed". It never fails the job: it always exits 0.
|
|
11111
|
+
# The API key is read from ./key (mode 0600); it is never printed.
|
|
11112
|
+
|
|
11113
|
+
PHASE=${opts.phase}
|
|
11114
|
+
ENDPOINT=${shSingleQuoted(opts.endpoint.replace(/\/+$/, ""))}
|
|
11115
|
+
HOST_ID=${opts.hostId}
|
|
11116
|
+
|
|
11117
|
+
safe() { printf '%s' "$1" | tr -cd 'A-Za-z0-9._/-' | cut -c1-100; }
|
|
11118
|
+
safe_name() { printf '%s' "$1" | tr -cd 'A-Za-z0-9._/ -' | cut -c1-100; }
|
|
11119
|
+
digits() { printf '%s' "$1" | tr -cd '0-9' | cut -c1-20; }
|
|
11120
|
+
num_or_null() { case "$1" in ''|*[!0-9.]*) printf 'null' ;; *) printf '%s' "$1" ;; esac; }
|
|
11121
|
+
mb() { case "$1" in ''|max|*[!0-9]*) printf '' ;; *) printf '%s' $(( $1 / 1048576 )) ;; esac; }
|
|
11122
|
+
|
|
11123
|
+
mushi_hook() {
|
|
11124
|
+
HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || return 0
|
|
11125
|
+
[ -r "$HOOK_DIR/key" ] || return 0
|
|
11126
|
+
command -v curl >/dev/null 2>&1 || return 0
|
|
11127
|
+
|
|
11128
|
+
CG=$(sed -n 's/^0:://p' /proc/self/cgroup 2>/dev/null | head -n 1)
|
|
11129
|
+
CGDIR="/sys/fs/cgroup$CG"
|
|
11130
|
+
PEAK=$(mb "$(cat "$CGDIR/memory.peak" 2>/dev/null)")
|
|
11131
|
+
MAX=$(mb "$(cat "$CGDIR/memory.max" 2>/dev/null)")
|
|
11132
|
+
if [ -z "$MAX" ]; then MAX=$(mb "$(cat "$(dirname "$CGDIR")/memory.max" 2>/dev/null)"); fi
|
|
11133
|
+
OOM=$(sed -n 's/^oom_kill \\([0-9]*\\)$/\\1/p' "$CGDIR/memory.events" 2>/dev/null | head -n 1)
|
|
11134
|
+
SWAP=$(awk '/^SwapTotal:/{t=$2} /^SwapFree:/{f=$2} END{if (t!="") printf "%d", (t-f)/1024}' /proc/meminfo 2>/dev/null)
|
|
11135
|
+
LOAD=$(cut -d' ' -f1 /proc/loadavg 2>/dev/null)
|
|
11136
|
+
|
|
11137
|
+
REPO=$(safe "$GITHUB_REPOSITORY")
|
|
11138
|
+
RUNNER=$(safe_name "$RUNNER_NAME")
|
|
11139
|
+
JOB=$(safe "$GITHUB_JOB")
|
|
11140
|
+
RUN_ID=$(digits "$GITHUB_RUN_ID")
|
|
11141
|
+
ATTEMPT=$(digits "$GITHUB_RUN_ATTEMPT")
|
|
11142
|
+
[ -n "$REPO" ] && [ -n "$RUN_ID" ] && [ -n "$JOB" ] || return 0
|
|
11143
|
+
AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
11144
|
+
|
|
11145
|
+
BODY=$(printf '{"kind":"job","host_id":"%s","runner_name":"%s","repo":"%s","run_id":%s,"run_attempt":%s,"job_key":"%s","phase":"%s","at":"%s","mem_peak_mb":%s,"mem_max_mb":%s,"oom_kills":%s,"swap_used_mb":%s,"load1":%s}' \\
|
|
11146
|
+
"$HOST_ID" "$RUNNER" "$REPO" "$RUN_ID" "\${ATTEMPT:-1}" "$JOB" "$PHASE" "$AT" \\
|
|
11147
|
+
"$(num_or_null "$PEAK")" "$(num_or_null "$MAX")" "$(num_or_null "$OOM")" "$(num_or_null "$SWAP")" "$(num_or_null "$LOAD")")
|
|
11148
|
+
|
|
11149
|
+
# The key goes to curl on stdin as a header (printf is a shell builtin, so
|
|
11150
|
+
# it never appears in a process list).
|
|
11151
|
+
printf 'X-Mushi-Api-Key: %s\\n' "$(cat "$HOOK_DIR/key")" | curl -sS --max-time 5 -o /dev/null \\
|
|
11152
|
+
-H @- -H 'Content-Type: application/json' -X POST --data "$BODY" "$ENDPOINT/v1/ingest/runner"
|
|
11153
|
+
}
|
|
11154
|
+
|
|
11155
|
+
mushi_hook >/dev/null 2>&1 || true
|
|
11156
|
+
exit 0
|
|
11157
|
+
`;
|
|
11158
|
+
}
|
|
11159
|
+
function mergeRunnerEnv(existing, hooks, force = false) {
|
|
11160
|
+
const wanted = [
|
|
11161
|
+
[HOOK_ENV_STARTED, hooks.started],
|
|
11162
|
+
[HOOK_ENV_COMPLETED, hooks.completed]
|
|
11163
|
+
];
|
|
11164
|
+
const lines = existing.length ? existing.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n") : [];
|
|
11165
|
+
const conflicts = [];
|
|
11166
|
+
let changed = false;
|
|
11167
|
+
for (const [key, value] of wanted) {
|
|
11168
|
+
const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
|
|
11169
|
+
if (idx === -1) {
|
|
11170
|
+
lines.push(`${key}=${value}`);
|
|
11171
|
+
changed = true;
|
|
11172
|
+
continue;
|
|
11173
|
+
}
|
|
11174
|
+
const current = lines[idx].slice(key.length + 1);
|
|
11175
|
+
if (current === value) continue;
|
|
11176
|
+
if (!force) {
|
|
11177
|
+
conflicts.push({ key, current });
|
|
11178
|
+
continue;
|
|
11179
|
+
}
|
|
11180
|
+
lines[idx] = `${key}=${value}`;
|
|
11181
|
+
changed = true;
|
|
11182
|
+
}
|
|
11183
|
+
return { text: changed && conflicts.length === 0 ? `${lines.join("\n")}
|
|
11184
|
+
` : null, conflicts };
|
|
11185
|
+
}
|
|
11186
|
+
|
|
11187
|
+
// src/runner/key.ts
|
|
11188
|
+
var RUNNER_KEY_PATH = "/etc/mushi/runner.key";
|
|
11189
|
+
var RUNNER_KEY_CREDENTIAL = "runner-key";
|
|
11190
|
+
var LABEL_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
11191
|
+
function defaultRunnerKeyLabel(hostname2) {
|
|
11192
|
+
const short = hostname2.split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
11193
|
+
return (short || "host").slice(0, 40).replace(/-+$/, "");
|
|
11194
|
+
}
|
|
11195
|
+
function keyFromMintResponse(data) {
|
|
11196
|
+
if (!data || typeof data !== "object") return null;
|
|
11197
|
+
const d = data;
|
|
11198
|
+
for (const k of ["key", "apiKey", "api_key", "rawKey", "plaintext"]) {
|
|
11199
|
+
if (typeof d[k] === "string" && d[k].length >= 8) return d[k];
|
|
11200
|
+
}
|
|
11201
|
+
return null;
|
|
11202
|
+
}
|
|
11203
|
+
function assertNotCliKey(runnerKey, cliKey) {
|
|
11204
|
+
if (cliKey && runnerKey === cliKey) {
|
|
11205
|
+
throw new MushiCliError(
|
|
11206
|
+
"E_INVALID_INPUT",
|
|
11207
|
+
"That is your CLI key, not a runner key. A runner key can only report runner health; the CLI key can merge pull requests, so it never goes on a runner.",
|
|
11208
|
+
"unset MUSHI_RUNNER_KEY, or set it to a key from `mushi runner key`"
|
|
11209
|
+
);
|
|
11210
|
+
}
|
|
11211
|
+
}
|
|
11212
|
+
var RUNNER_KEY_SHAPE = /^mushi_[A-Za-z0-9_-]{8,128}$/;
|
|
11213
|
+
function assertRunnerKeyShape(key) {
|
|
11214
|
+
if (!RUNNER_KEY_SHAPE.test(key)) {
|
|
11215
|
+
throw new MushiCliError("E_INVALID_INPUT", "That does not look like a Mushi key (mushi_\u2026).", "mint one with `mushi runner key`, or check MUSHI_RUNNER_KEY");
|
|
11216
|
+
}
|
|
11217
|
+
return key;
|
|
11218
|
+
}
|
|
11219
|
+
async function mintRunnerKey(config, projectId2, label) {
|
|
11220
|
+
if (!LABEL_RE.test(label)) {
|
|
11221
|
+
throw new MushiCliError("E_INVALID_INPUT", `Key label "${label}" must be 1-40 lowercase letters, digits and hyphens.`);
|
|
11222
|
+
}
|
|
11223
|
+
const result = await apiCall(
|
|
11224
|
+
`/v1/admin/projects/${encodeURIComponent(projectId2)}/runner-key`,
|
|
11225
|
+
config,
|
|
11226
|
+
{ method: "POST", body: JSON.stringify({ label }) }
|
|
11227
|
+
);
|
|
11228
|
+
if (!result.ok) {
|
|
11229
|
+
const scope = result.error.code === "INSUFFICIENT_SCOPE" || result.httpStatus === 403;
|
|
11230
|
+
throw new MushiCliError(
|
|
11231
|
+
"E_INVALID_INPUT",
|
|
11232
|
+
`Could not create a runner key: ${result.error.code} \u2014 ${result.error.message}`,
|
|
11233
|
+
scope ? "creating one needs a CLI key with mcp:write: run `mushi login --upgrade-scope`" : void 0
|
|
11234
|
+
);
|
|
11235
|
+
}
|
|
11236
|
+
const key = keyFromMintResponse(result.data);
|
|
11237
|
+
if (!key) throw new MushiCliError("E_INVALID_INPUT", "The server answered without a runner key.");
|
|
11238
|
+
assertNotCliKey(key, config.apiKey);
|
|
11239
|
+
return assertRunnerKeyShape(key);
|
|
11240
|
+
}
|
|
11241
|
+
async function resolveRunnerKey(config, opts) {
|
|
11242
|
+
const fromEnv = (opts.env ?? process.env)["MUSHI_RUNNER_KEY"]?.trim();
|
|
11243
|
+
if (fromEnv) {
|
|
11244
|
+
assertNotCliKey(fromEnv, config.apiKey);
|
|
11245
|
+
return { key: assertRunnerKeyShape(fromEnv), source: "env" };
|
|
11246
|
+
}
|
|
11247
|
+
if (!config.apiKey) {
|
|
11248
|
+
throw new MushiCliError("E_AUTH_MISSING", "No runner key and no CLI key to create one.", "set MUSHI_RUNNER_KEY, or run `mushi login` first");
|
|
11249
|
+
}
|
|
11250
|
+
if (!opts.projectId) {
|
|
11251
|
+
throw new MushiCliError("E_PROJECT_MISSING", "Which project is the runner key for?", "pass --project <uuid>, or set MUSHI_RUNNER_KEY");
|
|
11252
|
+
}
|
|
11253
|
+
return { key: await mintRunnerKey(config, opts.projectId, opts.label), source: "minted" };
|
|
11254
|
+
}
|
|
11255
|
+
|
|
11256
|
+
// src/runner/add-plan.ts
|
|
11257
|
+
var REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
|
|
11258
|
+
var LABEL_RE2 = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
11259
|
+
var USER_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
|
|
11260
|
+
var MEMORY_RE = /^\d+(?:\.\d+)?[MG]$/;
|
|
11261
|
+
var RUNNER_ROOT = "/opt/mushi-runner";
|
|
11262
|
+
var LIB_DIR = "/usr/local/lib/mushi-runner";
|
|
11263
|
+
var ETC_DIR = "/etc/mushi-runner";
|
|
11264
|
+
var UNIT_DIR = "/etc/systemd/system";
|
|
11265
|
+
var SLICE = "mushi-runner.slice";
|
|
11266
|
+
function defaultLabel(hostname2) {
|
|
11267
|
+
const short = hostname2.split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
11268
|
+
return `mushi-${short || "runner"}`.slice(0, 40).replace(/-+$/, "");
|
|
11269
|
+
}
|
|
11270
|
+
function defaultMemory(totalMb) {
|
|
11271
|
+
const mb = Math.floor(totalMb * 0.6);
|
|
11272
|
+
return mb >= 2048 ? `${Math.floor(mb / 1024)}G` : `${Math.max(mb, 512)}M`;
|
|
11273
|
+
}
|
|
11274
|
+
function runnerArch(nodeArch2) {
|
|
11275
|
+
if (nodeArch2 === "x64") return "x64";
|
|
11276
|
+
if (nodeArch2 === "arm64") return "arm64";
|
|
11277
|
+
return null;
|
|
11278
|
+
}
|
|
11279
|
+
function parseRunnerSha(body, target) {
|
|
11280
|
+
const esc = target.replace(/[-.]/g, (c) => `\\${c}`);
|
|
11281
|
+
const m = body.match(new RegExp(`<!--\\s*BEGIN SHA ${esc}\\s*-->\\s*([0-9a-f]{64})\\s*<!--\\s*END SHA ${esc}\\s*-->`, "i"));
|
|
11282
|
+
return m ? m[1].toLowerCase() : null;
|
|
11283
|
+
}
|
|
11284
|
+
function parseResolvers(resolvConf) {
|
|
11285
|
+
const out = [];
|
|
11286
|
+
for (const line of resolvConf.split("\n")) {
|
|
11287
|
+
const m = line.match(/^\s*nameserver\s+(\d{1,3}(?:\.\d{1,3}){3})\s*$/);
|
|
11288
|
+
if (m && !out.includes(m[1])) out.push(m[1]);
|
|
11289
|
+
}
|
|
11290
|
+
return out;
|
|
11291
|
+
}
|
|
11292
|
+
function mergeIni(existing, wanted) {
|
|
11293
|
+
const lines = existing.replace(/\r\n/g, "\n").split("\n");
|
|
11294
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
11295
|
+
let changed = false;
|
|
11296
|
+
for (const [section, keys] of Object.entries(wanted)) {
|
|
11297
|
+
let start = lines.findIndex((l) => l.trim().toLowerCase() === `[${section.toLowerCase()}]`);
|
|
11298
|
+
if (start === -1) {
|
|
11299
|
+
if (lines.length && lines[lines.length - 1].trim() !== "") lines.push("");
|
|
11300
|
+
lines.push(`[${section}]`);
|
|
11301
|
+
start = lines.length - 1;
|
|
11302
|
+
}
|
|
11303
|
+
for (const [key, value] of Object.entries(keys)) {
|
|
11304
|
+
let end = lines.findIndex((l, i) => i > start && /^\s*\[/.test(l));
|
|
11305
|
+
if (end === -1) end = lines.length;
|
|
11306
|
+
const idx = lines.findIndex((l, i) => i > start && i < end && new RegExp(`^\\s*${key}\\s*=`, "i").test(l));
|
|
11307
|
+
if (idx === -1) {
|
|
11308
|
+
let insertAt = end;
|
|
11309
|
+
while (insertAt > start + 1 && lines[insertAt - 1].trim() === "") insertAt--;
|
|
11310
|
+
lines.splice(insertAt, 0, `${key}=${value}`);
|
|
11311
|
+
changed = true;
|
|
11312
|
+
} else if (lines[idx].split("=").slice(1).join("=").trim().toLowerCase() !== value.toLowerCase()) {
|
|
11313
|
+
lines[idx] = `${key}=${value}`;
|
|
11314
|
+
changed = true;
|
|
11315
|
+
}
|
|
11316
|
+
}
|
|
11317
|
+
}
|
|
11318
|
+
return changed ? `${lines.join("\n")}
|
|
11319
|
+
` : null;
|
|
11320
|
+
}
|
|
11321
|
+
var q = (s) => `'${s.replace(/'/g, `'"'"'`)}'`;
|
|
11322
|
+
function fileStep(id, title, path, content, mode, owner = "root:root") {
|
|
11323
|
+
const dir = path.slice(0, path.lastIndexOf("/")) || "/";
|
|
11324
|
+
return {
|
|
11325
|
+
id,
|
|
11326
|
+
title,
|
|
11327
|
+
sudo: true,
|
|
11328
|
+
run: `mkdir -p ${q(dir)} && cat > ${q(path)} && chmod ${mode} ${q(path)} && chown ${owner} ${q(path)}`,
|
|
11329
|
+
file: { path, content }
|
|
11330
|
+
};
|
|
11331
|
+
}
|
|
11332
|
+
function notesForWorkflows(label, setVariable, repo) {
|
|
11333
|
+
return [
|
|
11334
|
+
"Point a workflow at this machine (only workflows that ask for the label run here):",
|
|
11335
|
+
` runs-on: [self-hosted, ${label}]`,
|
|
11336
|
+
"Or keep GitHub-hosted runners as the fallback with a repository variable:",
|
|
11337
|
+
" runs-on: ${{ vars.LINUX_RUNNER || 'ubuntu-latest' }}",
|
|
11338
|
+
setVariable ? ` (this plan sets LINUX_RUNNER=${label} on ${repo})` : ` gh variable set LINUX_RUNNER --repo ${repo} --body ${label} (or re-run with --set-variable)`
|
|
11339
|
+
];
|
|
11340
|
+
}
|
|
11341
|
+
function wslNotes(distro) {
|
|
11342
|
+
const d = distro ?? "<your distro>";
|
|
11343
|
+
return [
|
|
11344
|
+
"WSL2 stops a distro about 15 s after its last terminal closes, and the VM a minute later, so the runner goes offline.",
|
|
11345
|
+
"Keep it running: in %USERPROFILE%\\.wslconfig on Windows set",
|
|
11346
|
+
" [general]",
|
|
11347
|
+
" instanceIdleTimeout=-1",
|
|
11348
|
+
" [wsl2]",
|
|
11349
|
+
" vmIdleTimeout=-1",
|
|
11350
|
+
"then run `wsl --shutdown` once. And start the distro at logon (PowerShell, as you):",
|
|
11351
|
+
` schtasks /Create /TN "mushi-runner-keepalive-${d}" /SC ONLOGON /RL LIMITED /F /TR "wsl.exe -d ${d} -u root --exec sleep infinity"`
|
|
11352
|
+
];
|
|
11353
|
+
}
|
|
11354
|
+
function macNotes(input) {
|
|
11355
|
+
return [
|
|
11356
|
+
"On macOS Mushi prints the steps instead of running them. Use a user-session LaunchAgent (not a daemon), so jobs reach the keychain and the iOS simulator:",
|
|
11357
|
+
" 1. Create a standard (non-admin) macOS user for the runner and log in to it once (fast user switching keeps it running).",
|
|
11358
|
+
` 2. As that user, download the runner from https://github.com/actions/runner/releases (osx-${input.arch ?? "arm64"}) and check its SHA-256 against the release notes.`,
|
|
11359
|
+
` 3. Register it with your own gh login: TOKEN=$(gh api -X POST repos/${input.repo}/actions/runners/registration-token --jq .token)`,
|
|
11360
|
+
` ACTIONS_RUNNER_INPUT_TOKEN="$TOKEN" ./config.sh --unattended --url https://github.com/${input.repo} --name ${input.label} --labels ${input.label}`,
|
|
11361
|
+
" 4. ./svc.sh install && ./svc.sh start (installs a LaunchAgent for that user)",
|
|
11362
|
+
` 5. mushi runner hooks install --dir <runner folder>, then restart the agent.`,
|
|
11363
|
+
...notesForWorkflows(input.label, false, input.repo)
|
|
11364
|
+
];
|
|
11365
|
+
}
|
|
11366
|
+
function validate(input) {
|
|
11367
|
+
if (!REPO_RE.test(input.repo)) return `"${input.repo}" is not owner/name.`;
|
|
11368
|
+
if (!LABEL_RE2.test(input.label)) return `Label "${input.label}" must be lowercase letters, digits and dashes (\u2264 40).`;
|
|
11369
|
+
if (!USER_RE.test(input.runnerUser)) return `Runner user "${input.runnerUser}" is not a valid user name.`;
|
|
11370
|
+
if (!USER_RE.test(input.installUser)) return `Could not tell who you are ("${input.installUser}").`;
|
|
11371
|
+
if (input.installUser === "root") return "Run `mushi runner add` as yourself, not as root. It uses sudo for the steps that need it, and your own gh login.";
|
|
11372
|
+
if (input.installUser === input.runnerUser) return "The runner needs its own user, not yours.";
|
|
11373
|
+
if (!Number.isInteger(input.uid) || input.uid < 500 || input.uid > 6e4) return "The runner uid must be between 500 and 60000.";
|
|
11374
|
+
if (!MEMORY_RE.test(input.memoryMax)) return `Memory "${input.memoryMax}" must look like 8G or 6144M.`;
|
|
11375
|
+
if (!/^[0-9a-f]{64}$/.test(input.hostId)) return "Could not hash a machine id for this host.";
|
|
11376
|
+
if (input.wsl?.distro && !/^[A-Za-z0-9._-]+$/.test(input.wsl.distro)) return "Unexpected WSL distro name.";
|
|
11377
|
+
if (input.release && !/^\d{1,4}\.\d{1,4}\.\d{1,4}$/.test(input.release.version)) return `Unexpected actions/runner version "${input.release.version}".`;
|
|
11378
|
+
if (input.release && !/^[0-9a-f]{64}$/.test(input.release.sha256)) return "Unexpected actions/runner checksum.";
|
|
11379
|
+
if (input.resolvers.some((ip) => !/^\d{1,3}(?:\.\d{1,3}){3}$/.test(ip))) return "Unexpected DNS resolver address.";
|
|
11380
|
+
return null;
|
|
11381
|
+
}
|
|
11382
|
+
function buildAddPlan(input) {
|
|
11383
|
+
const warnings = [];
|
|
11384
|
+
const refuse = (why, notes2 = []) => ({ refused: why, steps: [], warnings, notes: notes2 });
|
|
11385
|
+
if (input.platform === "windows") {
|
|
11386
|
+
return refuse(
|
|
11387
|
+
"Mushi sets up runners on Linux, WSL2 and macOS. On Windows, install WSL2 (`wsl --install`), open the distro and run `mushi runner add` there: the runner then lives in its own Linux user, apart from your Windows files."
|
|
11388
|
+
);
|
|
11389
|
+
}
|
|
11390
|
+
const invalid = validate(input);
|
|
11391
|
+
if (invalid) return refuse(invalid);
|
|
11392
|
+
if (input.isPublic === true && !input.allowPublic) {
|
|
11393
|
+
return refuse(
|
|
11394
|
+
`${input.repo} is public. A pull request from a fork would run its code on this machine, and GitHub says self-hosted runners "should almost never be used for public repositories" (https://docs.github.com/en/actions/reference/security/secure-use). Use GitHub-hosted runners for it (public repos run free), or pass --allow-public if you accept that risk.`
|
|
11395
|
+
);
|
|
11396
|
+
}
|
|
11397
|
+
if (input.isPublic === true) warnings.push(`${input.repo} is public and --allow-public is set, so every fork pull request your settings let run will run on this machine.`);
|
|
11398
|
+
if (input.isPublic === null) warnings.push(`Could not read whether ${input.repo} is public; check it is private before you point workflows here.`);
|
|
11399
|
+
if (input.forkApproval === null) {
|
|
11400
|
+
warnings.push("Could not read the fork pull request approval setting (Settings \u2192 Actions \u2192 General).");
|
|
11401
|
+
} else if (input.forkApproval !== "all_external_contributors") {
|
|
11402
|
+
warnings.push(`Fork pull requests from some outside contributors run without approval (${input.forkApproval}). Set "Require approval for all external contributors" in Settings \u2192 Actions \u2192 General.`);
|
|
11403
|
+
}
|
|
11404
|
+
if (input.prTargetWorkflows && input.prTargetWorkflows.length > 0) {
|
|
11405
|
+
warnings.push(`These workflows use pull_request_target, which runs with your secrets on fork pull requests; keep them off this runner: ${input.prTargetWorkflows.join(", ")}`);
|
|
11406
|
+
}
|
|
11407
|
+
warnings.push("Cloud agents push branches inside the repo, so fork approval does not stop code an agent wrote from running here. Review what your agents can push.");
|
|
11408
|
+
if (input.platform === "macos") {
|
|
11409
|
+
return { refused: null, steps: [], warnings, notes: macNotes(input) };
|
|
11410
|
+
}
|
|
11411
|
+
if (!input.arch) return refuse("This CPU is not supported by the GitHub runner (x64 or arm64 only).");
|
|
11412
|
+
const steps = [];
|
|
11413
|
+
if (input.platform === "wsl2" && input.wsl) {
|
|
11414
|
+
const wanted = { boot: { systemd: "true" } };
|
|
11415
|
+
if (input.wsl.isolate) {
|
|
11416
|
+
wanted["automount"] = { enabled: "false" };
|
|
11417
|
+
wanted["interop"] = { enabled: "false", appendWindowsPath: "false" };
|
|
11418
|
+
}
|
|
11419
|
+
const merged = mergeIni(input.wsl.wslConf, wanted);
|
|
11420
|
+
if (merged) {
|
|
11421
|
+
steps.push(fileStep("wsl-conf", "Update /etc/wsl.conf (systemd on" + (input.wsl.isolate ? "; Windows drives and Windows programs off for this distro" : "") + ")", "/etc/wsl.conf", merged, "0644"));
|
|
11422
|
+
}
|
|
11423
|
+
if (!input.wsl.isolate) {
|
|
11424
|
+
warnings.push("Jobs in this distro can read your Windows drives (/mnt/c) and start Windows programs. Re-run with --wsl-isolate to turn both off for this distro (it needs a `wsl --shutdown`).");
|
|
11425
|
+
}
|
|
11426
|
+
if (!input.systemdRunning) {
|
|
11427
|
+
return {
|
|
11428
|
+
refused: null,
|
|
11429
|
+
steps,
|
|
11430
|
+
warnings,
|
|
11431
|
+
notes: ["systemd is not running in this distro yet. After these steps run `wsl --shutdown` in Windows, open the distro again and run `mushi runner add` again."]
|
|
11432
|
+
};
|
|
11433
|
+
}
|
|
11434
|
+
} else if (!input.systemdRunning) {
|
|
11435
|
+
return refuse("This machine does not run systemd, which the runner service, its memory cap and the heartbeat timer need.");
|
|
11436
|
+
}
|
|
11437
|
+
if (!input.release) return refuse("Could not read the latest actions/runner release and its published SHA-256, so there is nothing safe to download.");
|
|
11438
|
+
const target = `linux-${input.arch}`;
|
|
11439
|
+
const dir = `${RUNNER_ROOT}/${input.label}`;
|
|
11440
|
+
const ru = input.runnerUser;
|
|
11441
|
+
const unit = `mushi-runner-${input.label}.service`;
|
|
11442
|
+
const tarball = `actions-runner-${target}-${input.release.version}.tar.gz`;
|
|
11443
|
+
const url = `https://github.com/actions/runner/releases/download/v${input.release.version}/${tarball}`;
|
|
11444
|
+
steps.push({
|
|
11445
|
+
id: "user",
|
|
11446
|
+
title: `Create the ${ru} user (uid ${input.uid}, no login shell, not an admin)`,
|
|
11447
|
+
sudo: true,
|
|
11448
|
+
run: `if id -u ${ru} >/dev/null 2>&1; then echo '${ru} already exists'; elif getent passwd ${input.uid} >/dev/null; then echo 'uid ${input.uid} is taken; pass --uid' >&2; exit 1; else useradd --system --uid ${input.uid} --user-group --home-dir /var/lib/${ru} --create-home --shell /usr/sbin/nologin ${ru}; fi`
|
|
11449
|
+
});
|
|
11450
|
+
steps.push({
|
|
11451
|
+
id: "download",
|
|
11452
|
+
title: `Download actions/runner ${input.release.version} and check its SHA-256`,
|
|
11453
|
+
sudo: true,
|
|
11454
|
+
run: [
|
|
11455
|
+
"set -e",
|
|
11456
|
+
"tmp=$(mktemp -d)",
|
|
11457
|
+
`curl -fsSL --proto '=https' -o "$tmp/runner.tgz" ${q(url)}`,
|
|
11458
|
+
`echo '${input.release.sha256} '"$tmp/runner.tgz" | sha256sum -c -`,
|
|
11459
|
+
`install -d -o ${ru} -g ${ru} -m 0750 ${q(dir)}`,
|
|
11460
|
+
`tar -xzf "$tmp/runner.tgz" -C ${q(dir)}`,
|
|
11461
|
+
`chown -R ${ru}:${ru} ${q(dir)}`,
|
|
11462
|
+
'rm -rf "$tmp"'
|
|
11463
|
+
].join("; ")
|
|
11464
|
+
});
|
|
11465
|
+
steps.push({
|
|
11466
|
+
id: "deps",
|
|
11467
|
+
title: "Install the runner's system libraries (its own script)",
|
|
11468
|
+
sudo: true,
|
|
11469
|
+
run: `${q(`${dir}/bin/installdependencies.sh`)}`
|
|
11470
|
+
});
|
|
11471
|
+
steps.push(fileStep(
|
|
11472
|
+
"slice",
|
|
11473
|
+
`Cap runner memory at ${input.memoryMax} (systemd slice)`,
|
|
11474
|
+
`${UNIT_DIR}/${SLICE}`,
|
|
11475
|
+
[
|
|
11476
|
+
"[Unit]",
|
|
11477
|
+
"Description=Mushi runners: memory and CPU limits",
|
|
11478
|
+
"",
|
|
11479
|
+
"[Slice]",
|
|
11480
|
+
`MemoryMax=${input.memoryMax}`,
|
|
11481
|
+
// No MemoryHigh: reclaim throttling also stalls the runner's own
|
|
11482
|
+
// heartbeat, and GitHub then cancels the job as "lost communication"
|
|
11483
|
+
// (seen on 2026-10-21 at PSI full 71%). A swap cap keeps a leaking
|
|
11484
|
+
// build from paging the whole machine instead.
|
|
11485
|
+
"MemorySwapMax=2G",
|
|
11486
|
+
"CPUWeight=50",
|
|
11487
|
+
"TasksMax=4096",
|
|
11488
|
+
""
|
|
11489
|
+
].join("\n"),
|
|
11490
|
+
"0644"
|
|
11491
|
+
));
|
|
11492
|
+
const osLabel = "Linux";
|
|
11493
|
+
const archLabel = input.arch === "arm64" ? "ARM64" : "X64";
|
|
11494
|
+
const hardening = [
|
|
11495
|
+
"NoNewPrivileges=yes",
|
|
11496
|
+
"PrivateTmp=yes",
|
|
11497
|
+
"ProtectHome=yes",
|
|
11498
|
+
"ProtectSystem=full",
|
|
11499
|
+
"ProtectKernelTunables=yes",
|
|
11500
|
+
"ProtectKernelModules=yes",
|
|
11501
|
+
"ProtectControlGroups=yes",
|
|
11502
|
+
"RestrictSUIDSGID=yes"
|
|
11503
|
+
];
|
|
11504
|
+
if (input.mode === "ephemeral") {
|
|
11505
|
+
const tokenFile = `${ETC_DIR}/${input.label}.gh-token`;
|
|
11506
|
+
steps.push(fileStep(
|
|
11507
|
+
"mint",
|
|
11508
|
+
"Write the script that mints a one-job runner config before every job",
|
|
11509
|
+
`${LIB_DIR}/${input.label}-mint.sh`,
|
|
11510
|
+
[
|
|
11511
|
+
"#!/bin/sh",
|
|
11512
|
+
`# Mints a one-job (JIT) runner config for ${input.repo}. Runs as root before`,
|
|
11513
|
+
`# each job (ExecStartPre=+). Uses ${tokenFile} (a fine-grained token with`,
|
|
11514
|
+
`# Administration: write on this repo only) when it exists, else ${input.installUser}'s gh login.`,
|
|
11515
|
+
"set -eu",
|
|
11516
|
+
"umask 077",
|
|
11517
|
+
`rm -rf ${q(`${dir}/_work`)}`,
|
|
11518
|
+
`NAME="${input.label}-$(date +%s)"`,
|
|
11519
|
+
// The token can create runners on the repo: use it only when root alone can read it.
|
|
11520
|
+
`if [ -e ${q(tokenFile)} ]; then`,
|
|
11521
|
+
` [ "$(stat -c '%u %a' ${q(tokenFile)})" = "0 600" ] || { echo ${q(`${tokenFile} must be owned by root with mode 0600; refusing to use it`)} >&2; exit 1; }`,
|
|
11522
|
+
` GH_TOKEN=$(cat ${q(tokenFile)}); export GH_TOKEN; GH=gh`,
|
|
11523
|
+
`else GH="runuser -u ${input.installUser} -- gh"; fi`,
|
|
11524
|
+
`$GH api -X POST repos/${input.repo}/actions/runners/generate-jitconfig \\`,
|
|
11525
|
+
` -f name="$NAME" -F runner_group_id=1 -f work_folder=_work \\`,
|
|
11526
|
+
` -f 'labels[]=self-hosted' -f 'labels[]=${osLabel}' -f 'labels[]=${archLabel}' -f 'labels[]=${input.label}' \\`,
|
|
11527
|
+
' --jq .encoded_jit_config > "$RUNTIME_DIRECTORY/jit"',
|
|
11528
|
+
`chown ${ru}:${ru} "$RUNTIME_DIRECTORY/jit"`,
|
|
11529
|
+
""
|
|
11530
|
+
].join("\n"),
|
|
11531
|
+
"0755"
|
|
11532
|
+
));
|
|
11533
|
+
steps.push(fileStep(
|
|
11534
|
+
"start",
|
|
11535
|
+
"Write the script that starts the runner with that config (read from a 0600 file, passed as an environment variable)",
|
|
11536
|
+
`${LIB_DIR}/${input.label}-start.sh`,
|
|
11537
|
+
[
|
|
11538
|
+
"#!/bin/sh",
|
|
11539
|
+
"set -eu",
|
|
11540
|
+
'ACTIONS_RUNNER_INPUT_JITCONFIG=$(cat "$RUNTIME_DIRECTORY/jit")',
|
|
11541
|
+
'rm -f "$RUNTIME_DIRECTORY/jit"',
|
|
11542
|
+
"export ACTIONS_RUNNER_INPUT_JITCONFIG",
|
|
11543
|
+
`exec ${q(`${dir}/run.sh`)}`,
|
|
11544
|
+
""
|
|
11545
|
+
].join("\n"),
|
|
11546
|
+
"0755"
|
|
11547
|
+
));
|
|
11548
|
+
steps.push(fileStep("service", `Write the ${unit} service (one job, then a fresh runner)`, `${UNIT_DIR}/${unit}`, [
|
|
11549
|
+
"[Unit]",
|
|
11550
|
+
`Description=Mushi GitHub Actions runner ${input.label} for ${input.repo} (one job per runner)`,
|
|
11551
|
+
"After=network-online.target",
|
|
11552
|
+
"Wants=network-online.target",
|
|
11553
|
+
"",
|
|
11554
|
+
"[Service]",
|
|
11555
|
+
`User=${ru}`,
|
|
11556
|
+
`Group=${ru}`,
|
|
11557
|
+
`Slice=${SLICE}`,
|
|
11558
|
+
`WorkingDirectory=${dir}`,
|
|
11559
|
+
`RuntimeDirectory=mushi-runner-${input.label}`,
|
|
11560
|
+
"RuntimeDirectoryMode=0700",
|
|
11561
|
+
`ExecStartPre=+${LIB_DIR}/${input.label}-mint.sh`,
|
|
11562
|
+
`ExecStart=${LIB_DIR}/${input.label}-start.sh`,
|
|
11563
|
+
"Restart=always",
|
|
11564
|
+
"RestartSec=15",
|
|
11565
|
+
"KillSignal=SIGINT",
|
|
11566
|
+
"TimeoutStopSec=5min",
|
|
11567
|
+
...hardening,
|
|
11568
|
+
"",
|
|
11569
|
+
"[Install]",
|
|
11570
|
+
"WantedBy=multi-user.target",
|
|
11571
|
+
""
|
|
11572
|
+
].join("\n"), "0644"));
|
|
11573
|
+
if (input.ghTokenInKeyring) {
|
|
11574
|
+
warnings.push(`Your gh login is in a desktop keyring, which a background service cannot read, so minting will fail. Create a fine-grained token with Administration: write on ${input.repo} only and save it as ${tokenFile} (root, mode 0600).`);
|
|
11575
|
+
}
|
|
11576
|
+
} else {
|
|
11577
|
+
steps.push({
|
|
11578
|
+
id: "register",
|
|
11579
|
+
title: `Register the runner with ${input.repo} (token from your gh login, passed on stdin)`,
|
|
11580
|
+
sudo: true,
|
|
11581
|
+
stdinSecret: "registration-token",
|
|
11582
|
+
run: `cd ${q(dir)} && runuser -u ${ru} -- sh -c 'IFS= read -r ACTIONS_RUNNER_INPUT_TOKEN; export ACTIONS_RUNNER_INPUT_TOKEN; exec ./config.sh --unattended --replace --url https://github.com/${input.repo} --name ${input.label} --labels ${input.label} --work _work'`
|
|
11583
|
+
});
|
|
11584
|
+
steps.push(fileStep("service", `Write the ${unit} service`, `${UNIT_DIR}/${unit}`, [
|
|
11585
|
+
"[Unit]",
|
|
11586
|
+
`Description=Mushi GitHub Actions runner ${input.label} for ${input.repo}`,
|
|
11587
|
+
"After=network-online.target",
|
|
11588
|
+
"Wants=network-online.target",
|
|
11589
|
+
"",
|
|
11590
|
+
"[Service]",
|
|
11591
|
+
`User=${ru}`,
|
|
11592
|
+
`Group=${ru}`,
|
|
11593
|
+
`Slice=${SLICE}`,
|
|
11594
|
+
`WorkingDirectory=${dir}`,
|
|
11595
|
+
`ExecStart=${dir}/run.sh`,
|
|
11596
|
+
"Restart=always",
|
|
11597
|
+
"RestartSec=15",
|
|
11598
|
+
"KillSignal=SIGINT",
|
|
11599
|
+
"TimeoutStopSec=5min",
|
|
11600
|
+
...hardening,
|
|
11601
|
+
"",
|
|
11602
|
+
"[Install]",
|
|
11603
|
+
"WantedBy=multi-user.target",
|
|
11604
|
+
""
|
|
11605
|
+
].join("\n"), "0644"));
|
|
11606
|
+
}
|
|
11607
|
+
const hooksDir = `${dir}/${HOOKS_DIR_NAME}`;
|
|
11608
|
+
for (const phase of ["started", "completed"]) {
|
|
11609
|
+
steps.push(fileStep(
|
|
11610
|
+
`hook-${phase}`,
|
|
11611
|
+
`Write the job-${phase} hook (memory peak, OOM kills, swap, load per job)`,
|
|
11612
|
+
`${hooksDir}/job-${phase}.sh`,
|
|
11613
|
+
hookScript({ phase, endpoint: input.endpoint, hostId: input.hostId }),
|
|
11614
|
+
"0755",
|
|
11615
|
+
`${ru}:${ru}`
|
|
11616
|
+
));
|
|
11617
|
+
}
|
|
11618
|
+
steps.push({
|
|
11619
|
+
id: "hook-key",
|
|
11620
|
+
title: `Save the runner key (runner:write, can only report runner health) for the hooks (${hooksDir}/key, mode 0600, owned by ${ru})`,
|
|
11621
|
+
sudo: true,
|
|
11622
|
+
stdinSecret: "runner-key",
|
|
11623
|
+
run: `umask 077 && IFS= read -r k && printf '%s\\n' "$k" > ${q(`${hooksDir}/key`)} && chown ${ru}:${ru} ${q(`${hooksDir}/key`)}`
|
|
11624
|
+
});
|
|
11625
|
+
steps.push({
|
|
11626
|
+
id: "hook-env",
|
|
11627
|
+
title: "Turn the hooks on in the runner's .env",
|
|
11628
|
+
sudo: true,
|
|
11629
|
+
run: [
|
|
11630
|
+
`cd ${q(dir)}`,
|
|
11631
|
+
`grep -q '^${HOOK_ENV_STARTED}=' .env 2>/dev/null || echo '${HOOK_ENV_STARTED}=${hooksDir}/job-started.sh' >> .env`,
|
|
11632
|
+
`grep -q '^${HOOK_ENV_COMPLETED}=' .env 2>/dev/null || echo '${HOOK_ENV_COMPLETED}=${hooksDir}/job-completed.sh' >> .env`,
|
|
11633
|
+
`chown ${ru}:${ru} .env`
|
|
11634
|
+
].join(" && ")
|
|
11635
|
+
});
|
|
11636
|
+
const keyDir = RUNNER_KEY_PATH.slice(0, RUNNER_KEY_PATH.lastIndexOf("/"));
|
|
11637
|
+
steps.push({
|
|
11638
|
+
id: "heartbeat-key",
|
|
11639
|
+
title: `Save the runner key for the heartbeat (${RUNNER_KEY_PATH}, root only, handed to the service by systemd)`,
|
|
11640
|
+
sudo: true,
|
|
11641
|
+
stdinSecret: "runner-key",
|
|
11642
|
+
run: `install -d -m 0700 ${keyDir} && umask 077 && IFS= read -r k && printf '%s\\n' "$k" > ${RUNNER_KEY_PATH}`
|
|
11643
|
+
});
|
|
11644
|
+
steps.push(fileStep("heartbeat-service", "Write the heartbeat service (runs as you, reads systemd and cgroups only)", `${UNIT_DIR}/mushi-runner-heartbeat.service`, [
|
|
11645
|
+
"[Unit]",
|
|
11646
|
+
"Description=Mushi runner heartbeat (memory, swap, load, runner services)",
|
|
11647
|
+
"After=network-online.target",
|
|
11648
|
+
"",
|
|
11649
|
+
"[Service]",
|
|
11650
|
+
"Type=oneshot",
|
|
11651
|
+
`User=${input.installUser}`,
|
|
11652
|
+
// systemd reads the root-only key and exposes it to this service alone
|
|
11653
|
+
// at $CREDENTIALS_DIRECTORY/runner-key; it is never in the unit or env.
|
|
11654
|
+
`LoadCredential=${RUNNER_KEY_CREDENTIAL}:${RUNNER_KEY_PATH}`,
|
|
11655
|
+
`Environment=MUSHI_API_ENDPOINT=${input.endpoint.replace(/%/g, "%%")}`,
|
|
11656
|
+
`ExecStart=${systemdArgv(input.heartbeatCommand)}`,
|
|
11657
|
+
"NoNewPrivileges=yes",
|
|
11658
|
+
""
|
|
11659
|
+
].join("\n"), "0644"));
|
|
11660
|
+
steps.push(fileStep("heartbeat-timer", "Send the heartbeat every 2 minutes", `${UNIT_DIR}/mushi-runner-heartbeat.timer`, [
|
|
11661
|
+
"[Unit]",
|
|
11662
|
+
"Description=Mushi runner heartbeat every 2 minutes",
|
|
11663
|
+
"",
|
|
11664
|
+
"[Timer]",
|
|
11665
|
+
"OnBootSec=1min",
|
|
11666
|
+
"OnUnitActiveSec=2min",
|
|
11667
|
+
"",
|
|
11668
|
+
"[Install]",
|
|
11669
|
+
"WantedBy=timers.target",
|
|
11670
|
+
""
|
|
11671
|
+
].join("\n"), "0644"));
|
|
11672
|
+
const enable = [unit, "mushi-runner-heartbeat.timer"];
|
|
11673
|
+
if (input.blockLan) {
|
|
11674
|
+
const nftPath = `${ETC_DIR}/egress-${input.uid}.nft`;
|
|
11675
|
+
steps.push({
|
|
11676
|
+
id: "egress-check",
|
|
11677
|
+
title: "Check that nftables is installed",
|
|
11678
|
+
sudo: true,
|
|
11679
|
+
run: "command -v nft >/dev/null 2>&1 || { echo 'nftables is not installed: install it (for example sudo apt install nftables) or drop --block-lan' >&2; exit 1; }"
|
|
11680
|
+
});
|
|
11681
|
+
steps.push(fileStep("egress-rules", `Block the ${ru} user from your LAN and tailnet (nftables table of its own)`, nftPath, egressRules(input.uid, input.resolvers), "0644"));
|
|
11682
|
+
steps.push(fileStep("egress-service", "Load those rules at boot", `${UNIT_DIR}/mushi-runner-egress.service`, [
|
|
11683
|
+
"[Unit]",
|
|
11684
|
+
"Description=Mushi runner egress block (LAN, tailnet, link-local)",
|
|
11685
|
+
"Before=network-pre.target",
|
|
11686
|
+
"Wants=network-pre.target",
|
|
11687
|
+
"",
|
|
11688
|
+
"[Service]",
|
|
11689
|
+
"Type=oneshot",
|
|
11690
|
+
"RemainAfterExit=yes",
|
|
11691
|
+
`ExecStart=/usr/sbin/nft -f ${nftPath}`,
|
|
11692
|
+
`ExecStop=/usr/sbin/nft delete table inet mushi_runner_egress_${input.uid}`,
|
|
11693
|
+
"",
|
|
11694
|
+
"[Install]",
|
|
11695
|
+
"WantedBy=multi-user.target",
|
|
11696
|
+
""
|
|
11697
|
+
].join("\n"), "0644"));
|
|
11698
|
+
enable.unshift("mushi-runner-egress.service");
|
|
11699
|
+
}
|
|
11700
|
+
steps.push({
|
|
11701
|
+
id: "enable",
|
|
11702
|
+
title: "Start the runner and the heartbeat",
|
|
11703
|
+
sudo: true,
|
|
11704
|
+
run: `systemctl daemon-reload && systemctl enable --now ${enable.join(" ")}`
|
|
11705
|
+
});
|
|
11706
|
+
if (input.setVariable) {
|
|
11707
|
+
steps.push({
|
|
11708
|
+
id: "variable",
|
|
11709
|
+
title: `Set LINUX_RUNNER=${input.label} on ${input.repo} (your gh login)`,
|
|
11710
|
+
sudo: false,
|
|
11711
|
+
run: `gh variable set LINUX_RUNNER --repo ${input.repo} --body ${input.label}`
|
|
11712
|
+
});
|
|
11713
|
+
}
|
|
11714
|
+
const notes = [
|
|
11715
|
+
...notesForWorkflows(input.label, input.setVariable, input.repo),
|
|
11716
|
+
`Check it: mushi runner status (and journalctl -u ${unit} -f on this machine).`,
|
|
11717
|
+
"Stronger isolation (a fresh container per job) is not set up by this command yet; until then keep this runner on private repos you trust."
|
|
11718
|
+
];
|
|
11719
|
+
if (input.platform === "wsl2") notes.push(...wslNotes(input.wsl?.distro ?? null));
|
|
11720
|
+
return { refused: null, steps, warnings, notes };
|
|
11721
|
+
}
|
|
11722
|
+
function systemdArgv(argv) {
|
|
11723
|
+
return argv.map((a) => a.replace(/%/g, "%%")).map((a) => /[\s"'\\]/.test(a) ? `"${a.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : a).join(" ");
|
|
11724
|
+
}
|
|
11725
|
+
function egressRules(uid, resolvers) {
|
|
11726
|
+
const table = `mushi_runner_egress_${uid}`;
|
|
11727
|
+
const dns = [.../* @__PURE__ */ new Set(["10.255.255.254", ...resolvers])].filter((ip) => !ip.startsWith("127."));
|
|
11728
|
+
return [
|
|
11729
|
+
"#!/usr/sbin/nft -f",
|
|
11730
|
+
`# Mushi runner egress block for uid ${uid}. Its own table; other rules are untouched.`,
|
|
11731
|
+
`define MUSHI_RUNNER_UID = ${uid}`,
|
|
11732
|
+
`table inet ${table} {}`,
|
|
11733
|
+
`delete table inet ${table}`,
|
|
11734
|
+
`table inet ${table} {`,
|
|
11735
|
+
" chain output {",
|
|
11736
|
+
" type filter hook output priority 0; policy accept;",
|
|
11737
|
+
" meta skuid $MUSHI_RUNNER_UID oif lo accept",
|
|
11738
|
+
...dns.length ? [` meta skuid $MUSHI_RUNNER_UID ip daddr { ${dns.join(", ")} } meta l4proto { tcp, udp } th dport 53 accept`] : [],
|
|
11739
|
+
" meta skuid $MUSHI_RUNNER_UID ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, 169.254.0.0/16 } reject",
|
|
11740
|
+
" meta skuid $MUSHI_RUNNER_UID ip6 daddr { fc00::/7, fe80::/10 } reject",
|
|
11741
|
+
" }",
|
|
11742
|
+
"}",
|
|
11743
|
+
""
|
|
11744
|
+
].join("\n");
|
|
11745
|
+
}
|
|
11746
|
+
|
|
11747
|
+
// src/runner/audit.ts
|
|
11748
|
+
var BACKDOOR_NAME = /sha1[-_ ]?hulud|shai[-_ ]?hulud/i;
|
|
11749
|
+
function labelNames(r) {
|
|
11750
|
+
return (r.labels ?? []).map((l) => typeof l === "string" ? l : l.name).filter(Boolean);
|
|
11751
|
+
}
|
|
11752
|
+
function auditRepo(input) {
|
|
11753
|
+
const findings = [];
|
|
11754
|
+
const runners = (input.runners ?? []).map((r) => {
|
|
11755
|
+
const labels = labelNames(r);
|
|
11756
|
+
const setUpByMushi = labels.some((l) => l.toLowerCase().startsWith("mushi-")) || (input.heartbeatRunners?.has(r.name) ?? false);
|
|
11757
|
+
return { id: r.id, name: r.name, status: r.status ?? "unknown", busy: r.busy === true, labels, setUpByMushi };
|
|
11758
|
+
});
|
|
11759
|
+
if (input.runners === null) {
|
|
11760
|
+
findings.push({ severity: "info", message: `Could not list runners${input.runnersError ? ` (${input.runnersError})` : ""}. Listing them needs Administration: read on the repo.` });
|
|
11761
|
+
}
|
|
11762
|
+
for (const r of runners) {
|
|
11763
|
+
if (BACKDOOR_NAME.test(r.name) || r.labels.some((l) => BACKDOOR_NAME.test(l))) {
|
|
11764
|
+
findings.push({
|
|
11765
|
+
severity: "critical",
|
|
11766
|
+
message: `Runner "${r.name}" (id ${r.id}) is named like the Shai-Hulud 2.0 backdoor runner. Remove it (Settings \u2192 Actions \u2192 Runners), then check the machine it runs on and rotate your GitHub and npm tokens.`
|
|
11767
|
+
});
|
|
11768
|
+
} else if (!r.setUpByMushi) {
|
|
11769
|
+
findings.push({
|
|
11770
|
+
severity: "warn",
|
|
11771
|
+
message: `Runner "${r.name}" (id ${r.id}) was not set up by Mushi (no mushi- label, no Mushi heartbeat). Make sure you know which machine it is.`
|
|
11772
|
+
});
|
|
11773
|
+
}
|
|
11774
|
+
}
|
|
11775
|
+
if (input.isPublic === true && runners.length > 0) {
|
|
11776
|
+
findings.push({ severity: "warn", message: "This repo is public and has self-hosted runners: a fork pull request can run code on them." });
|
|
11777
|
+
}
|
|
11778
|
+
if (input.forkApproval === null) {
|
|
11779
|
+
findings.push({ severity: "info", message: "Could not read the fork pull request approval setting." });
|
|
11780
|
+
} else if (input.forkApproval !== "all_external_contributors") {
|
|
11781
|
+
findings.push({
|
|
11782
|
+
severity: "warn",
|
|
11783
|
+
message: `Fork pull requests run without approval for some outside contributors (${input.forkApproval}). Set "Require approval for all external contributors" in Settings \u2192 Actions \u2192 General.`
|
|
11784
|
+
});
|
|
11785
|
+
}
|
|
11786
|
+
if (input.prTargetWorkflows && input.prTargetWorkflows.length > 0) {
|
|
11787
|
+
findings.push({
|
|
11788
|
+
severity: "warn",
|
|
11789
|
+
message: `pull_request_target runs with your secrets on fork pull requests: ${input.prTargetWorkflows.join(", ")}. Keep these off self-hosted runners and never check out the pull request's code in them.`
|
|
11790
|
+
});
|
|
11791
|
+
}
|
|
11792
|
+
return { repo: input.repo, runners, findings };
|
|
11793
|
+
}
|
|
11794
|
+
function workflowsWithPrTarget(files) {
|
|
11795
|
+
return files.filter((f) => f.text.split("\n").some((line) => {
|
|
11796
|
+
const code = line.replace(/#.*$/, "");
|
|
11797
|
+
return /\bpull_request_target\b/.test(code);
|
|
11798
|
+
})).map((f) => f.name);
|
|
11799
|
+
}
|
|
11800
|
+
|
|
11801
|
+
// src/runner/github.ts
|
|
11802
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
11803
|
+
var nodeGh = (args) => {
|
|
11804
|
+
const res = spawnSync2("gh", args, { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
|
|
11805
|
+
if (res.error) {
|
|
11806
|
+
const missing = res.error.code === "ENOENT";
|
|
11807
|
+
return { ok: false, error: missing ? "gh is not installed (https://cli.github.com)" : res.error.message };
|
|
11808
|
+
}
|
|
11809
|
+
if (res.status !== 0) {
|
|
11810
|
+
const first = (res.stderr || res.stdout || "").trim().split("\n")[0] ?? "";
|
|
11811
|
+
return { ok: false, error: first.slice(0, 200) || `gh exited ${res.status}` };
|
|
11812
|
+
}
|
|
11813
|
+
return { ok: true, stdout: res.stdout };
|
|
11814
|
+
};
|
|
11815
|
+
function ghAuthStatus() {
|
|
11816
|
+
const res = spawnSync2("gh", ["auth", "status"], { encoding: "utf8", timeout: 15e3, stdio: ["ignore", "pipe", "pipe"] });
|
|
11817
|
+
return { ok: !res.error && res.status === 0, text: `${res.stdout ?? ""}${res.stderr ?? ""}` };
|
|
11818
|
+
}
|
|
11819
|
+
function repoIsPublic(gh, repo) {
|
|
11820
|
+
const r = gh(["api", `repos/${repo}`, "--jq", ".private"]);
|
|
11821
|
+
if (!r.ok) return null;
|
|
11822
|
+
const v = r.stdout.trim();
|
|
11823
|
+
return v === "true" ? false : v === "false" ? true : null;
|
|
11824
|
+
}
|
|
11825
|
+
function forkApprovalPolicy(gh, repo) {
|
|
11826
|
+
const r = gh(["api", `repos/${repo}/actions/permissions/fork-pr-contributor-approval`, "--jq", ".approval_policy"]);
|
|
11827
|
+
if (!r.ok) return null;
|
|
11828
|
+
const v = r.stdout.trim();
|
|
11829
|
+
return v && v !== "null" ? v : null;
|
|
11830
|
+
}
|
|
11831
|
+
function prTargetWorkflows(gh, repo) {
|
|
11832
|
+
const list = gh(["api", `repos/${repo}/contents/.github/workflows`, "--jq", '.[] | select(.type == "file") | .path']);
|
|
11833
|
+
if (!list.ok) return /404|Not Found/i.test(list.error) ? [] : null;
|
|
11834
|
+
const paths = list.stdout.split("\n").map((s) => s.trim()).filter((p4) => /\.ya?ml$/.test(p4)).slice(0, 30);
|
|
11835
|
+
const files = [];
|
|
11836
|
+
for (const path of paths) {
|
|
11837
|
+
const r = gh(["api", `repos/${repo}/contents/${path}`, "--jq", ".content"]);
|
|
11838
|
+
if (!r.ok) return null;
|
|
11839
|
+
files.push({ name: path.split("/").pop(), text: Buffer.from(r.stdout.replace(/\\n|\s/g, ""), "base64").toString("utf8") });
|
|
11840
|
+
}
|
|
11841
|
+
return workflowsWithPrTarget(files);
|
|
11842
|
+
}
|
|
11843
|
+
function latestRunnerRelease(gh, target) {
|
|
11844
|
+
const r = gh(["api", "repos/actions/runner/releases/latest"]);
|
|
11845
|
+
if (!r.ok) return null;
|
|
11846
|
+
try {
|
|
11847
|
+
const data = JSON.parse(r.stdout);
|
|
11848
|
+
const version = data.tag_name?.replace(/^v/, "");
|
|
11849
|
+
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) return null;
|
|
11850
|
+
const sha256 = parseRunnerSha(data.body ?? "", target);
|
|
11851
|
+
return sha256 ? { version, sha256 } : null;
|
|
11852
|
+
} catch {
|
|
11853
|
+
return null;
|
|
11854
|
+
}
|
|
11855
|
+
}
|
|
11856
|
+
function listRunners(gh, repo) {
|
|
11857
|
+
const r = gh(["api", "--paginate", `repos/${repo}/actions/runners`, "--jq", ".runners[]"]);
|
|
11858
|
+
if (!r.ok) return { runners: null, error: r.error };
|
|
11859
|
+
const runners = [];
|
|
11860
|
+
for (const line of r.stdout.split("\n")) {
|
|
11861
|
+
if (!line.trim()) continue;
|
|
11862
|
+
try {
|
|
11863
|
+
runners.push(JSON.parse(line));
|
|
11864
|
+
} catch {
|
|
11865
|
+
}
|
|
11866
|
+
}
|
|
11867
|
+
return { runners, error: null };
|
|
11868
|
+
}
|
|
11869
|
+
function listOwnRepos(gh) {
|
|
11870
|
+
const r = gh(["repo", "list", "--limit", "200", "--json", "nameWithOwner", "--jq", ".[].nameWithOwner"]);
|
|
11871
|
+
if (!r.ok) return null;
|
|
11872
|
+
return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
11873
|
+
}
|
|
11874
|
+
function currentRepo(gh) {
|
|
11875
|
+
const r = gh(["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]);
|
|
11876
|
+
return r.ok ? r.stdout.trim() || null : null;
|
|
11877
|
+
}
|
|
11878
|
+
function registrationToken(gh, repo) {
|
|
11879
|
+
return gh(["api", "-X", "POST", `repos/${repo}/actions/runners/registration-token`, "--jq", ".token"]);
|
|
11880
|
+
}
|
|
11881
|
+
|
|
11882
|
+
// src/runner/host.ts
|
|
11883
|
+
import { createHash as createHash2 } from "crypto";
|
|
11884
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
11885
|
+
import { readFileSync as readFileSync7, statfsSync } from "fs";
|
|
11886
|
+
import { cpus, freemem, hostname, loadavg, totalmem } from "os";
|
|
11887
|
+
import { dirname as dirname4, join as join11 } from "path";
|
|
11888
|
+
var nodeHostIO = {
|
|
11889
|
+
platform: process.platform,
|
|
11890
|
+
readFile(path) {
|
|
11891
|
+
try {
|
|
11892
|
+
return readFileSync7(path, "utf8");
|
|
11893
|
+
} catch {
|
|
11894
|
+
return null;
|
|
11895
|
+
}
|
|
11896
|
+
},
|
|
11897
|
+
exec(cmd, args) {
|
|
11898
|
+
try {
|
|
11899
|
+
return execFileSync4(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 1e4 });
|
|
11900
|
+
} catch {
|
|
11901
|
+
return null;
|
|
11902
|
+
}
|
|
11903
|
+
},
|
|
11904
|
+
diskFreeBytes(path) {
|
|
11905
|
+
try {
|
|
11906
|
+
const s = statfsSync(path);
|
|
11907
|
+
return Number(s.bavail) * Number(s.bsize);
|
|
11908
|
+
} catch {
|
|
11909
|
+
return null;
|
|
11910
|
+
}
|
|
11911
|
+
},
|
|
11912
|
+
// Getters, so a heartbeat loop reads fresh values every tick.
|
|
11913
|
+
get cpuCount() {
|
|
11914
|
+
return cpus().length;
|
|
11915
|
+
},
|
|
11916
|
+
get totalMemBytes() {
|
|
11917
|
+
return totalmem();
|
|
11918
|
+
},
|
|
11919
|
+
get freeMemBytes() {
|
|
11920
|
+
return freemem();
|
|
11921
|
+
},
|
|
11922
|
+
get load1() {
|
|
11923
|
+
return loadavg()[0] ?? 0;
|
|
11924
|
+
},
|
|
11925
|
+
get hostname() {
|
|
11926
|
+
return hostname();
|
|
11927
|
+
}
|
|
11928
|
+
};
|
|
11929
|
+
var HOST_ID_SALT = "mushi-runner";
|
|
11930
|
+
var MAX_RUNNERS = 40;
|
|
11931
|
+
function detectPlatform(io) {
|
|
11932
|
+
if (io.platform === "darwin") return "macos";
|
|
11933
|
+
if (io.platform === "win32") return "windows";
|
|
11934
|
+
const version = io.readFile("/proc/version") ?? "";
|
|
11935
|
+
return /microsoft/i.test(version) ? "wsl2" : "linux";
|
|
11936
|
+
}
|
|
11937
|
+
function readMachineId(io) {
|
|
11938
|
+
if (io.platform === "darwin") {
|
|
11939
|
+
const out = io.exec("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]);
|
|
11940
|
+
return out?.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1] ?? null;
|
|
11941
|
+
}
|
|
11942
|
+
for (const path of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
11943
|
+
const id = io.readFile(path)?.trim();
|
|
11944
|
+
if (id) return id;
|
|
11945
|
+
}
|
|
11946
|
+
return null;
|
|
11947
|
+
}
|
|
11948
|
+
function wslMachineId(io, platform) {
|
|
11949
|
+
if (platform !== "wsl2") return null;
|
|
11950
|
+
const boot = io.readFile("/proc/sys/kernel/random/boot_id")?.trim();
|
|
11951
|
+
return boot ? hashHostId(`wsl-vm:${boot}`) : null;
|
|
11952
|
+
}
|
|
11953
|
+
function hashHostId(rawId) {
|
|
11954
|
+
return createHash2("sha256").update(`${rawId.trim()}${HOST_ID_SALT}`).digest("hex");
|
|
11955
|
+
}
|
|
11956
|
+
function parseMeminfo(text2) {
|
|
11957
|
+
const kb = (key) => {
|
|
11958
|
+
const m = text2.match(new RegExp(`^${key}:\\s+(\\d+)\\s*kB`, "m"));
|
|
11959
|
+
return m ? Number(m[1]) : null;
|
|
11960
|
+
};
|
|
11961
|
+
const total = kb("MemTotal");
|
|
11962
|
+
if (total == null) return null;
|
|
11963
|
+
const available = kb("MemAvailable") ?? kb("MemFree") ?? 0;
|
|
11964
|
+
return {
|
|
11965
|
+
totalMb: Math.round(total / 1024),
|
|
11966
|
+
availableMb: Math.round(available / 1024),
|
|
11967
|
+
swapTotalMb: Math.round((kb("SwapTotal") ?? 0) / 1024),
|
|
11968
|
+
swapFreeMb: Math.round((kb("SwapFree") ?? 0) / 1024)
|
|
11969
|
+
};
|
|
11970
|
+
}
|
|
11971
|
+
function parseLoadavg(text2) {
|
|
11972
|
+
const v = Number(text2.trim().split(/\s+/)[0]);
|
|
11973
|
+
return Number.isFinite(v) ? v : null;
|
|
11974
|
+
}
|
|
11975
|
+
function parseSwapUsage(text2) {
|
|
11976
|
+
const total = text2.match(/total\s*=\s*([\d.]+)M/);
|
|
11977
|
+
const used = text2.match(/used\s*=\s*([\d.]+)M/);
|
|
11978
|
+
if (!total || !used) return null;
|
|
11979
|
+
return { totalMb: Math.round(Number(total[1])), usedMb: Math.round(Number(used[1])) };
|
|
11980
|
+
}
|
|
11981
|
+
var MUSHI_HELPER_UNITS = /* @__PURE__ */ new Set(["mushi-runner-heartbeat.service", "mushi-runner-egress.service"]);
|
|
11982
|
+
function parseSystemctlUnits(text2) {
|
|
11983
|
+
const out = [];
|
|
11984
|
+
for (const line of text2.split("\n")) {
|
|
11985
|
+
const unit = line.trim().split(/\s+/).find((tok) => /^actions\.runner\..+\.service$/.test(tok) || /^mushi-runner-.+\.service$/.test(tok) && !MUSHI_HELPER_UNITS.has(tok));
|
|
11986
|
+
if (unit && !out.includes(unit)) out.push(unit);
|
|
11987
|
+
}
|
|
11988
|
+
return out;
|
|
11989
|
+
}
|
|
11990
|
+
function parseMushiUnitDescription(description) {
|
|
11991
|
+
const m = description?.match(/^Mushi GitHub Actions runner (\S+) for ([\w.-]+\/[\w.-]+)/);
|
|
11992
|
+
return m ? { name: m[1], repo: m[2] } : null;
|
|
11993
|
+
}
|
|
11994
|
+
function parseSystemctlShow(text2) {
|
|
11995
|
+
const out = {};
|
|
11996
|
+
for (const line of text2.split("\n")) {
|
|
11997
|
+
const eq = line.indexOf("=");
|
|
11998
|
+
if (eq > 0) out[line.slice(0, eq)] = line.slice(eq + 1).trim();
|
|
11999
|
+
}
|
|
12000
|
+
return out;
|
|
12001
|
+
}
|
|
12002
|
+
function parseUnitName(unit) {
|
|
12003
|
+
const m = unit.match(/^actions\.runner\.(.+)\.service$/);
|
|
12004
|
+
if (!m) return null;
|
|
12005
|
+
const middle = m[1];
|
|
12006
|
+
const dot = middle.lastIndexOf(".");
|
|
12007
|
+
if (dot <= 0) return { name: middle, repo: null };
|
|
12008
|
+
const name = middle.slice(dot + 1);
|
|
12009
|
+
const ownerRepo = middle.slice(0, dot);
|
|
12010
|
+
const dashes = ownerRepo.split("-").length - 1;
|
|
12011
|
+
if (dashes !== 1) return { name, repo: null };
|
|
12012
|
+
const [owner, repo] = ownerRepo.split("-");
|
|
12013
|
+
return { name, repo: owner && repo ? `${owner}/${repo}` : null };
|
|
12014
|
+
}
|
|
12015
|
+
function runnerDirFromShow(props) {
|
|
12016
|
+
const wd = props["WorkingDirectory"];
|
|
12017
|
+
if (wd && wd.startsWith("/")) return wd;
|
|
12018
|
+
const path = props["ExecStart"]?.match(/path=([^\s;]+)/)?.[1];
|
|
12019
|
+
return path && path.startsWith("/") ? dirname4(path) : null;
|
|
12020
|
+
}
|
|
12021
|
+
function parseRunnerFile(text2) {
|
|
12022
|
+
let data;
|
|
12023
|
+
try {
|
|
12024
|
+
data = JSON.parse(text2.replace(/^/, ""));
|
|
12025
|
+
} catch {
|
|
12026
|
+
return null;
|
|
12027
|
+
}
|
|
12028
|
+
if (!data || typeof data !== "object") return null;
|
|
12029
|
+
const d = data;
|
|
12030
|
+
const name = typeof d["agentName"] === "string" ? d["agentName"] : null;
|
|
12031
|
+
let repo = null;
|
|
12032
|
+
if (typeof d["gitHubUrl"] === "string") {
|
|
12033
|
+
try {
|
|
12034
|
+
const parts = new URL(d["gitHubUrl"]).pathname.split("/").filter(Boolean);
|
|
12035
|
+
if (parts.length >= 2) repo = `${parts[0]}/${parts[1]}`;
|
|
12036
|
+
} catch {
|
|
12037
|
+
repo = null;
|
|
12038
|
+
}
|
|
12039
|
+
}
|
|
12040
|
+
return { name, repo };
|
|
12041
|
+
}
|
|
12042
|
+
var MB = 1024 * 1024;
|
|
12043
|
+
function bytesToMb(text2) {
|
|
12044
|
+
const t = text2?.trim();
|
|
12045
|
+
if (!t || t === "max") return null;
|
|
12046
|
+
const n = Number(t);
|
|
12047
|
+
return Number.isFinite(n) ? Math.round(n / MB) : null;
|
|
12048
|
+
}
|
|
12049
|
+
function parseOomKills(eventsText) {
|
|
12050
|
+
const m = eventsText?.match(/^oom_kill\s+(\d+)/m);
|
|
12051
|
+
return m ? Number(m[1]) : null;
|
|
12052
|
+
}
|
|
12053
|
+
function readCgroupMemory(io, controlGroup) {
|
|
12054
|
+
if (!controlGroup || !controlGroup.startsWith("/")) return { mem_current_mb: null, mem_max_mb: null, oom_kills: null };
|
|
12055
|
+
const dir = join11("/sys/fs/cgroup", controlGroup);
|
|
12056
|
+
const slice = dirname4(dir);
|
|
12057
|
+
const max = bytesToMb(io.readFile(join11(dir, "memory.max"))) ?? (slice !== "/sys/fs/cgroup" ? bytesToMb(io.readFile(join11(slice, "memory.max"))) : null);
|
|
12058
|
+
return {
|
|
12059
|
+
mem_current_mb: bytesToMb(io.readFile(join11(dir, "memory.current"))),
|
|
12060
|
+
mem_max_mb: max,
|
|
12061
|
+
oom_kills: parseOomKills(io.readFile(join11(dir, "memory.events")))
|
|
12062
|
+
};
|
|
12063
|
+
}
|
|
12064
|
+
function linuxRunners(io) {
|
|
12065
|
+
const list = io.exec("systemctl", ["list-units", "--all", "actions.runner.*", "mushi-runner-*", "--no-legend", "--plain"]);
|
|
12066
|
+
if (!list) return [];
|
|
12067
|
+
const out = [];
|
|
12068
|
+
for (const unit of parseSystemctlUnits(list).slice(0, MAX_RUNNERS)) {
|
|
12069
|
+
const props = parseSystemctlShow(
|
|
12070
|
+
io.exec("systemctl", ["show", unit, "-p", "ActiveState", "-p", "NRestarts", "-p", "ControlGroup", "-p", "WorkingDirectory", "-p", "ExecStart", "-p", "Description"]) ?? ""
|
|
12071
|
+
);
|
|
12072
|
+
const fromName = parseMushiUnitDescription(props["Description"]) ?? parseUnitName(unit);
|
|
12073
|
+
const dir = runnerDirFromShow(props);
|
|
12074
|
+
const fromFile = dir ? parseRunnerFile(io.readFile(join11(dir, ".runner")) ?? "") : null;
|
|
12075
|
+
const restarts = Number(props["NRestarts"]);
|
|
12076
|
+
out.push({
|
|
12077
|
+
name: (fromFile?.name ?? fromName?.name ?? unit).slice(0, 100),
|
|
12078
|
+
repo: fromFile?.repo ?? fromName?.repo ?? null,
|
|
12079
|
+
service: unit,
|
|
12080
|
+
active: props["ActiveState"] === "active",
|
|
12081
|
+
restarts: Number.isFinite(restarts) && props["NRestarts"] !== "" && props["NRestarts"] !== void 0 ? restarts : null,
|
|
12082
|
+
...readCgroupMemory(io, props["ControlGroup"])
|
|
12083
|
+
});
|
|
12084
|
+
}
|
|
12085
|
+
return out;
|
|
12086
|
+
}
|
|
12087
|
+
function parseLaunchctlList(text2) {
|
|
12088
|
+
const out = [];
|
|
12089
|
+
for (const line of text2.split("\n")) {
|
|
12090
|
+
const [pid, , label] = line.trim().split(/\s+/);
|
|
12091
|
+
if (label && label.startsWith("actions.runner.")) out.push({ label, running: pid !== void 0 && /^\d+$/.test(pid) });
|
|
12092
|
+
}
|
|
12093
|
+
return out;
|
|
12094
|
+
}
|
|
12095
|
+
function macRunners(io) {
|
|
12096
|
+
const list = io.exec("launchctl", ["list"]);
|
|
12097
|
+
if (!list) return [];
|
|
12098
|
+
return parseLaunchctlList(list).slice(0, MAX_RUNNERS).map(({ label, running }) => {
|
|
12099
|
+
const parsed = parseUnitName(`${label}.service`);
|
|
12100
|
+
return {
|
|
12101
|
+
name: (parsed?.name ?? label).slice(0, 100),
|
|
12102
|
+
repo: parsed?.repo ?? null,
|
|
12103
|
+
service: label,
|
|
12104
|
+
active: running,
|
|
12105
|
+
restarts: null,
|
|
12106
|
+
mem_current_mb: null,
|
|
12107
|
+
mem_max_mb: null,
|
|
12108
|
+
oom_kills: null
|
|
12109
|
+
};
|
|
12110
|
+
});
|
|
12111
|
+
}
|
|
12112
|
+
var UnsupportedHostError = class extends Error {
|
|
12113
|
+
};
|
|
12114
|
+
function collectHeartbeat(io, mushiVersion) {
|
|
12115
|
+
const platform = detectPlatform(io);
|
|
12116
|
+
if (platform === "windows") {
|
|
12117
|
+
throw new UnsupportedHostError("The heartbeat runs on Linux, WSL2 and macOS. On Windows, run your runner inside WSL2 and run this there.");
|
|
12118
|
+
}
|
|
12119
|
+
const rawId = readMachineId(io);
|
|
12120
|
+
if (!rawId) throw new UnsupportedHostError("Could not read a machine id (/etc/machine-id or IOPlatformUUID).");
|
|
12121
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
12122
|
+
let memTotalMb = Math.round(io.totalMemBytes / MB);
|
|
12123
|
+
let memUsedMb = Math.round((io.totalMemBytes - io.freeMemBytes) / MB);
|
|
12124
|
+
let swapUsedMb = 0;
|
|
12125
|
+
let swapTotalMb = 0;
|
|
12126
|
+
let load1 = io.load1;
|
|
12127
|
+
if (platform === "macos") {
|
|
12128
|
+
const swap = parseSwapUsage(io.exec("sysctl", ["vm.swapusage"]) ?? "");
|
|
12129
|
+
if (swap) {
|
|
12130
|
+
swapUsedMb = swap.usedMb;
|
|
12131
|
+
swapTotalMb = swap.totalMb;
|
|
12132
|
+
}
|
|
12133
|
+
} else {
|
|
12134
|
+
const mem = parseMeminfo(io.readFile("/proc/meminfo") ?? "");
|
|
12135
|
+
if (mem) {
|
|
12136
|
+
memTotalMb = mem.totalMb;
|
|
12137
|
+
memUsedMb = mem.totalMb - mem.availableMb;
|
|
12138
|
+
swapTotalMb = mem.swapTotalMb;
|
|
12139
|
+
swapUsedMb = mem.swapTotalMb - mem.swapFreeMb;
|
|
12140
|
+
}
|
|
12141
|
+
load1 = parseLoadavg(io.readFile("/proc/loadavg") ?? "") ?? load1;
|
|
12142
|
+
}
|
|
12143
|
+
const diskFree = io.diskFreeBytes("/");
|
|
12144
|
+
const machineId = wslMachineId(io, platform);
|
|
12145
|
+
return {
|
|
12146
|
+
kind: "heartbeat",
|
|
12147
|
+
host: {
|
|
12148
|
+
host_id: hashHostId(rawId),
|
|
12149
|
+
hostname: io.hostname.slice(0, 80),
|
|
12150
|
+
platform,
|
|
12151
|
+
cpu_count: io.cpuCount,
|
|
12152
|
+
mem_total_mb: memTotalMb,
|
|
12153
|
+
mushi_version: mushiVersion.slice(0, 40),
|
|
12154
|
+
metrics: {
|
|
12155
|
+
load1: round1(load1),
|
|
12156
|
+
mem_used_mb: Math.max(0, memUsedMb),
|
|
12157
|
+
swap_used_mb: Math.max(0, swapUsedMb),
|
|
12158
|
+
swap_total_mb: swapTotalMb,
|
|
12159
|
+
disk_free_gb: diskFree == null ? 0 : round1(diskFree / 1024 ** 3),
|
|
12160
|
+
...machineId ? { machine_id: machineId } : {}
|
|
12161
|
+
}
|
|
12162
|
+
},
|
|
12163
|
+
runners: platform === "macos" ? macRunners(io) : linuxRunners(io)
|
|
12164
|
+
};
|
|
12165
|
+
}
|
|
12166
|
+
|
|
12167
|
+
// src/runner/overview.ts
|
|
12168
|
+
function field(row, ...keys) {
|
|
12169
|
+
if (!row) return void 0;
|
|
12170
|
+
for (const k of keys) {
|
|
12171
|
+
if (row[k] !== void 0 && row[k] !== null) return row[k];
|
|
12172
|
+
}
|
|
12173
|
+
return void 0;
|
|
12174
|
+
}
|
|
12175
|
+
function num2(row, ...keys) {
|
|
12176
|
+
const v = field(row, ...keys);
|
|
12177
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
12178
|
+
}
|
|
12179
|
+
function str(row, ...keys) {
|
|
12180
|
+
const v = field(row, ...keys);
|
|
12181
|
+
return typeof v === "string" && v ? v : null;
|
|
12182
|
+
}
|
|
12183
|
+
function fmtSeconds(s) {
|
|
12184
|
+
if (s == null || !Number.isFinite(s)) return "unknown";
|
|
12185
|
+
if (s < 90) return `${Math.round(s)} s`;
|
|
12186
|
+
if (s < 90 * 60) return `${Math.round(s / 60)} min`;
|
|
12187
|
+
return `${(s / 3600).toFixed(1)} h`;
|
|
12188
|
+
}
|
|
12189
|
+
function fmtMb(mb) {
|
|
12190
|
+
if (mb == null) return "?";
|
|
12191
|
+
return mb >= 1024 ? `${(mb / 1024).toFixed(1)} GB` : `${Math.round(mb)} MB`;
|
|
12192
|
+
}
|
|
12193
|
+
function fmtAgo(iso, now) {
|
|
12194
|
+
if (!iso) return "never";
|
|
12195
|
+
const t = Date.parse(iso);
|
|
12196
|
+
if (!Number.isFinite(t)) return "unknown";
|
|
12197
|
+
const s = Math.max(0, (now.getTime() - t) / 1e3);
|
|
12198
|
+
if (s < 90) return "just now";
|
|
12199
|
+
return `${fmtSeconds(s)} ago`;
|
|
12200
|
+
}
|
|
12201
|
+
function windowDays(w) {
|
|
12202
|
+
if (typeof w === "number") return w;
|
|
12203
|
+
if (w && typeof w === "object" && typeof w.days === "number") return w.days;
|
|
12204
|
+
return null;
|
|
12205
|
+
}
|
|
12206
|
+
function hostRunnerStats(hosts) {
|
|
12207
|
+
const out = /* @__PURE__ */ new Map();
|
|
12208
|
+
for (const h of hosts) {
|
|
12209
|
+
const runners = field(h, "runners");
|
|
12210
|
+
if (!Array.isArray(runners)) continue;
|
|
12211
|
+
for (const r of runners) {
|
|
12212
|
+
const name = str(r, "name");
|
|
12213
|
+
if (name) out.set(name, r);
|
|
12214
|
+
}
|
|
12215
|
+
}
|
|
12216
|
+
return out;
|
|
12217
|
+
}
|
|
12218
|
+
function runnerState(r) {
|
|
12219
|
+
const status = str(r, "status") ?? "unknown";
|
|
12220
|
+
const busy = field(r, "busy") === true;
|
|
12221
|
+
if (status === "online") return busy ? "BUSY" : "ONLINE";
|
|
12222
|
+
if (status === "offline") return "OFFLINE";
|
|
12223
|
+
return "SEEN";
|
|
12224
|
+
}
|
|
12225
|
+
function renderOverview(data, now = /* @__PURE__ */ new Date()) {
|
|
12226
|
+
const lines = [];
|
|
12227
|
+
const days = windowDays(data.window);
|
|
12228
|
+
lines.push(`CI over the last ${days ?? "?"} day(s), where your fixes are tested.`);
|
|
12229
|
+
const hosts = data.hosts ?? [];
|
|
12230
|
+
const runners = data.runners ?? [];
|
|
12231
|
+
const fromHosts = hostRunnerStats(hosts);
|
|
12232
|
+
lines.push("");
|
|
12233
|
+
lines.push("Machines");
|
|
12234
|
+
if (hosts.length === 0) {
|
|
12235
|
+
lines.push(" No machine has sent a heartbeat. Run `mushi runner heartbeat --once` on a runner machine to add one.");
|
|
12236
|
+
}
|
|
12237
|
+
for (const h of hosts) {
|
|
12238
|
+
const name = oneLine(str(h, "hostname") ?? "(unnamed)", 40);
|
|
12239
|
+
const platform = str(h, "platform") ?? "?";
|
|
12240
|
+
const cpu = num2(h, "cpuCount", "cpu_count");
|
|
12241
|
+
const memTotal = num2(h, "memTotalMb", "mem_total_mb");
|
|
12242
|
+
const metrics = field(h, "metrics") ?? null;
|
|
12243
|
+
const parts = [
|
|
12244
|
+
`${platform}${cpu != null ? `, ${cpu} CPU` : ""}${memTotal != null ? `, ${fmtMb(memTotal)}` : ""}`,
|
|
12245
|
+
`last heartbeat ${fmtAgo(str(h, "lastHeartbeatAt", "last_heartbeat_at"), now)}${field(h, "stale") === true ? " (stale: is the machine off?)" : ""}`
|
|
12246
|
+
];
|
|
12247
|
+
const load = num2(metrics, "load1");
|
|
12248
|
+
if (load != null) parts.push(`load ${load.toFixed(1)}`);
|
|
12249
|
+
const memUsed = num2(metrics, "memUsedMb", "mem_used_mb");
|
|
12250
|
+
if (memUsed != null) parts.push(`memory ${fmtMb(memUsed)} of ${fmtMb(memTotal)}`);
|
|
12251
|
+
const swap = num2(metrics, "swapUsedMb", "swap_used_mb");
|
|
12252
|
+
if (swap != null && swap > 0) parts.push(`swap ${fmtMb(swap)} used`);
|
|
12253
|
+
const disk = num2(metrics, "diskFreeGb", "disk_free_gb");
|
|
12254
|
+
if (disk != null) parts.push(`${disk.toFixed(0)} GB disk free`);
|
|
12255
|
+
lines.push(` ${name} (${parts.join(" \xB7 ")})`);
|
|
12256
|
+
}
|
|
12257
|
+
lines.push("");
|
|
12258
|
+
lines.push("Runners");
|
|
12259
|
+
if (runners.length === 0) lines.push(" No runner seen in this window.");
|
|
12260
|
+
for (const r of runners) {
|
|
12261
|
+
const name = str(r, "name", "runnerName", "runner_name") ?? "(unnamed)";
|
|
12262
|
+
const repo = str(r, "repo");
|
|
12263
|
+
const host = fromHosts.get(name);
|
|
12264
|
+
const extra = [];
|
|
12265
|
+
const memCur = num2(host, "memCurrentMb", "mem_current_mb");
|
|
12266
|
+
const memMax = num2(host, "memMaxMb", "mem_max_mb");
|
|
12267
|
+
if (memCur != null) extra.push(`memory ${fmtMb(memCur)}${memMax != null ? ` of ${fmtMb(memMax)}` : ""}`);
|
|
12268
|
+
const oom = num2(host, "oomKills", "oom_kills") ?? num2(r, "oomKills", "oom_kills");
|
|
12269
|
+
if (oom != null && oom > 0) extra.push(`${oom} out-of-memory kill(s)`);
|
|
12270
|
+
if (host && field(host, "active") === false) extra.push("service stopped");
|
|
12271
|
+
const lastJob = str(r, "lastJobAt", "last_job_at");
|
|
12272
|
+
if (lastJob) extra.push(`last job ${fmtAgo(lastJob, now)}`);
|
|
12273
|
+
lines.push(` ${runnerState(r).padEnd(8)} ${oneLine(name, 40)}${repo ? ` ${oneLine(repo, 60)}` : ""}${extra.length ? ` (${extra.join(" \xB7 ")})` : ""}`);
|
|
12274
|
+
}
|
|
12275
|
+
const s = data.stats ?? {};
|
|
12276
|
+
lines.push("");
|
|
12277
|
+
lines.push(`Waiting for a runner: half of jobs started within ${fmtSeconds(s.queueP50s)}, 95% within ${fmtSeconds(s.queueP95s)}.`);
|
|
12278
|
+
lines.push(`Run time: half finished within ${fmtSeconds(s.durationP50s)}, 95% within ${fmtSeconds(s.durationP95s)}.`);
|
|
12279
|
+
const infra = s.infraFailures ?? 0;
|
|
12280
|
+
const code = s.codeFailures ?? 0;
|
|
12281
|
+
const pass = typeof s.passRate === "number" ? ` Pass rate ${Math.round(s.passRate <= 1 ? s.passRate * 100 : s.passRate)}%.` : "";
|
|
12282
|
+
lines.push(`Failures: ${infra} where the test machine stopped (your fix was not tested), ${code} in your code.${pass}`);
|
|
12283
|
+
const perms = data.permissions ?? null;
|
|
12284
|
+
if (perms?.jobs === "missing") {
|
|
12285
|
+
lines.push("Mushi cannot read your CI jobs: give its GitHub connection the Actions: read permission.");
|
|
12286
|
+
}
|
|
12287
|
+
if (perms?.runners === "missing") {
|
|
12288
|
+
lines.push("Runner online/offline status needs the Administration: read permission on GitHub; showing runners seen in jobs instead.");
|
|
12289
|
+
}
|
|
12290
|
+
const advice = data.advice ?? [];
|
|
12291
|
+
lines.push("");
|
|
12292
|
+
if (advice.length === 0) {
|
|
12293
|
+
lines.push("No advice: nothing in this window looks slow or broken.");
|
|
12294
|
+
} else {
|
|
12295
|
+
lines.push("What to do first");
|
|
12296
|
+
for (const a of advice.slice(0, 3)) {
|
|
12297
|
+
lines.push(` ${String(a.severity).toUpperCase().padEnd(6)} ${oneLine(a.title, 110)}`);
|
|
12298
|
+
if (a.detail) lines.push(` ${oneLine(a.detail, 140)}`);
|
|
12299
|
+
if (a.fix) lines.push(` Fix: ${oneLine(a.fix, 140)}`);
|
|
12300
|
+
}
|
|
12301
|
+
if (advice.length > 3) lines.push(` \u2026and ${advice.length - 3} more (--json shows all, with the evidence for each).`);
|
|
12302
|
+
}
|
|
12303
|
+
return lines;
|
|
12304
|
+
}
|
|
12305
|
+
|
|
12306
|
+
// src/runner/compare.ts
|
|
12307
|
+
function formatMoney(n, currency = "USD") {
|
|
12308
|
+
if (typeof n !== "number" || !Number.isFinite(n)) return "\u2014";
|
|
12309
|
+
const abs = Math.abs(n).toFixed(2);
|
|
12310
|
+
const sign = n < 0 ? "\u2212" : "";
|
|
12311
|
+
return currency === "USD" ? `${sign}$${abs}` : `${sign}${abs} ${currency}`;
|
|
12312
|
+
}
|
|
12313
|
+
function formatDuration(s) {
|
|
12314
|
+
if (typeof s !== "number" || !Number.isFinite(s)) return "\u2014";
|
|
12315
|
+
if (s < 90) return `${Math.round(s)} s`;
|
|
12316
|
+
if (s < 90 * 60) return `${Math.round(s / 60)} min`;
|
|
12317
|
+
return `${Math.round(s / 3600 * 10) / 10} h`;
|
|
12318
|
+
}
|
|
12319
|
+
function pct(n) {
|
|
12320
|
+
return typeof n === "number" && Number.isFinite(n) ? `${Math.round(n * 100)}%` : "\u2014";
|
|
12321
|
+
}
|
|
12322
|
+
function runsOnLabel(r) {
|
|
12323
|
+
const labels = (r.labels ?? []).filter((l) => l.toLowerCase() !== "self-hosted").join(", ");
|
|
12324
|
+
if (r.runnerClass === "github_hosted") return `GitHub-hosted${labels ? ` (${labels})` : r.os ? ` (${r.os})` : ""}`;
|
|
12325
|
+
if (r.runnerClass === "self_hosted") {
|
|
12326
|
+
const who = r.runner || labels || "self-hosted";
|
|
12327
|
+
return `Self-hosted ${who}${r.machine && r.machine !== r.runner ? ` on ${r.machine}` : ""}`;
|
|
12328
|
+
}
|
|
12329
|
+
return `Never picked up${labels ? ` (${labels})` : ""}`;
|
|
12330
|
+
}
|
|
12331
|
+
var pad2 = (s, n) => s.length >= n ? `${s.slice(0, n - 1)}\u2026` : s.padEnd(n);
|
|
12332
|
+
function renderCompare(data, opts = {}) {
|
|
12333
|
+
const lines = [];
|
|
12334
|
+
const days = typeof data.window === "object" && data.window ? data.window.days ?? null : null;
|
|
12335
|
+
const vis = new Map((data.repos ?? []).map((r2) => [r2.repo, r2.visibility ?? "unknown"]));
|
|
12336
|
+
lines.push(`CI cost and speed${days ? `, last ${days} days` : ""} (${data.dataDays ?? 0} days of job data)`);
|
|
12337
|
+
if (data.lowConfidence) lines.push(" Low confidence: under 7 days of data. Every figure below will move.");
|
|
12338
|
+
if (data.sourceNote) lines.push(` ${data.sourceNote}`);
|
|
12339
|
+
const r = data.rates?.usdPerMinute;
|
|
12340
|
+
if (r) lines.push(` GitHub list prices from ${data.rates?.fetchedAt ?? "unknown date"}: Linux $${r.linux}/min, Windows $${r.windows}/min, macOS $${r.macos}/min.`);
|
|
12341
|
+
lines.push(" Wait and run times are measured; minutes billed and money are estimated.");
|
|
12342
|
+
const accounts = data.accounts ?? [];
|
|
12343
|
+
if (accounts.length > 0) {
|
|
12344
|
+
lines.push("", "GitHub accounts (included minutes are per account):");
|
|
12345
|
+
for (const a of accounts) {
|
|
12346
|
+
lines.push(` ${a.owner} ${a.plan ?? "?"} plan, ${(a.includedMinutes ?? 0).toLocaleString("en-US")} included \xB7 ~${(a.monthMinutes ?? 0).toLocaleString("en-US")} min a month on private repos \xB7 list ${formatMoney(a.monthListCost, a.currency)} \xB7 after included ${formatMoney(a.monthCost, a.currency)} (estimated)`);
|
|
12347
|
+
}
|
|
12348
|
+
}
|
|
12349
|
+
const rows = data.rows ?? [];
|
|
12350
|
+
lines.push("", `${pad2("Repo", 36)} ${pad2("Runs on", 44)} ${"Jobs".padStart(5)} ${"Wait p95".padStart(9)} ${"Run p50".padStart(8)} ${"PR pass".padStart(8)} ${"Billed".padStart(7)} ${"Month (est.)".padStart(13)}`);
|
|
12351
|
+
if (rows.length === 0) lines.push(" No CI jobs in this window.");
|
|
12352
|
+
for (const row of rows.slice(0, opts.maxRows ?? 40)) {
|
|
12353
|
+
const v = vis.get(row.repo);
|
|
12354
|
+
const repo = `${row.repo}${v === "public" ? " (public)" : v === "private" ? " (private)" : ""}`;
|
|
12355
|
+
const cost = row.costBasis === "github_public_free" ? "free" : row.costBasis === "github_larger_unpriced" ? "unpriced" : formatMoney(row.monthCost, row.currency);
|
|
12356
|
+
lines.push(
|
|
12357
|
+
`${pad2(repo, 36)} ${pad2(runsOnLabel(row), 44)} ${String(row.jobs ?? 0).padStart(5)} ${formatDuration(row.queueP95s).padStart(9)} ${formatDuration(row.runP50s).padStart(8)} ${pct(row.prPassRate).padStart(8)} ${String(row.billedMinutes ?? 0).padStart(7)} ${cost.padStart(13)}`
|
|
12358
|
+
);
|
|
12359
|
+
}
|
|
12360
|
+
if (rows.length > (opts.maxRows ?? 40)) lines.push(` \u2026 ${rows.length - (opts.maxRows ?? 40)} more (use --json)`);
|
|
12361
|
+
const machines = data.machines ?? [];
|
|
12362
|
+
if (machines.length > 0) {
|
|
12363
|
+
lines.push("", "Your machines (busy time counts parallel jobs once; electricity is the extra draw under load, your figures):");
|
|
12364
|
+
for (const m of machines) {
|
|
12365
|
+
lines.push(` ${m.name}${m.online === false ? " (offline)" : ""}: busy ${m.busyHours ?? 0} h (${pct(m.busyShare)}) \xB7 electricity ${formatMoney(m.monthEnergyCost, m.currency)}/month \xB7 wear ${formatMoney(m.monthWearCost, m.currency)}/month \xB7 wait p95 ${formatDuration(m.queueP95s)}`);
|
|
12366
|
+
}
|
|
12367
|
+
}
|
|
12368
|
+
const measured = (data.speed ?? []).filter((s) => s.factor != null);
|
|
12369
|
+
const thin = (data.speed ?? []).filter((s) => s.factor == null);
|
|
12370
|
+
if (measured.length > 0 || thin.length > 0) {
|
|
12371
|
+
lines.push("", "Speed, self-hosted vs GitHub-hosted (passed runs, at least 5 on each side):");
|
|
12372
|
+
for (const s of measured.slice(0, 10)) {
|
|
12373
|
+
lines.push(` ${s.repo} \xB7 ${s.workflow} \xB7 ${s.job}: ${formatDuration(s.hostedP50s)} \u2192 ${formatDuration(s.selfHostedP50s)} = ${s.factor}\xD7 (${s.hostedSamples}/${s.selfHostedSamples} runs)`);
|
|
12374
|
+
}
|
|
12375
|
+
if (thin.length > 0) lines.push(` ${thin.length} more job${thin.length === 1 ? "" : "s"} ran on both but with too few passed runs to measure.`);
|
|
12376
|
+
}
|
|
12377
|
+
const whatIfs = data.whatIfs ?? [];
|
|
12378
|
+
if (whatIfs.length > 0) {
|
|
12379
|
+
lines.push("", "What if (estimated, 30 days):");
|
|
12380
|
+
for (const w of whatIfs.slice(0, 6)) {
|
|
12381
|
+
const to = w.to?.machine ?? w.to?.label ?? w.to?.runnerClass ?? "?";
|
|
12382
|
+
const speed = w.speed?.measured ? `${w.speed.factor}\xD7 measured (${w.speed.samplesFrom}/${w.speed.samplesTo} runs)` : "speed not measured, assuming 1.0";
|
|
12383
|
+
lines.push(` Move ${w.repo} \xB7 ${w.workflow} to ${to}: ${formatMoney(w.monthCostDelta, w.currency)} a month, ${(w.monthMinutesMoved ?? 0).toLocaleString("en-US")} min moved, ${speed}`);
|
|
12384
|
+
for (const s of (w.stays ?? []).slice(0, 3)) lines.push(` stays: ${s.job} \u2014 ${s.reason}`);
|
|
12385
|
+
for (const warn of w.warnings ?? []) lines.push(` ! ${warn}`);
|
|
12386
|
+
}
|
|
12387
|
+
}
|
|
12388
|
+
const advice = data.advice ?? [];
|
|
12389
|
+
if (advice.length > 0) {
|
|
12390
|
+
lines.push("", "Cost advice:");
|
|
12391
|
+
for (const a of advice.slice(0, 6)) {
|
|
12392
|
+
lines.push(` ${a.severity.toUpperCase().padEnd(7)} ${a.title}`);
|
|
12393
|
+
if (a.evidence) lines.push(` ${a.evidence}`);
|
|
12394
|
+
if (a.fix) lines.push(` Fix: ${a.fix}`);
|
|
12395
|
+
}
|
|
12396
|
+
}
|
|
12397
|
+
return lines;
|
|
12398
|
+
}
|
|
12399
|
+
|
|
12400
|
+
// src/runner/placement.ts
|
|
12401
|
+
function targetText(t) {
|
|
12402
|
+
if (!t) return "?";
|
|
12403
|
+
if (t.kind === "github_hosted") return `GitHub-hosted${t.label ? ` (${t.label})` : ""}`;
|
|
12404
|
+
return `self-hosted \u201C${t.label}\u201D`;
|
|
12405
|
+
}
|
|
12406
|
+
function money(n, currency) {
|
|
12407
|
+
const abs = Math.abs(n).toFixed(2);
|
|
12408
|
+
const v = currency === "USD" ? `$${abs}` : `${abs} ${currency}`;
|
|
12409
|
+
return n < 0 ? `\u2212${v}` : n > 0 ? `+${v}` : v;
|
|
12410
|
+
}
|
|
12411
|
+
function renderPlacement(data) {
|
|
12412
|
+
const lines = ["Where CI runs (ADR 0037: Mushi changes only LINUX_RUNNER / RUNNER_<WORKFLOW>, with approval)"];
|
|
12413
|
+
const p4 = data.policy;
|
|
12414
|
+
lines.push(
|
|
12415
|
+
p4 ? ` CI agreement: ${p4.noPaidHostedOnPrivate ? "no paid GitHub-hosted minutes on private repos" : "GitHub-hosted allowed"}${p4.sourceNote ? ` (${p4.sourceNote})` : ""}` : " CI agreement: not recorded (an owner fills it in: console Settings \u2192 CI policy)"
|
|
12416
|
+
);
|
|
12417
|
+
if (data.tokenStored === false) lines.push(" No GitHub token is stored for this project: Mushi cannot read or change variables.");
|
|
12418
|
+
for (const r of data.repos ?? []) {
|
|
12419
|
+
lines.push("", `${r.repo}${r.visibility ? ` (${r.visibility})` : ""}`);
|
|
12420
|
+
if (r.controller) lines.push(` Managed by ${r.controller.name}${r.controller.owner ? ` (${r.controller.owner})` : ""}: read-only here.`);
|
|
12421
|
+
if (r.workflowsRead === false) lines.push(" Workflow files not read yet.");
|
|
12422
|
+
const vars = r.variables ?? [];
|
|
12423
|
+
if (vars.length === 0 && r.workflowsRead !== false) lines.push(" No job reads a runner variable: every job names its runner in the workflow.");
|
|
12424
|
+
for (const v of vars) {
|
|
12425
|
+
const cur = v.current ? v.current.exists ? `= \u201C${v.current.value}\u201D` : "not set" : "value unknown";
|
|
12426
|
+
const fb = v.fallbacks?.length ? `, else ${v.fallbacks.join(" / ")}` : "";
|
|
12427
|
+
lines.push(` vars.${v.name} ${cur} (${v.jobs} job${v.jobs === 1 ? "" : "s"}${fb})${v.allowed === false ? " [Mushi does not change this one]" : ""}`);
|
|
12428
|
+
}
|
|
12429
|
+
const labels = (r.labels ?? []).filter((l) => l.runners > 0);
|
|
12430
|
+
if (labels.length > 0) lines.push(` Runner labels: ${labels.map((l) => `${l.label} ${l.online}/${l.runners} online`).join(", ")}`);
|
|
12431
|
+
}
|
|
12432
|
+
const pending = (data.requests ?? []).filter((x) => ["pending_approval", "approved"].includes(x.status));
|
|
12433
|
+
if (pending.length > 0) {
|
|
12434
|
+
lines.push("", "Waiting for an owner or admin in the console:");
|
|
12435
|
+
for (const x of pending) lines.push(` ${x.id.slice(0, 8)} ${x.status} ${x.payload?.repo ?? ""} ${x.payload?.name ?? ""} \u2192 ${x.payload?.value ?? "(remove)"}`);
|
|
12436
|
+
}
|
|
12437
|
+
for (const w of data.watches ?? []) {
|
|
12438
|
+
if (w.status === "watching" || w.status === "alert") {
|
|
12439
|
+
lines.push(` Watching ${w.repo} ${w.variable}: ${w.status}, ${w.jobs_seen ?? 0}/10 jobs${w.unexpected ? `, ${w.unexpected} on another runner` : ""}${w.slow ? `, ${w.slow} waited over 30 min` : ""}`);
|
|
12440
|
+
}
|
|
12441
|
+
}
|
|
12442
|
+
return lines;
|
|
12443
|
+
}
|
|
12444
|
+
function renderPreview(p4) {
|
|
12445
|
+
const lines = [];
|
|
12446
|
+
const cur = p4.current ? p4.current.exists ? `\u201C${p4.current.value}\u201D` : "not set" : "unreadable";
|
|
12447
|
+
lines.push(`${p4.repo}: vars.${p4.variable} ${cur} \u2192 ${p4.next?.value === null ? "removed" : `\u201C${p4.next?.value}\u201D`}`);
|
|
12448
|
+
lines.push(` Jobs that read it run on: ${targetText(p4.now?.target)} \u2192 ${targetText(p4.next?.target)}`);
|
|
12449
|
+
const moves = p4.jobs?.moves ?? [];
|
|
12450
|
+
lines.push(` Moves (${moves.length}): ${moves.map((m) => `${m.workflow} \xB7 ${m.job}`).join(", ") || "nothing"}`);
|
|
12451
|
+
for (const s of (p4.jobs?.stays ?? []).slice(0, 10)) lines.push(` Stays: ${s.workflow} \xB7 ${s.job} \u2014 ${s.detail}`);
|
|
12452
|
+
if (p4.target && p4.next?.target.kind === "self_hosted") {
|
|
12453
|
+
lines.push(` Target: ${p4.target.online}/${p4.target.runners} runner${p4.target.runners === 1 ? "" : "s"} online with \u201C${p4.target.label}\u201D${p4.target.waitP95s != null ? `, wait p95 ${Math.round(p4.target.waitP95s)} s` : ""}`);
|
|
12454
|
+
}
|
|
12455
|
+
if (p4.cost) lines.push(` Cost: ${money(p4.cost.monthCostDelta, p4.cost.currency)} a month (estimated). ${p4.cost.note}`);
|
|
12456
|
+
if (p4.policy?.controller) lines.push(` Managed by ${p4.policy.controller.name}${p4.policy.controller.owner ? ` (${p4.policy.controller.owner})` : ""}.`);
|
|
12457
|
+
for (const w of p4.warnings ?? []) lines.push(` ! ${w}`);
|
|
12458
|
+
if (p4.blocked) lines.push(` Cannot request: ${p4.blocked.message} (${p4.blocked.code})`);
|
|
12459
|
+
else lines.push(" Ready to request: `mushi runner placement request \u2026` asks; an owner or admin approves and runs it in the console.");
|
|
12460
|
+
return lines;
|
|
12461
|
+
}
|
|
12462
|
+
|
|
12463
|
+
// src/commands/runner.ts
|
|
12464
|
+
var HEARTBEAT_INTERVAL_MS = 12e4;
|
|
12465
|
+
function readIfPresent(path) {
|
|
12466
|
+
let fd;
|
|
12467
|
+
try {
|
|
12468
|
+
fd = openSync2(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
12469
|
+
} catch (err) {
|
|
12470
|
+
if (err.code === "ENOENT") return "";
|
|
12471
|
+
throw err;
|
|
12472
|
+
}
|
|
12473
|
+
try {
|
|
12474
|
+
return readFileSync8(fd, "utf8");
|
|
12475
|
+
} finally {
|
|
12476
|
+
closeSync2(fd);
|
|
12477
|
+
}
|
|
12478
|
+
}
|
|
12479
|
+
function withFd(path, fn) {
|
|
12480
|
+
const fd = openSync2(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
12481
|
+
try {
|
|
12482
|
+
return fn(fd);
|
|
12483
|
+
} finally {
|
|
12484
|
+
closeSync2(fd);
|
|
12485
|
+
}
|
|
12486
|
+
}
|
|
12487
|
+
var defaultRunStep = (step, stdin) => {
|
|
12488
|
+
const argv = step.sudo ? ["sudo", "sh", "-c", step.run] : ["sh", "-c", step.run];
|
|
12489
|
+
const res = spawnSync3(argv[0], argv.slice(1), {
|
|
12490
|
+
stdio: [stdin === void 0 ? "inherit" : "pipe", "inherit", "inherit"],
|
|
12491
|
+
input: stdin
|
|
12492
|
+
});
|
|
12493
|
+
return res.status ?? 1;
|
|
12494
|
+
};
|
|
12495
|
+
var deps = {
|
|
12496
|
+
host: nodeHostIO,
|
|
12497
|
+
gh: nodeGh,
|
|
12498
|
+
runStep: defaultRunStep,
|
|
12499
|
+
systemdRunning: () => existsSync6("/run/systemd/system"),
|
|
12500
|
+
ghAuthText: () => ghAuthStatus().text
|
|
12501
|
+
};
|
|
12502
|
+
function hostIdOrThrow() {
|
|
12503
|
+
const raw = readMachineId(deps.host);
|
|
12504
|
+
if (!raw) throw new MushiCliError("E_INVALID_INPUT", "Could not read a machine id on this host.", "Linux and WSL2 need /etc/machine-id; macOS reads IOPlatformUUID.");
|
|
12505
|
+
return hashHostId(raw);
|
|
12506
|
+
}
|
|
12507
|
+
function readRunnerKeyForHeartbeat(keyFile) {
|
|
12508
|
+
const fromEnv = process.env["MUSHI_RUNNER_KEY"]?.trim();
|
|
12509
|
+
if (fromEnv) return fromEnv;
|
|
12510
|
+
const credDir = process.env["CREDENTIALS_DIRECTORY"];
|
|
12511
|
+
const paths = [
|
|
12512
|
+
...credDir ? [join12(credDir, RUNNER_KEY_CREDENTIAL)] : [],
|
|
12513
|
+
keyFile ? resolve7(keyFile) : RUNNER_KEY_PATH
|
|
12514
|
+
];
|
|
12515
|
+
for (const path of paths) {
|
|
12516
|
+
const key = deps.host.readFile(path)?.trim();
|
|
12517
|
+
if (key) return key;
|
|
12518
|
+
}
|
|
12519
|
+
throw new MushiCliError(
|
|
12520
|
+
"E_AUTH_MISSING",
|
|
12521
|
+
`No runner key (MUSHI_RUNNER_KEY, or a readable ${keyFile ?? RUNNER_KEY_PATH}).`,
|
|
12522
|
+
"create one with `mushi runner key` (it can only report runner health; your CLI key is never used here)"
|
|
12523
|
+
);
|
|
12524
|
+
}
|
|
12525
|
+
async function mintRunnerKeyOnly(config, project, label) {
|
|
12526
|
+
const { key } = await resolveRunnerKey(config, {
|
|
12527
|
+
projectId: project ? requireUuid(project, "project id") : config.projectId,
|
|
12528
|
+
label: label ?? defaultRunnerKeyLabel(deps.host.hostname),
|
|
12529
|
+
env: {}
|
|
12530
|
+
});
|
|
12531
|
+
return key;
|
|
12532
|
+
}
|
|
12533
|
+
function sleep(ms, signal) {
|
|
12534
|
+
return new Promise((done) => {
|
|
12535
|
+
if (signal.aborted) return done();
|
|
12536
|
+
const t = setTimeout(done, ms);
|
|
12537
|
+
signal.addEventListener("abort", () => {
|
|
12538
|
+
clearTimeout(t);
|
|
12539
|
+
done();
|
|
12540
|
+
}, { once: true });
|
|
12541
|
+
});
|
|
12542
|
+
}
|
|
12543
|
+
function printPlan(plan, dryRun) {
|
|
12544
|
+
for (const w of plan.warnings) console.log(`WARN ${w}`);
|
|
12545
|
+
if (plan.warnings.length) console.log("");
|
|
12546
|
+
plan.steps.forEach((step, i) => {
|
|
12547
|
+
console.log(`[${i + 1}/${plan.steps.length}] ${step.title}`);
|
|
12548
|
+
console.log(` $ ${step.sudo ? "sudo " : ""}sh -c ${JSON.stringify(step.run)}`);
|
|
12549
|
+
if (step.stdinSecret) console.log(` (stdin: the ${step.stdinSecret === "runner-key" ? "runner key (runner:write)" : "registration token"}, not shown)`);
|
|
12550
|
+
if (step.file && dryRun) {
|
|
12551
|
+
console.log(` ${step.file.path}:`);
|
|
12552
|
+
for (const line of step.file.content.replace(/\n$/, "").split("\n")) console.log(` ${line}`);
|
|
12553
|
+
}
|
|
12554
|
+
});
|
|
12555
|
+
}
|
|
12556
|
+
function printNotes(notes) {
|
|
12557
|
+
if (!notes.length) return;
|
|
12558
|
+
console.log("");
|
|
12559
|
+
for (const n of notes) console.log(n);
|
|
12560
|
+
}
|
|
12561
|
+
function heartbeatCommand() {
|
|
12562
|
+
const entry = process.argv[1] ? safeRealpath(process.argv[1]) : null;
|
|
12563
|
+
if (entry && !entry.includes("/_npx/") && entry.endsWith(".js")) {
|
|
12564
|
+
return [process.execPath, entry, "runner", "heartbeat", "--once"];
|
|
12565
|
+
}
|
|
12566
|
+
return [join12(dirname5(process.execPath), "npx"), "-y", `@mushi-mushi/cli@${MUSHI_CLI_VERSION}`, "runner", "heartbeat", "--once"];
|
|
12567
|
+
}
|
|
12568
|
+
function safeRealpath(path) {
|
|
12569
|
+
try {
|
|
12570
|
+
return realpathSync(path);
|
|
12571
|
+
} catch {
|
|
12572
|
+
return null;
|
|
12573
|
+
}
|
|
12574
|
+
}
|
|
12575
|
+
function auditLines(a) {
|
|
12576
|
+
const lines = [`${a.repo}`];
|
|
12577
|
+
if (a.runners.length === 0) lines.push(" No self-hosted runners.");
|
|
12578
|
+
for (const r of a.runners) {
|
|
12579
|
+
lines.push(` ${(r.busy ? "BUSY" : r.status.toUpperCase()).padEnd(8)} ${r.name} [${r.labels.join(", ")}]${r.setUpByMushi ? " (Mushi)" : ""}`);
|
|
12580
|
+
}
|
|
12581
|
+
for (const f of a.findings) lines.push(` ${f.severity.toUpperCase().padEnd(8)} ${f.message}`);
|
|
12582
|
+
return lines;
|
|
12583
|
+
}
|
|
12584
|
+
function registerRunnerCommands(program2) {
|
|
12585
|
+
const runner = program2.command("runner").description("Where your tests run and why a fix waits to land: CI runners, their machines, and one-command runner setup");
|
|
12586
|
+
runner.command("status").description("Machines, runners, queue and run times, test-machine vs code failures, and what to do first").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--project <id>", "Same as --project-id").option("--all", "Every project your key can read (account-level key)").option("--days <n>", "Window in days (1-30)", "7").option("--refresh", "Read GitHub first instead of the last 10-minute sync (at most once a minute per project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
|
|
12587
|
+
const config = requireConfig();
|
|
12588
|
+
const days = Number(opts.days);
|
|
12589
|
+
if (!Number.isInteger(days) || days < 1 || days > 30) {
|
|
12590
|
+
throw new MushiCliError("E_INVALID_INPUT", "--days must be a whole number from 1 to 30.");
|
|
12591
|
+
}
|
|
12592
|
+
const qs = new URLSearchParams();
|
|
12593
|
+
const raw = opts.all ? void 0 : opts.projectId ?? opts.project ?? config.projectId;
|
|
12594
|
+
if (raw) qs.set("project_id", requireUuid(raw, "project id"));
|
|
12595
|
+
qs.set("days", String(days));
|
|
12596
|
+
if (opts.refresh) qs.set("refresh", "1");
|
|
12597
|
+
const result = await apiCall(`/v1/admin/ci/overview?${qs}`, config, {}, { timeoutMs: opts.refresh ? 6e4 : void 0 });
|
|
12598
|
+
if (!result.ok) die(result);
|
|
12599
|
+
if (outputIsJson(opts.json)) {
|
|
12600
|
+
console.log(JSON.stringify(result.data, null, 2));
|
|
12601
|
+
return;
|
|
12602
|
+
}
|
|
12603
|
+
for (const line of renderOverview(result.data)) console.log(line);
|
|
12604
|
+
});
|
|
12605
|
+
runner.command("compare").description("What CI costs and how fast it runs, per repo: GitHub-hosted vs your own machines, with what-ifs (estimated)").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--project <id>", "Same as --project-id").option("--all", "Every project your key can read (account-level key)").option("--days <n>", "Window in days (1-30)", "30").option("--json", "Machine-readable JSON output").addHelpText("after", `
|
|
12606
|
+
Read-only: nothing is changed on GitHub. Wait and run times are measured from
|
|
12607
|
+
GitHub's job times; billed minutes and money are estimated from GitHub's list
|
|
12608
|
+
prices (dated), your plan's included minutes, and your electricity figures
|
|
12609
|
+
(console: Settings \u2192 CI cost).`).action(async (opts) => {
|
|
12610
|
+
const config = requireConfig();
|
|
12611
|
+
const days = Number(opts.days);
|
|
12612
|
+
if (!Number.isInteger(days) || days < 1 || days > 30) {
|
|
12613
|
+
throw new MushiCliError("E_INVALID_INPUT", "--days must be a whole number from 1 to 30.");
|
|
12614
|
+
}
|
|
12615
|
+
const qs = new URLSearchParams();
|
|
12616
|
+
const raw = opts.all ? void 0 : opts.projectId ?? opts.project ?? config.projectId;
|
|
12617
|
+
if (raw) qs.set("project_id", requireUuid(raw, "project id"));
|
|
12618
|
+
qs.set("days", String(days));
|
|
12619
|
+
const result = await apiCall(`/v1/admin/ci/compare?${qs}`, config, {}, { timeoutMs: 6e4 });
|
|
12620
|
+
if (!result.ok) die(result);
|
|
12621
|
+
if (outputIsJson(opts.json)) {
|
|
12622
|
+
console.log(JSON.stringify(result.data, null, 2));
|
|
12623
|
+
return;
|
|
12624
|
+
}
|
|
12625
|
+
for (const line of renderCompare(result.data)) console.log(line);
|
|
12626
|
+
});
|
|
12627
|
+
const placement = runner.command("placement").description("Where each repo's CI runs, and changing it through one runner variable with approval (ADR 0037)");
|
|
12628
|
+
const placementBody = (opts, config) => {
|
|
12629
|
+
if (!opts.remove && !opts.value) throw new MushiCliError("E_INVALID_INPUT", "Pass --value <runner label>, or --remove to delete the variable.");
|
|
12630
|
+
if (opts.remove && opts.value) throw new MushiCliError("E_INVALID_INPUT", "Pass either --value or --remove, not both.");
|
|
12631
|
+
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(opts.repo)) throw new MushiCliError("E_INVALID_INPUT", "--repo must be owner/repo.");
|
|
12632
|
+
const raw = opts.projectId ?? opts.project ?? config.projectId;
|
|
12633
|
+
if (!raw) throw new MushiCliError("E_INVALID_INPUT", "Pass --project-id (or configure a project).");
|
|
12634
|
+
return { projectId: requireUuid(raw, "project id"), repo: opts.repo, variable: opts.variable, value: opts.remove ? null : opts.value };
|
|
12635
|
+
};
|
|
12636
|
+
placement.command("show").description("Each repo: the runner variables its workflows read and their values, the CI agreement, runner labels, pending requests").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--project <id>", "Same as --project-id").option("--json", "Machine-readable JSON output").action(async (opts) => {
|
|
12637
|
+
const config = requireConfig();
|
|
12638
|
+
const raw = opts.projectId ?? opts.project ?? config.projectId;
|
|
12639
|
+
if (!raw) throw new MushiCliError("E_INVALID_INPUT", "Pass --project-id (or configure a project).");
|
|
12640
|
+
const result = await apiCall(`/v1/admin/ci/placement?project_id=${encodeURIComponent(requireUuid(raw, "project id"))}`, config, {}, { timeoutMs: 6e4 });
|
|
12641
|
+
if (!result.ok) die(result);
|
|
12642
|
+
if (outputIsJson(opts.json)) {
|
|
12643
|
+
console.log(JSON.stringify(result.data, null, 2));
|
|
12644
|
+
return;
|
|
12645
|
+
}
|
|
12646
|
+
for (const line of renderPlacement(result.data)) console.log(line);
|
|
12647
|
+
});
|
|
12648
|
+
const placementAction = (verb) => async (opts) => {
|
|
12649
|
+
const config = requireConfig();
|
|
12650
|
+
const body = { ...placementBody(opts, config), ...verb === "request" && opts.reason ? { reason: opts.reason.slice(0, 500) } : {} };
|
|
12651
|
+
const path = verb === "preview" ? "/v1/admin/ci/placement/preview" : "/v1/admin/ci/placement/requests";
|
|
12652
|
+
const result = await apiCall(path, config, { method: "POST", body: JSON.stringify(body) }, { timeoutMs: 6e4 });
|
|
12653
|
+
if (!result.ok) die(result);
|
|
12654
|
+
if (outputIsJson(opts.json)) {
|
|
12655
|
+
console.log(JSON.stringify(result.data, null, 2));
|
|
12656
|
+
return;
|
|
12657
|
+
}
|
|
12658
|
+
if (verb === "preview") {
|
|
12659
|
+
for (const line of renderPreview(result.data)) console.log(line);
|
|
12660
|
+
return;
|
|
12661
|
+
}
|
|
12662
|
+
const r = result.data;
|
|
12663
|
+
for (const line of renderPreview(r.preview)) console.log(line);
|
|
12664
|
+
console.log(`
|
|
12665
|
+
Requested ${r.id} (${r.status}). ${r.next ?? "An owner or admin approves and runs it in the console."}`);
|
|
12666
|
+
};
|
|
12667
|
+
const changeOptions = (cmd) => cmd.requiredOption("--repo <owner/repo>", "Repository connected to the project").option("--variable <name>", "LINUX_RUNNER or RUNNER_<WORKFLOW>", "LINUX_RUNNER").option("--value <label>", "The runner label jobs should ask for").option("--remove", "Remove the variable (jobs use the workflow's fallback, usually GitHub-hosted)").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--project <id>", "Same as --project-id").option("--json", "Machine-readable JSON output");
|
|
12668
|
+
changeOptions(
|
|
12669
|
+
placement.command("preview").description("Dry run: what would move, what stays and why, the target runners, cost, the CI agreement's verdict. Changes nothing")
|
|
12670
|
+
).action(placementAction("preview"));
|
|
12671
|
+
changeOptions(
|
|
12672
|
+
placement.command("request").description("Ask for the change. Nothing changes until an owner or admin approves and runs it in the console")
|
|
12673
|
+
).option("--reason <text>", "Why, shown to the approver").action(placementAction("request"));
|
|
12674
|
+
placement.command("revert <requestId>").description("Ask to undo a change that ran (it needs its own approval in the console)").option("--json", "Machine-readable JSON output").action(async (requestId, opts) => {
|
|
12675
|
+
const config = requireConfig();
|
|
12676
|
+
const id = requireUuid(requestId, "request id");
|
|
12677
|
+
const result = await apiCall(`/v1/admin/ci/placement/requests/${id}/revert`, config, { method: "POST" });
|
|
12678
|
+
if (!result.ok) die(result);
|
|
12679
|
+
if (outputIsJson(opts.json)) {
|
|
12680
|
+
console.log(JSON.stringify(result.data, null, 2));
|
|
12681
|
+
return;
|
|
12682
|
+
}
|
|
12683
|
+
console.log(`Requested revert ${result.data.id} of ${result.data.revertOf} (${result.data.status}). An owner or admin approves and runs it in the console.`);
|
|
12684
|
+
});
|
|
12685
|
+
runner.command("heartbeat").description("Send this machine's memory, swap, load and runner services to Mushi (every 2 minutes, or once)").option("--once", "Send one heartbeat and exit (for a systemd timer or cron)").option("--dry-run", "Print the heartbeat JSON and send nothing").option("--key-file <path>", `File holding the runner key (default ${RUNNER_KEY_PATH})`).addHelpText("after", `
|
|
12686
|
+
What is sent: the hostname, platform, CPU count, total memory, a sha256 hash of
|
|
12687
|
+
the machine id (never the id), load, memory and swap in use, free disk, and for
|
|
12688
|
+
each GitHub runner service: its name, repo, unit name, whether it runs, restarts,
|
|
12689
|
+
memory use, memory limit and out-of-memory kills.
|
|
12690
|
+
Never sent: file contents, environment values, process arguments, secrets.
|
|
12691
|
+
|
|
12692
|
+
It sends with a runner key (scope runner:write), which can only report runner
|
|
12693
|
+
health, never your CLI key. The key is read from MUSHI_RUNNER_KEY, the systemd
|
|
12694
|
+
credential "${RUNNER_KEY_CREDENTIAL}", --key-file, or ${RUNNER_KEY_PATH}.
|
|
12695
|
+
Create one with \`mushi runner key\`.`).action(async (opts) => {
|
|
12696
|
+
const build = () => {
|
|
12697
|
+
try {
|
|
12698
|
+
return collectHeartbeat(deps.host, MUSHI_CLI_VERSION);
|
|
12699
|
+
} catch (err) {
|
|
12700
|
+
if (err instanceof UnsupportedHostError) throw new MushiCliError("E_INVALID_INPUT", err.message);
|
|
12701
|
+
throw err;
|
|
12702
|
+
}
|
|
12703
|
+
};
|
|
12704
|
+
if (opts.dryRun) {
|
|
12705
|
+
console.log(JSON.stringify(build(), null, 2));
|
|
12706
|
+
return;
|
|
12707
|
+
}
|
|
12708
|
+
const cli = loadConfig();
|
|
12709
|
+
const runnerKey = readRunnerKeyForHeartbeat(opts.keyFile);
|
|
12710
|
+
assertNotCliKey(runnerKey, cli.apiKey);
|
|
12711
|
+
const config = { ...cli, apiKey: runnerKey, endpoint: cli.endpoint ?? CLOUD_API_ENDPOINT };
|
|
12712
|
+
const signal = getAbortSignal();
|
|
12713
|
+
for (; ; ) {
|
|
12714
|
+
const body = build();
|
|
12715
|
+
const result = await apiCall("/v1/ingest/runner", config, { method: "POST", body: JSON.stringify(body) });
|
|
12716
|
+
if (opts.once) {
|
|
12717
|
+
if (!result.ok) die(result);
|
|
12718
|
+
console.log(`Sent a heartbeat for ${body.host.hostname} with ${body.runners.length} runner(s).`);
|
|
12719
|
+
return;
|
|
12720
|
+
}
|
|
12721
|
+
if (result.ok) {
|
|
12722
|
+
console.log(`${(/* @__PURE__ */ new Date()).toISOString()} heartbeat sent (${body.runners.length} runner(s)).`);
|
|
12723
|
+
} else {
|
|
12724
|
+
process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} heartbeat failed: ${result.error.code} \u2014 ${result.error.message}
|
|
12725
|
+
`);
|
|
12726
|
+
}
|
|
12727
|
+
await sleep(HEARTBEAT_INTERVAL_MS, signal);
|
|
12728
|
+
if (signal.aborted) return;
|
|
12729
|
+
}
|
|
12730
|
+
});
|
|
12731
|
+
const hooks = runner.command("hooks").description("Job hooks that report memory peak and OOM kills for every job");
|
|
12732
|
+
hooks.command("install").description("Write job hooks into a runner folder and turn them on in its .env").requiredOption("--dir <path>", "The runner folder (where config.sh and .runner live)").option("--force", "Replace hooks the runner's .env already points somewhere else").option("--project <id>", "Project the runner key is for (defaults to the configured project)").option("--label <name>", "Label of the runner key (default: this host; shown as runner:<label>)").option("--dry-run", "Show what would be written and change nothing").addHelpText("after", `
|
|
12733
|
+
The hooks read the runner's own cgroup (memory.peak, memory.max, oom_kill), swap
|
|
12734
|
+
and load, and send one sample per job. They always exit 0, so they never fail a
|
|
12735
|
+
job. They send with a runner key (scope runner:write): a key that can only report
|
|
12736
|
+
runner health. CI jobs run as the same user as the hooks and could read it; if
|
|
12737
|
+
one steals it, all it can do is send fake numbers. Your CLI key never goes here.
|
|
12738
|
+
The key comes from MUSHI_RUNNER_KEY, or a new one is created with your CLI key
|
|
12739
|
+
(needs mcp:write). It is saved in <dir>/${HOOKS_DIR_NAME}/key with mode 0600,
|
|
12740
|
+
never in the scripts, and never printed.
|
|
12741
|
+
GitHub docs: https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/run-scripts`).action(async (opts) => {
|
|
12742
|
+
const dir = resolve7(opts.dir);
|
|
12743
|
+
if (!existsSync6(join12(dir, "config.sh")) && !existsSync6(join12(dir, "run.sh"))) {
|
|
12744
|
+
throw new MushiCliError("E_INVALID_INPUT", `${dir} does not look like a runner folder (no config.sh or run.sh).`);
|
|
12745
|
+
}
|
|
12746
|
+
const config = loadConfig();
|
|
12747
|
+
const endpoint = config.endpoint ?? CLOUD_API_ENDPOINT;
|
|
12748
|
+
const hostId = hostIdOrThrow();
|
|
12749
|
+
const hooksDir = join12(dir, HOOKS_DIR_NAME);
|
|
12750
|
+
const files = {
|
|
12751
|
+
started: join12(hooksDir, "job-started.sh"),
|
|
12752
|
+
completed: join12(hooksDir, "job-completed.sh")
|
|
12753
|
+
};
|
|
12754
|
+
const envPath = join12(dir, ".env");
|
|
12755
|
+
const envBefore = readIfPresent(envPath);
|
|
12756
|
+
const merged = mergeRunnerEnv(envBefore, files, opts.force);
|
|
12757
|
+
if (merged.conflicts.length) {
|
|
12758
|
+
for (const c of merged.conflicts) process.stderr.write(`${c.key} already runs ${c.current}
|
|
12759
|
+
`);
|
|
12760
|
+
throw new MushiCliError("E_INVALID_INPUT", "The runner's .env already sets a job hook.", "chain it from the Mushi hook yourself, or pass --force to replace it");
|
|
12761
|
+
}
|
|
12762
|
+
if (opts.dryRun) {
|
|
12763
|
+
console.log(`Would write ${files.started} and ${files.completed} (mode 0755), and ${join12(hooksDir, "key")} (mode 0600, a runner:write key, not shown).`);
|
|
12764
|
+
const added = (merged.text ?? "").split("\n").filter((l) => l && !envBefore.split(/\r?\n/).includes(l));
|
|
12765
|
+
console.log(added.length ? `Would add to ${envPath}:
|
|
12766
|
+
${added.map((l) => ` ${l}`).join("\n")}` : `${envPath} already has the hooks.`);
|
|
12767
|
+
return;
|
|
12768
|
+
}
|
|
12769
|
+
const { key: runnerKey, source } = await resolveRunnerKey(config, {
|
|
12770
|
+
projectId: opts.project ? requireUuid(opts.project, "project id") : config.projectId,
|
|
12771
|
+
label: opts.label ?? defaultRunnerKeyLabel(deps.host.hostname)
|
|
12772
|
+
});
|
|
12773
|
+
mkdirSync2(hooksDir, { recursive: true, mode: 493 });
|
|
12774
|
+
for (const phase of ["started", "completed"]) {
|
|
12775
|
+
writeFileSync5(files[phase], hookScript({ phase, endpoint, hostId }), { mode: 493 });
|
|
12776
|
+
}
|
|
12777
|
+
const keyPath = join12(hooksDir, "key");
|
|
12778
|
+
writeFileSync5(keyPath, `${runnerKey}
|
|
12779
|
+
`, { mode: 384 });
|
|
12780
|
+
console.log(source === "env" ? "Using the runner key from MUSHI_RUNNER_KEY." : "Created a runner key (runner:write: it can only report runner health).");
|
|
12781
|
+
if (merged.text) {
|
|
12782
|
+
const fd = openSync2(envPath, fsConstants.O_RDWR | fsConstants.O_CREAT | (fsConstants.O_NOFOLLOW ?? 0), 420);
|
|
12783
|
+
try {
|
|
12784
|
+
const now = mergeRunnerEnv(readFileSync8(fd, "utf8"), files, opts.force);
|
|
12785
|
+
if (now.conflicts.length) throw new MushiCliError("E_INVALID_INPUT", "The runner's .env changed and now sets a job hook.", "run the command again");
|
|
12786
|
+
if (now.text) {
|
|
12787
|
+
ftruncateSync(fd, 0);
|
|
12788
|
+
writeSync(fd, now.text, 0);
|
|
12789
|
+
}
|
|
12790
|
+
} finally {
|
|
12791
|
+
closeSync2(fd);
|
|
12792
|
+
}
|
|
12793
|
+
}
|
|
12794
|
+
const owner = withFd(dir, (fd) => fstatSync2(fd));
|
|
12795
|
+
const me = process.getuid?.();
|
|
12796
|
+
if (me === 0 && owner.uid !== 0) {
|
|
12797
|
+
for (const path of [hooksDir, files.started, files.completed, keyPath, envPath]) {
|
|
12798
|
+
withFd(path, (fd) => fchownSync(fd, owner.uid, owner.gid));
|
|
12799
|
+
}
|
|
12800
|
+
} else if (me !== void 0 && owner.uid !== me) {
|
|
12801
|
+
console.log(`The runner folder belongs to uid ${owner.uid}. Give it the hook files: sudo chown -R ${owner.uid}:${owner.gid} ${hooksDir}`);
|
|
12802
|
+
}
|
|
12803
|
+
console.log(`Installed the job hooks in ${hooksDir}.`);
|
|
12804
|
+
console.log("Restart the runner service so it reads its .env again, for example:");
|
|
12805
|
+
console.log(` cd ${dir} && sudo ./svc.sh stop && sudo ./svc.sh start`);
|
|
12806
|
+
});
|
|
12807
|
+
runner.command("key").description("Create a runner key (runner:write) and save it for the heartbeat; it is never printed").option("--project <id>", "Project the key is for (defaults to the configured project)").option("--label <name>", "Key label shown in the console (default: this host; shown as runner:<label>)").option("--out <path>", "Where to save it (mode 0600)", RUNNER_KEY_PATH).addHelpText("after", `
|
|
12808
|
+
A runner key can only report runner health. CI jobs on a self-hosted runner can
|
|
12809
|
+
read files the runner user can read; if a job steals this key, all it can do is
|
|
12810
|
+
send fake numbers. Your CLI key (which can merge pull requests) never goes on a
|
|
12811
|
+
runner. Creating one needs a CLI key with mcp:write (mushi login --upgrade-scope).
|
|
12812
|
+
${RUNNER_KEY_PATH} is root-only; the heartbeat timer gets it from systemd.`).action(async (opts) => {
|
|
12813
|
+
const config = requireConfig();
|
|
12814
|
+
const key = await mintRunnerKeyOnly(config, opts.project, opts.label);
|
|
12815
|
+
const out = resolve7(opts.out);
|
|
12816
|
+
const dir = dirname5(out);
|
|
12817
|
+
const writable = (() => {
|
|
12818
|
+
try {
|
|
12819
|
+
accessSync(existsSync6(dir) ? dir : dirname5(dir), fsConstants.W_OK);
|
|
12820
|
+
return existsSync6(dir) || process.getuid?.() === 0;
|
|
12821
|
+
} catch {
|
|
12822
|
+
return false;
|
|
12823
|
+
}
|
|
12824
|
+
})();
|
|
12825
|
+
if (writable) {
|
|
12826
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
12827
|
+
writeFileSync5(out, `${key}
|
|
12828
|
+
`, { mode: 384 });
|
|
12829
|
+
chmodSync(out, 384);
|
|
12830
|
+
} else {
|
|
12831
|
+
const step = {
|
|
12832
|
+
id: "runner-key",
|
|
12833
|
+
title: `Save the runner key to ${out} (root, mode 0600)`,
|
|
12834
|
+
sudo: true,
|
|
12835
|
+
stdinSecret: "runner-key",
|
|
12836
|
+
run: `install -d -m 0700 '${dir.replace(/'/g, `'"'"'`)}' && umask 077 && IFS= read -r k && printf '%s\\n' "$k" > '${out.replace(/'/g, `'"'"'`)}'`
|
|
12837
|
+
};
|
|
12838
|
+
console.log(`$ sudo sh -c ${JSON.stringify(step.run)} (stdin: the runner key, not shown)`);
|
|
12839
|
+
const code = deps.runStep(step, `${key}
|
|
12840
|
+
`);
|
|
12841
|
+
if (code !== 0) throw new MushiCliError("E_INVALID_INPUT", `Could not save the runner key to ${out} (exit ${code}).`, "the new key was not shown; revoke it in the console and run this again");
|
|
12842
|
+
}
|
|
12843
|
+
console.log(`Saved a runner key to ${out}. It can only report runner health.`);
|
|
12844
|
+
});
|
|
12845
|
+
runner.command("add").description("Set this machine up as a GitHub Actions runner for one repo, with safe defaults").requiredOption("--repo <owner/name>", "The repo the runner serves").option("--label <label>", "Runner label workflows opt in with (default mushi-<host>)").option("--memory <size>", "Memory cap for runner jobs, e.g. 8G (default 60% of RAM)").option("--ephemeral", "One job per runner, a fresh runner config per job (default)").option("--persistent", "One long-lived runner that takes job after job").option("--allow-public", "Allow a public repo (fork pull requests would run on this machine)").option("--block-lan", "Block the runner user from your LAN, tailnet and link-local addresses (nftables)").option("--wsl-isolate", "On WSL2: turn off Windows drives and Windows programs for this distro").option("--set-variable", "Set the LINUX_RUNNER repo variable to the label").option("--uid <n>", "uid for the runner user", "1990").option("--user <name>", "Name of the runner user", "gha-runner").option("--project <id>", "Mushi project the runner key is for (defaults to the configured project)").option("--yes", "Run the steps without asking").option("--dry-run", "Print the whole plan and change nothing").addHelpText("after", `
|
|
12846
|
+
Safe defaults: private repos only; one job per runner; a dedicated user with no
|
|
12847
|
+
admin rights and no view of /home; a memory cap so a build cannot freeze the
|
|
12848
|
+
machine; a label of its own so GitHub-hosted runners stay the fallback. Every
|
|
12849
|
+
command that needs root runs through sudo and is shown first. Self-hosted
|
|
12850
|
+
runners are free on GitHub; this keeps your agents' fix PRs tested on a machine
|
|
12851
|
+
you already own when your included minutes run out.
|
|
12852
|
+
Docs: https://kensaur.us/mushi-mushi/docs/operating/ci-runners`).action(async (opts) => {
|
|
12853
|
+
if (opts.ephemeral && opts.persistent) throw new MushiCliError("E_INVALID_INPUT", "Pick --ephemeral or --persistent, not both.");
|
|
12854
|
+
const host = deps.host;
|
|
12855
|
+
const platform = detectPlatform(host);
|
|
12856
|
+
const config = loadConfig();
|
|
12857
|
+
const endpoint = config.endpoint ?? CLOUD_API_ENDPOINT;
|
|
12858
|
+
const archName = runnerArch(nodeArch());
|
|
12859
|
+
const gh = deps.gh;
|
|
12860
|
+
const repo = opts.repo.trim();
|
|
12861
|
+
const preflight = platform === "windows" || !/^[\w.-]+\/[\w.-]+$/.test(repo) ? { isPublic: null, forkApproval: null, prTargetWorkflows: null, release: null, keyring: false } : {
|
|
12862
|
+
isPublic: repoIsPublic(gh, repo),
|
|
12863
|
+
forkApproval: forkApprovalPolicy(gh, repo),
|
|
12864
|
+
prTargetWorkflows: prTargetWorkflows(gh, repo),
|
|
12865
|
+
release: archName && platform !== "macos" ? latestRunnerRelease(gh, `linux-${archName}`) : null,
|
|
12866
|
+
keyring: /keyring/i.test(deps.ghAuthText())
|
|
12867
|
+
};
|
|
12868
|
+
const rawId = platform === "windows" ? null : readMachineId(host);
|
|
12869
|
+
const meminfo = host.readFile("/proc/meminfo") ?? "";
|
|
12870
|
+
const totalMb = Number(meminfo.match(/^MemTotal:\s+(\d+)/m)?.[1] ?? 0) / 1024 || host.totalMemBytes / 1024 / 1024;
|
|
12871
|
+
const plan = buildAddPlan({
|
|
12872
|
+
repo,
|
|
12873
|
+
label: opts.label ?? defaultLabel(host.hostname),
|
|
12874
|
+
mode: opts.persistent ? "persistent" : "ephemeral",
|
|
12875
|
+
platform,
|
|
12876
|
+
arch: archName,
|
|
12877
|
+
memoryMax: opts.memory ?? defaultMemory(totalMb),
|
|
12878
|
+
runnerUser: opts.user,
|
|
12879
|
+
uid: Number(opts.uid),
|
|
12880
|
+
installUser: userInfo().username,
|
|
12881
|
+
release: preflight.release,
|
|
12882
|
+
endpoint,
|
|
12883
|
+
hostId: rawId ? hashHostId(rawId) : "",
|
|
12884
|
+
heartbeatCommand: heartbeatCommand(),
|
|
12885
|
+
blockLan: opts.blockLan === true,
|
|
12886
|
+
resolvers: parseResolvers(host.readFile("/etc/resolv.conf") ?? ""),
|
|
12887
|
+
systemdRunning: deps.systemdRunning(),
|
|
12888
|
+
wsl: platform === "wsl2" ? { distro: process.env["WSL_DISTRO_NAME"] ?? null, wslConf: host.readFile("/etc/wsl.conf") ?? "", isolate: opts.wslIsolate === true } : null,
|
|
12889
|
+
setVariable: opts.setVariable === true,
|
|
12890
|
+
isPublic: preflight.isPublic,
|
|
12891
|
+
allowPublic: opts.allowPublic === true,
|
|
12892
|
+
forkApproval: preflight.forkApproval,
|
|
12893
|
+
prTargetWorkflows: preflight.prTargetWorkflows,
|
|
12894
|
+
ghTokenInKeyring: preflight.keyring
|
|
12895
|
+
});
|
|
12896
|
+
if (outputIsJson()) {
|
|
12897
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
12898
|
+
return;
|
|
12899
|
+
}
|
|
12900
|
+
if (plan.refused) {
|
|
12901
|
+
for (const w of plan.warnings) console.log(`WARN ${w}`);
|
|
12902
|
+
printNotes(plan.notes);
|
|
12903
|
+
throw new MushiCliError("E_INVALID_INPUT", plan.refused, "nothing was changed");
|
|
12904
|
+
}
|
|
12905
|
+
printPlan(plan, opts.dryRun === true);
|
|
12906
|
+
if (opts.dryRun || plan.steps.length === 0) {
|
|
12907
|
+
printNotes(plan.notes);
|
|
12908
|
+
if (opts.dryRun) console.log("\nDry run: nothing was changed.");
|
|
12909
|
+
return;
|
|
12910
|
+
}
|
|
12911
|
+
if (!opts.yes) {
|
|
12912
|
+
if (!process.stdin.isTTY) throw new MushiCliError("E_INVALID_INPUT", "Not a terminal, so nothing was run.", "re-run with --yes, or with --dry-run to review the plan");
|
|
12913
|
+
const go = await p3.confirm({ message: `Run these ${plan.steps.length} steps? sudo will ask for your password.` });
|
|
12914
|
+
if (go !== true) {
|
|
12915
|
+
console.log("Nothing was changed.");
|
|
12916
|
+
return;
|
|
12917
|
+
}
|
|
12918
|
+
}
|
|
12919
|
+
let runnerKey = null;
|
|
12920
|
+
if (plan.steps.some((s) => s.stdinSecret === "runner-key")) {
|
|
12921
|
+
const resolved = await resolveRunnerKey(config, {
|
|
12922
|
+
projectId: opts.project ? requireUuid(opts.project, "project id") : config.projectId,
|
|
12923
|
+
label: defaultRunnerKeyLabel(host.hostname)
|
|
12924
|
+
});
|
|
12925
|
+
runnerKey = resolved.key;
|
|
12926
|
+
console.log(resolved.source === "env" ? "Using the runner key from MUSHI_RUNNER_KEY." : "Created a runner key (runner:write: it can only report runner health).");
|
|
12927
|
+
}
|
|
12928
|
+
for (const [i, step] of plan.steps.entries()) {
|
|
12929
|
+
console.log(`
|
|
12930
|
+
[${i + 1}/${plan.steps.length}] ${step.title}`);
|
|
12931
|
+
let stdin;
|
|
12932
|
+
if (step.file) stdin = step.file.content;
|
|
12933
|
+
if (step.stdinSecret === "runner-key") stdin = `${runnerKey}
|
|
12934
|
+
`;
|
|
12935
|
+
if (step.stdinSecret === "registration-token") {
|
|
12936
|
+
const tok = registrationToken(gh, repo);
|
|
12937
|
+
if (!tok.ok) throw new MushiCliError("E_INVALID_INPUT", `Could not get a registration token: ${tok.error}`, "your gh login needs Administration: write on the repo");
|
|
12938
|
+
stdin = `${tok.stdout.trim()}
|
|
12939
|
+
`;
|
|
12940
|
+
}
|
|
12941
|
+
const code = deps.runStep(step, stdin);
|
|
12942
|
+
if (code !== 0) {
|
|
12943
|
+
throw new MushiCliError("E_INVALID_INPUT", `Step ${i + 1} (${step.id}) failed with exit code ${code}. Later steps did not run.`, "fix the cause and run the same command again: the steps are safe to repeat");
|
|
12944
|
+
}
|
|
12945
|
+
}
|
|
12946
|
+
console.log("\nDone.");
|
|
12947
|
+
printNotes(plan.notes);
|
|
12948
|
+
});
|
|
12949
|
+
runner.command("audit").description("List the self-hosted runners on your repos and flag the ones to look at").option("--repo <owner/name>", "One repo (default: the repo of this folder)").option("--all", "Every repo your gh login lists (up to 200)").option("--json", "Machine-readable JSON output").action(async (opts) => {
|
|
12950
|
+
const gh = deps.gh;
|
|
12951
|
+
let repos;
|
|
12952
|
+
if (opts.all) {
|
|
12953
|
+
const list = listOwnRepos(gh);
|
|
12954
|
+
if (!list) throw new MushiCliError("E_INVALID_INPUT", "Could not list your repos with gh.", "run `gh auth login` first");
|
|
12955
|
+
repos = list;
|
|
12956
|
+
} else {
|
|
12957
|
+
const one = opts.repo ?? currentRepo(gh);
|
|
12958
|
+
if (!one) throw new MushiCliError("E_INVALID_INPUT", "Which repo?", "pass --repo owner/name, or --all");
|
|
12959
|
+
repos = [one];
|
|
12960
|
+
}
|
|
12961
|
+
let heartbeatRunners = null;
|
|
12962
|
+
const config = loadConfig();
|
|
12963
|
+
if (config.apiKey) {
|
|
12964
|
+
const qs = config.projectId ? `?project_id=${encodeURIComponent(config.projectId)}&days=7` : "?days=7";
|
|
12965
|
+
const ov = await apiCall(`/v1/admin/ci/overview${qs}`, { ...config, endpoint: config.endpoint ?? CLOUD_API_ENDPOINT });
|
|
12966
|
+
if (ov.ok) {
|
|
12967
|
+
heartbeatRunners = /* @__PURE__ */ new Set();
|
|
12968
|
+
for (const h of ov.data.hosts ?? []) {
|
|
12969
|
+
for (const r of h["runners"] ?? []) if (r.name) heartbeatRunners.add(r.name);
|
|
12970
|
+
}
|
|
12971
|
+
}
|
|
12972
|
+
}
|
|
12973
|
+
const audits = [];
|
|
12974
|
+
for (const repo of repos) {
|
|
12975
|
+
const { runners, error } = listRunners(gh, repo);
|
|
12976
|
+
if (opts.all && runners !== null && runners.length === 0) continue;
|
|
12977
|
+
audits.push(auditRepo({
|
|
12978
|
+
repo,
|
|
12979
|
+
runners,
|
|
12980
|
+
runnersError: error,
|
|
12981
|
+
heartbeatRunners,
|
|
12982
|
+
forkApproval: forkApprovalPolicy(gh, repo),
|
|
12983
|
+
prTargetWorkflows: prTargetWorkflows(gh, repo),
|
|
12984
|
+
isPublic: repoIsPublic(gh, repo)
|
|
12985
|
+
}));
|
|
12986
|
+
}
|
|
12987
|
+
if (outputIsJson(opts.json)) {
|
|
12988
|
+
console.log(JSON.stringify({ repos: audits }, null, 2));
|
|
12989
|
+
} else {
|
|
12990
|
+
if (audits.length === 0) console.log(`No self-hosted runners on ${repos.length} repo(s).`);
|
|
12991
|
+
for (const a of audits) for (const line of auditLines(a)) console.log(line);
|
|
12992
|
+
}
|
|
12993
|
+
if (audits.some((a) => a.findings.some((f) => f.severity === "critical"))) process.exitCode = 1;
|
|
12994
|
+
});
|
|
12995
|
+
}
|
|
12996
|
+
|
|
12997
|
+
// src/commands/verify.ts
|
|
12998
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync6 } from "fs";
|
|
12999
|
+
import { dirname as dirname6, resolve as resolve8 } from "path";
|
|
13000
|
+
var WORKFLOW_MAX_BYTES = 64 * 1024;
|
|
13001
|
+
var WORKFLOW_SHAPE = /uses:\s*kensaurus\/mushi-mushi\/\.github\/actions\/fix-verify@/;
|
|
13002
|
+
var VERIFY_WORKFLOW_PATH = ".github/workflows/mushi-verify.yml";
|
|
13003
|
+
function registerVerifyCommands(program2) {
|
|
13004
|
+
const verify = program2.command("verify").description("Verified merge: the workflow that proves an agent fix with a test (see also `mushi fixes verify`)");
|
|
13005
|
+
verify.command("workflow").description(`Print the mushi-verify workflow for this project, or write it with --out (save as ${VERIFY_WORKFLOW_PATH})`).option("--project <id>", "Project ID (defaults to the configured project)").option("--out <path>", `Write the YAML to this file, e.g. ${VERIFY_WORKFLOW_PATH}`).option("--json", "Machine-readable JSON output").addHelpText("after", `
|
|
13006
|
+
Examples:
|
|
13007
|
+
mushi verify workflow # print it
|
|
13008
|
+
mushi verify workflow --out ${VERIFY_WORKFLOW_PATH}
|
|
13009
|
+
|
|
13010
|
+
The runner labels and test command come from the console: Settings \u2192 Fixes \u2192
|
|
13011
|
+
Verified merge. Docs: https://kensaur.us/mushi-mushi/docs/admin/verified-merge`).action(async (opts) => {
|
|
13012
|
+
const config = requireConfig();
|
|
13013
|
+
const projectId2 = resolveProjectId(opts.project, config.projectId);
|
|
13014
|
+
const result = await apiCall(`/v1/admin/projects/${projectId2}/verify-workflow`, config);
|
|
13015
|
+
if (!result.ok) die(result);
|
|
13016
|
+
const yaml = result.data?.yaml;
|
|
13017
|
+
if (typeof yaml !== "string" || !yaml.trim()) {
|
|
13018
|
+
throw new MushiCliError("E_API_ERROR", "The server sent no workflow.", "Update the CLI (mushi upgrade) or copy it from the console: Settings \u2192 Fixes \u2192 Verified merge.");
|
|
13019
|
+
}
|
|
13020
|
+
const text2 = yaml.endsWith("\n") ? yaml : `${yaml}
|
|
13021
|
+
`;
|
|
13022
|
+
if (text2.length > WORKFLOW_MAX_BYTES || !WORKFLOW_SHAPE.test(text2) || text2.includes("\0")) {
|
|
13023
|
+
throw new MushiCliError("E_API_ERROR", "The server sent something that is not the Mushi verify workflow.", "Copy it from the console instead: Settings \u2192 Fixes \u2192 Verified merge.");
|
|
13024
|
+
}
|
|
13025
|
+
const out = opts.out ? resolve8(opts.out) : null;
|
|
13026
|
+
if (out && !/\.ya?ml$/i.test(out)) {
|
|
13027
|
+
throw new MushiCliError("E_INVALID_INPUT", `--out must be a .yml or .yaml file: ${out}`, "For example: --out .github/workflows/mushi-verify.yml");
|
|
13028
|
+
}
|
|
13029
|
+
if (out) {
|
|
13030
|
+
mkdirSync3(dirname6(out), { recursive: true });
|
|
13031
|
+
writeFileSync6(out, text2);
|
|
13032
|
+
}
|
|
13033
|
+
if (outputIsJson(opts.json)) {
|
|
13034
|
+
console.log(JSON.stringify({ ok: true, projectId: projectId2, yaml: text2, ...out ? { path: out } : {} }, null, 2));
|
|
13035
|
+
return;
|
|
13036
|
+
}
|
|
13037
|
+
if (!out) {
|
|
13038
|
+
process.stdout.write(text2);
|
|
13039
|
+
return;
|
|
13040
|
+
}
|
|
13041
|
+
console.log(`Wrote ${out}.`);
|
|
13042
|
+
console.log('Commit it to your default branch. Agent PRs then get a "Mushi verify" check.');
|
|
13043
|
+
});
|
|
13044
|
+
}
|
|
13045
|
+
|
|
10835
13046
|
// src/index.ts
|
|
10836
13047
|
installSignalHandlers();
|
|
10837
13048
|
var program = new Command().name("mushi").description("Mushi CLI \u2014 set up the SDK, triage a report, fix from your editor").version(MUSHI_CLI_VERSION).option("--profile <name>", "Use a named credential profile for this invocation (or set MUSHI_PROFILE)").option("-o, --output <format>", "Output format: text | json", "text").hook("preAction", (_thisCommand, actionCommand) => {
|
|
@@ -10897,6 +13108,8 @@ registerSpendCommands(program);
|
|
|
10897
13108
|
registerDesignCommands(program);
|
|
10898
13109
|
registerUxCommands(program);
|
|
10899
13110
|
registerUxRunsCommands(program);
|
|
13111
|
+
registerRunnerCommands(program);
|
|
13112
|
+
registerVerifyCommands(program);
|
|
10900
13113
|
registerCompletionCommand(program);
|
|
10901
13114
|
program.parseAsync().catch((err) => {
|
|
10902
13115
|
printAndExit(err);
|