@mushi-mushi/cli 0.32.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/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-3YMPNCJ4.js";
58
+ } from "./chunk-VAFSOAIO.js";
58
59
  import {
59
60
  MUSHI_CLI_VERSION,
60
61
  MUSHI_MCP_PIN_SPEC
61
- } from "./chunk-XIB5NTHP.js";
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 sleep = opts.sleep ?? defaultSleep;
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 sleep(intervalMs);
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, deps) {
398
- const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
399
- const now = deps.now ?? Date.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 sleep(pollIntervalMs);
407
+ await sleep2(pollIntervalMs);
407
408
  }
408
409
  firstPoll = false;
409
- const outcome = await deps.poll(endpoint, deviceData.device_code);
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((p3, i) => {
439
- console.log(` ${i + 1}. ${p3.name} (${p3.id})`);
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 num2 = parseInt(choice, 10);
445
- if (num2 >= 1 && num2 <= projectsList.length) {
446
- const picked = projectsList[num2 - 1];
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((p3) => slugify(p3.name) === wanted || p3.slug !== void 0 && slugify(p3.slug) === wanted);
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 = (q) => new Promise((resolve7) => rl.question(q, (a) => resolve7(a.trim())));
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 deps = {
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 deps) return FRAMEWORK_DEPS[dep];
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: join11, resolve: resolve7 } = await import("path");
913
- const root = resolve7(cwd);
914
- const pkgPath = join11(root, "package.json");
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 deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
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) => deps[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}@${deps[s]}`).join(", ") : "No @mushi-mushi/* package in package.json \u2014 run `npx mushi-mushi` to install"
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: join11, resolve: resolve7 } = await import("path");
1125
- const root = resolve7(cwd);
1126
- const pkgPath = join11(root, "package.json");
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 deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
1129
- const isCapHybrid = Boolean(deps["@capacitor/core"] && deps["react"]);
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(join11(root, f), "utf8");
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", join11(".cursor", "mcp.json")]) {
1150
+ for (const rel of [".mcp.json", join13(".cursor", "mcp.json")]) {
1150
1151
  try {
1151
- await access(join11(root, rel));
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(deps["@mushi-mushi/web"] || deps["@mushi-mushi/react"]);
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: deps["@mushi-mushi/capacitor"] ? `@mushi-mushi/capacitor@${deps["@mushi-mushi/capacitor"]} installed` : "Optional: @mushi-mushi/capacitor for native shell parity \u2014 WebView SDK covers most flows"
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: join11 } = await import("path");
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 = join11(d, e.name);
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((p3) => p3.test(src))) return full;
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: resolve7 } = await import("path");
1221
+ const { resolve: resolve9 } = await import("path");
1221
1222
  for (const sub of ["src", "app", "pages", "lib", "components"]) {
1222
- const found = await walk2(join11(resolve7(dir), sub), 0);
1223
+ const found = await walk2(join13(resolve9(dir), sub), 0);
1223
1224
  if (found) return found;
1224
1225
  }
1225
- return walk2(resolve7(dir), 0);
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: join11 } = await import("path");
1259
+ const { join: join13 } = await import("path");
1259
1260
  const sources = [];
1260
1261
  const invalid = [];
1261
1262
  const plainFiles = [
1262
- { path: join11(root, ".mcp.json"), claudeCode: true },
1263
- { path: join11(root, ".cursor", "mcp.json"), claudeCode: false },
1264
- { path: join11(home, ".cursor", "mcp.json"), claudeCode: false }
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 = join11(home, ".claude.json");
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: resolve7 } = await import("path");
1353
+ const { resolve: resolve9 } = await import("path");
1353
1354
  const { homedir } = await import("os");
1354
- const root = resolve7(cwd);
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: join11, resolve: resolve7 } = await import("path");
1479
- const root = resolve7(cwd);
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(join11(root, f), "utf8");
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-AX72RMKE.js");
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: join11 } = await import("path");
1580
- const pkg = JSON.parse(await readFile3(join11(cwd, "package.json"), "utf8"));
1581
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1582
- const candidates = Object.entries(deps).filter(([name]) => name.startsWith("@mushi-mushi/")).filter(([, v]) => /^[~^]?\d/.test(v));
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 deps = {
1837
+ const deps2 = {
1837
1838
  ...pkg?.dependencies,
1838
1839
  ...pkg?.devDependencies
1839
1840
  };
1840
- if (Object.keys(deps).some((d) => d.startsWith("@mushi-mushi/"))) {
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(deps, args) {
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 deps.createProject(args.endpoint, args.cliToken, args.target.name, {
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 deps.mintProjectKey(args.endpoint, args.cliToken, projectId2, {
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 deps.mintProjectKey(args.endpoint, args.cliToken, projectId2, {
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((resolve7, reject) => {
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) resolve7();
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-JWNAYQEU.js");
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((p3) => d.has(p3))
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((p3) => d.has(p3))
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(deps, catalog = MIGRATE_CATALOG) {
2705
- return catalog.filter((g) => g.status === "published" && g.match?.(deps)).map((g) => ({ guide: g, url: `${DOCS_BASE}/migrations/${g.slug}` }));
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 deps = depsFromPackageJson(pkg);
2726
- const matches = detectMigrations(deps);
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((resolve7, reject) => {
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", () => resolve7(hash.digest("hex")));
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 = (q) => new Promise((resolve7) => rl.question(q, (a) => resolve7(a.trim())));
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 num2 = parseInt(choice, 10);
3795
- if (num2 >= 1 && num2 <= projectsList.length) {
3796
- chosen = projectsList[num2 - 1];
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 config = loadConfig();
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((p3) => p3.id === config.projectId);
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 p3 of allProjectsList) {
4051
- const pServerName = buildMcpServerName({ projectId: p3.id, projectName: p3.name ?? void 0 });
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: p3.id,
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((p3) => p3.name ?? p3.id.slice(0, 8)).join(", ")})`);
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
- (resolve7) => rl.question(" Upgrade this key now via browser sign-in? [Y/n] ", (a) => resolve7(a.trim().toLowerCase()))
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(
@@ -4611,6 +4757,38 @@ ${review.summary}`);
4611
4757
  if (!opts.show) console.log("Review queued. It takes a few minutes; check with --show.");
4612
4758
  if (review) printReview(review);
4613
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);
4791
+ });
4614
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) => {
4615
4793
  const config = loadConfig();
4616
4794
  if (!config.apiKey) {
@@ -4640,6 +4818,33 @@ ${review.summary}`);
4640
4818
  const updated = data.check_run_updated_at ? new Date(data.check_run_updated_at).toISOString() : "\u2014";
4641
4819
  console.log(`CI status: ${status} \xB7 conclusion: ${conclusion} \xB7 updated: ${updated}`);
4642
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
+ });
4643
4848
  }
4644
4849
 
4645
4850
  // src/nudge.ts
@@ -4692,24 +4897,24 @@ var PRESETS = {
4692
4897
  }
4693
4898
  };
4694
4899
  function renderNudgeSnippet(opts) {
4695
- const p3 = { ...PRESETS[opts.phase], ...opts.overrides };
4696
- const dwellMs = p3.pageDwellMinutes > 0 ? `${p3.pageDwellMinutes} * 60 * 1000` : null;
4697
- const firstMs = p3.firstSessionSeconds > 0 ? `${p3.firstSessionSeconds} * 1000` : null;
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;
4698
4903
  const proactiveLines = [];
4699
- if (p3.rageClick) proactiveLines.push(` rageClick: true,`);
4700
- if (p3.longTask) proactiveLines.push(` longTask: true,`);
4701
- if (p3.apiCascade) proactiveLines.push(` apiCascade: true,`);
4702
- if (p3.errorBoundary) proactiveLines.push(` errorBoundary: true,`);
4703
- if (dwellMs) proactiveLines.push(` pageDwell: { thresholdMs: ${dwellMs} }, // ${p3.pageDwellMinutes}min on the same route`);
4704
- if (firstMs) proactiveLines.push(` firstSession: { delayMs: ${firstMs} }, // welcome new users after ${p3.firstSessionSeconds}s`);
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`);
4705
4910
  proactiveLines.push(` cooldown: {`);
4706
- proactiveLines.push(` maxProactivePerSession: ${p3.maxProactivePerSession},`);
4707
- proactiveLines.push(` dismissCooldownHours: ${p3.dismissCooldownHours},`);
4708
- proactiveLines.push(` suppressAfterDismissals: ${p3.suppressAfterDismissals},`);
4911
+ proactiveLines.push(` maxProactivePerSession: ${p4.maxProactivePerSession},`);
4912
+ proactiveLines.push(` dismissCooldownHours: ${p4.dismissCooldownHours},`);
4913
+ proactiveLines.push(` suppressAfterDismissals: ${p4.suppressAfterDismissals},`);
4709
4914
  proactiveLines.push(` },`);
4710
4915
  const widgetLines = [];
4711
- if (p3.featureRequestCard) widgetLines.push(` featureRequestCard: true,`);
4712
- if (p3.betaMode) {
4916
+ if (p4.featureRequestCard) widgetLines.push(` featureRequestCard: true,`);
4917
+ if (p4.betaMode) {
4713
4918
  widgetLines.push(` betaMode: {`);
4714
4919
  widgetLines.push(` enabled: true,`);
4715
4920
  widgetLines.push(` label: 'BETA',`);
@@ -4731,23 +4936,23 @@ function renderNudgeSnippet(opts) {
4731
4936
  ].join("\n");
4732
4937
  }
4733
4938
  function renderNudgeExplainer(phase) {
4734
- const p3 = PRESETS[phase];
4939
+ const p4 = PRESETS[phase];
4735
4940
  const lines = [];
4736
4941
  lines.push(``);
4737
4942
  lines.push(`Nudge preset for "${phase}" phase:`);
4738
- lines.push(` - max ${p3.maxProactivePerSession} proactive prompts per session`);
4739
- lines.push(` - ${p3.dismissCooldownHours}h cooldown after a dismissal`);
4740
- lines.push(` - suppress permanently after ${p3.suppressAfterDismissals} consecutive dismissals`);
4741
- if (p3.pageDwellMinutes > 0) lines.push(` - fire page-dwell trigger after ${p3.pageDwellMinutes} continuous minutes on a route`);
4742
- if (p3.firstSessionSeconds > 0) lines.push(` - welcome new users with a button pulse ${p3.firstSessionSeconds}s after init`);
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`);
4743
4948
  lines.push(` - signals enabled: ${[
4744
- p3.rageClick && "rage-click",
4745
- p3.longTask && "long-task",
4746
- p3.apiCascade && "api-cascade",
4747
- p3.errorBoundary && "error-boundary"
4949
+ p4.rageClick && "rage-click",
4950
+ p4.longTask && "long-task",
4951
+ p4.apiCascade && "api-cascade",
4952
+ p4.errorBoundary && "error-boundary"
4748
4953
  ].filter(Boolean).join(", ")}`);
4749
- lines.push(` - feature-request card: ${p3.featureRequestCard ? "shown" : "hidden"}`);
4750
- lines.push(` - beta-mode UI: ${p3.betaMode ? "on" : "off"}`);
4954
+ lines.push(` - feature-request card: ${p4.featureRequestCard ? "shown" : "hidden"}`);
4955
+ lines.push(` - beta-mode UI: ${p4.betaMode ? "on" : "off"}`);
4751
4956
  lines.push(``);
4752
4957
  return lines.join("\n");
4753
4958
  }
@@ -4802,14 +5007,14 @@ async function planUpgrade(cwd) {
4802
5007
  const root = resolve2(cwd);
4803
5008
  const pkg = readPackageJson(root);
4804
5009
  const pm = detectPackageManager(root);
4805
- const deps = {
5010
+ const deps2 = {
4806
5011
  ...pkg?.dependencies ?? {},
4807
5012
  ...pkg?.devDependencies ?? {}
4808
5013
  };
4809
- const installed = MUSHI_PACKAGES.filter((name) => deps[name]);
5014
+ const installed = MUSHI_PACKAGES.filter((name) => deps2[name]);
4810
5015
  const entries = await Promise.all(
4811
5016
  installed.map(async (name) => {
4812
- const current = deps[name] ?? "";
5017
+ const current = deps2[name] ?? "";
4813
5018
  const currentCore = current.replace(/^[\^~>=<]*/, "");
4814
5019
  if (!/^\d/.test(currentCore)) {
4815
5020
  return { name, current, latest: null, willUpgrade: false };
@@ -5395,7 +5600,7 @@ function spendDownStatusLine(k) {
5395
5600
  const name = `${k.label ?? "(no label)"} ${k.keyHint ?? ""}`.trim();
5396
5601
  const expires = k.creditsExpireAt ? k.creditsExpireAt.slice(0, 10) : "not set";
5397
5602
  const model = k.model ?? "app default";
5398
- const apps = k.projects.length > 0 ? k.projects.map((p3) => p3.name).join(", ") : "none";
5603
+ const apps = k.projects.length > 0 ? k.projects.map((p4) => p4.name).join(", ") : "none";
5399
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}`;
5400
5605
  }
5401
5606
 
@@ -5428,8 +5633,9 @@ function registerKeysCommands(program2) {
5428
5633
  const lastUsed = k.last_used_at ? ` used=${new Date(k.last_used_at).toISOString()}` : "";
5429
5634
  const baseUrl = k.base_url ? ` base=${k.base_url}` : "";
5430
5635
  const hint = k.key_hint ? ` hint=${k.key_hint}` : "";
5636
+ const scope = ` scope=${k.scope ?? "project"}`;
5431
5637
  console.log(
5432
- `${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}`
5433
5639
  );
5434
5640
  }
5435
5641
  for (const k of legacyKeys) {
@@ -5511,7 +5717,9 @@ function registerKeysCommands(program2) {
5511
5717
  }
5512
5718
  );
5513
5719
  });
5514
- keys.command("add").description("Add a new API key to the pool").requiredOption("--provider <p>", "Provider: anthropic, openai, openrouter, firecrawl, browserbase, cursor, supabase").option(
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(
5515
5723
  "--key <k>",
5516
5724
  "The API key value (prefer the MUSHI_BYOK_KEY env var to keep it out of shell history)"
5517
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(
@@ -5924,54 +6132,6 @@ Recent runs for story ${storyId.slice(0, 8)}\u2026:
5924
6132
  });
5925
6133
  }
5926
6134
 
5927
- // src/command-helpers.ts
5928
- function orgSegment(org) {
5929
- if (!org || org === "current") return "current";
5930
- return encodeURIComponent(requireUuid(org, "organization id"));
5931
- }
5932
- function dieOrgError(result) {
5933
- const { code } = result.error;
5934
- if (code === "ORG_REQUIRED") {
5935
- const orgs = Array.isArray(result.error["organizations"]) ? result.error["organizations"] : [];
5936
- if (orgs.length > 0) {
5937
- process.stderr.write("\n Pick one and pass it with --org:\n");
5938
- for (const o of orgs) {
5939
- process.stderr.write(` --org ${String(o.id)} ${typeof o.name === "string" ? o.name : ""}
5940
- `);
5941
- }
5942
- process.stderr.write("\n");
5943
- }
5944
- } else if (code.endsWith("_NEEDS_ACCOUNT_KEY")) {
5945
- process.stderr.write(
5946
- "\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"
5947
- );
5948
- }
5949
- die(result);
5950
- }
5951
- function requireYes(yes, action) {
5952
- if (yes) return;
5953
- throw new MushiCliError(
5954
- "E_INVALID_INPUT",
5955
- `${action} Re-run with --yes to go ahead.`,
5956
- "nothing was sent"
5957
- );
5958
- }
5959
- function resolveProjectId(flag, configured) {
5960
- const raw = flag ?? configured;
5961
- if (!raw) {
5962
- throw new MushiCliError(
5963
- "E_PROJECT_MISSING",
5964
- "Project ID not configured.",
5965
- "pass --project-id <uuid> or run `mushi login --project-id <uuid>`"
5966
- );
5967
- }
5968
- return requireUuid(raw, "project id");
5969
- }
5970
- function oneLine(text2, max = 80) {
5971
- const flat = String(text2 ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
5972
- return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
5973
- }
5974
-
5975
6135
  // src/commands/audit.ts
5976
6136
  var KNOWN_GATES = [
5977
6137
  "dead_handler",
@@ -7063,10 +7223,10 @@ var REPO_SCAN_PATHS = [
7063
7223
  ".github/workflows/"
7064
7224
  ];
7065
7225
  function isRepoScanPath(path) {
7066
- const p3 = normalizePath(path);
7067
- if (REPO_SCAN_PATHS.includes(p3)) return true;
7068
- if (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(p3)) return true;
7069
- return /^ios\/(?:[^/]+\/)?[^/]+\.xcodeproj\/project\.pbxproj$/.test(p3);
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);
7070
7230
  }
7071
7231
  function normalizePath(path) {
7072
7232
  return path.replace(/\\/g, "/").replace(/^\.\//, "");
@@ -7326,7 +7486,7 @@ function bundleRootsFromFlags(root, dirs) {
7326
7486
  }
7327
7487
  return [...new Set(roots)].sort();
7328
7488
  }
7329
- function scanBuiltBundles(root, readFile3 = (p3) => readFileSync3(p3, "utf8"), bundleDirs) {
7489
+ function scanBuiltBundles(root, readFile3 = (p4) => readFileSync3(p4, "utf8"), bundleDirs) {
7330
7490
  const roots = bundleDirs && bundleDirs.length > 0 ? bundleRootsFromFlags(root, bundleDirs) : findBundleRoots(root);
7331
7491
  const out = { roots, scannedFiles: 0, truncated: false, unreadable: 0, findings: [] };
7332
7492
  const walk2 = (dir) => {
@@ -7371,7 +7531,7 @@ function scanBuiltBundles(root, readFile3 = (p3) => readFileSync3(p3, "utf8"), b
7371
7531
  for (const r of roots) walk2(join5(root, r));
7372
7532
  return out;
7373
7533
  }
7374
- function scanLocalRepo(root, readFile3 = (p3) => readFileSync3(p3, "utf8"), opts = {}) {
7534
+ function scanLocalRepo(root, readFile3 = (p4) => readFileSync3(p4, "utf8"), opts = {}) {
7375
7535
  const { files, truncated, unreadable: unreadableDirs } = listRepoFiles(root);
7376
7536
  let unreadable = unreadableDirs;
7377
7537
  const findings = [];
@@ -7561,8 +7721,8 @@ function toHex(c) {
7561
7721
  function num(token, scale) {
7562
7722
  const t = token.trim();
7563
7723
  if (t === "none") return 0;
7564
- const pct = /^(-?[\d.]+)%$/.exec(t);
7565
- if (pct) return Number(pct[1]) / 100 * scale;
7724
+ const pct2 = /^(-?[\d.]+)%$/.exec(t);
7725
+ if (pct2) return Number(pct2[1]) / 100 * scale;
7566
7726
  const deg = /^(-?[\d.]+)(deg|turn|rad|grad)?$/.exec(t);
7567
7727
  if (!deg) return null;
7568
7728
  const v = Number(deg[1]);
@@ -7581,7 +7741,7 @@ function num(token, scale) {
7581
7741
  function splitArgs(body) {
7582
7742
  const [main, alpha] = body.split("/");
7583
7743
  const parts = main.includes(",") ? main.split(",") : main.trim().split(/\s+/);
7584
- const cleaned = parts.map((p3) => p3.trim()).filter(Boolean);
7744
+ const cleaned = parts.map((p4) => p4.trim()).filter(Boolean);
7585
7745
  if (alpha === void 0 && cleaned.length === 4) return { parts: cleaned.slice(0, 3), alpha: cleaned[3] };
7586
7746
  return { parts: cleaned, alpha: alpha?.trim() ?? null };
7587
7747
  }
@@ -7630,7 +7790,7 @@ function parseCssColor(input) {
7630
7790
  if (parts.length !== 3) return null;
7631
7791
  const a = alphaOf(alpha);
7632
7792
  if (kind === "rgb" || kind === "rgba") {
7633
- const ch = parts.map((p3) => num(p3, 255));
7793
+ const ch = parts.map((p4) => num(p4, 255));
7634
7794
  if (ch.some((v) => v == null)) return null;
7635
7795
  const [r2, g2, b2] = ch;
7636
7796
  return { r: clamp01(r2 / 255), g: clamp01(g2 / 255), b: clamp01(b2 / 255), a };
@@ -7950,7 +8110,7 @@ function normalizeTokenSet(files) {
7950
8110
  };
7951
8111
  const norm = r ? normalizeByType(type, r.value, note2) : { value: null, display: alias ? `{${alias}} (unresolved)` : "unresolved", hex: null, px: null };
7952
8112
  const ext = tok.ext;
7953
- const str = (k) => ext && typeof ext[k] === "string" ? ext[k] : null;
8113
+ const str2 = (k) => ext && typeof ext[k] === "string" ? ext[k] : null;
7954
8114
  tokens.push({
7955
8115
  path: tok.path,
7956
8116
  type,
@@ -7959,9 +8119,9 @@ function normalizeTokenSet(files) {
7959
8119
  hex: norm.hex,
7960
8120
  px: norm.px,
7961
8121
  aliasOf: alias,
7962
- cssVar: str("cssVar"),
7963
- ts: str("ts"),
7964
- rn: str("rn"),
8122
+ cssVar: str2("cssVar"),
8123
+ ts: str2("ts"),
8124
+ rn: str2("rn"),
7965
8125
  description: tok.description,
7966
8126
  file: tok.file,
7967
8127
  role: tok.role,
@@ -7988,13 +8148,13 @@ function declaredFontFamilies(tokens) {
7988
8148
  // src/recipe/engine/recipe-glob.ts
7989
8149
  function normalizeRepoPath(input) {
7990
8150
  if (typeof input !== "string") return null;
7991
- const p3 = input.trim();
7992
- if (p3.length === 0 || p3.length > 512) return null;
7993
- if (p3.startsWith("/") || p3.startsWith("~")) return null;
7994
- if (p3.includes("\\") || p3.includes("%")) return null;
7995
- if (/^[A-Za-z]:/.test(p3)) return null;
7996
- if (/[\u0000-\u001f\u007f]/.test(p3)) return null;
7997
- const segments = p3.split("/");
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("/");
7998
8158
  for (const seg of segments) {
7999
8159
  if (seg === "" || seg === "." || seg === "..") return null;
8000
8160
  }
@@ -8365,13 +8525,13 @@ function stripBraces(ref) {
8365
8525
  }
8366
8526
  function evaluateContrast(tokens, pairs) {
8367
8527
  const byPath = new Map(tokens.map((t) => [t.path, t]));
8368
- return pairs.map((p3) => {
8369
- const fgPath = stripBraces(p3.fg);
8370
- const bgPath = stripBraces(p3.bg);
8371
- const min = p3.min ?? (p3.large ? 3 : 4.5);
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);
8372
8532
  const fg = byPath.get(fgPath);
8373
8533
  const bg = byPath.get(bgPath);
8374
- const base = { fg: fgPath, bg: bgPath, min, use: p3.use ?? null };
8534
+ const base = { fg: fgPath, bg: bgPath, min, use: p4.use ?? null };
8375
8535
  const fc = fg?.hex ? parseCssColor(fg.hex) : null;
8376
8536
  const bc = bg?.hex ? parseCssColor(bg.hex) : null;
8377
8537
  if (!fc || !bc) {
@@ -8398,7 +8558,7 @@ function contrastFindings(results, tokens, rule) {
8398
8558
  }
8399
8559
  function computeDevianceScore(input) {
8400
8560
  const kloc = Math.max(1, input.scannedLines / 1e3);
8401
- let num2 = 0;
8561
+ let num3 = 0;
8402
8562
  let den = 0;
8403
8563
  const breakdown = input.rules.map((r) => {
8404
8564
  const count = input.counts[r.id] ?? 0;
@@ -8420,12 +8580,12 @@ function computeDevianceScore(input) {
8420
8580
  }
8421
8581
  }
8422
8582
  if (r.enabled && applicable) {
8423
- num2 += weight * penalty;
8583
+ num3 += weight * penalty;
8424
8584
  den += weight;
8425
8585
  }
8426
8586
  return { rule: r.id, enabled: r.enabled, applicable, severity: r.severity, weight, count, density, penalty: Math.round(penalty * 1e3) / 1e3 };
8427
8587
  });
8428
- return { score: den === 0 ? null : Math.round(100 * num2 / den), breakdown };
8588
+ return { score: den === 0 ? null : Math.round(100 * num3 / den), breakdown };
8429
8589
  }
8430
8590
  var SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
8431
8591
  function sortFindings(findings) {
@@ -8501,19 +8661,19 @@ function planTokenSets(declared, treePaths, directions = []) {
8501
8661
  if (directions.length > 0) {
8502
8662
  for (const d of directions) {
8503
8663
  if (d.status !== "inactive" || d.name === activeName) continue;
8504
- const files = d.tokens.map((p3) => normalizeRepoPath(p3)).filter((p3) => p3 !== null);
8664
+ const files = d.tokens.map((p4) => normalizeRepoPath(p4)).filter((p4) => p4 !== null);
8505
8665
  sets.push({ name: d.name, kind: "direction", active: false, files: files.map((path) => ({ path, role: "source", generator: null })), note: d.note ?? null });
8506
8666
  }
8507
8667
  } else if (sameDirection) {
8508
8668
  const base = dirs[0].base;
8509
8669
  const siblings = /* @__PURE__ */ new Map();
8510
- for (const p3 of treePaths) {
8511
- if (!p3.startsWith(base)) continue;
8512
- const d = directionOf(p3);
8670
+ for (const p4 of treePaths) {
8671
+ if (!p4.startsWith(base)) continue;
8672
+ const d = directionOf(p4);
8513
8673
  if (!d || d.base !== base || d.name === activeName) continue;
8514
8674
  if (!/\.tokens\.json$|^tokens\.json$/.test(d.file)) continue;
8515
8675
  const list = siblings.get(d.name) ?? [];
8516
- list.push(p3);
8676
+ list.push(p4);
8517
8677
  siblings.set(d.name, list);
8518
8678
  }
8519
8679
  for (const name of [...siblings.keys()].sort()) {
@@ -8568,10 +8728,22 @@ var ALWAYS_IGNORE = [
8568
8728
  "**/android/**",
8569
8729
  "**/ios/**"
8570
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
+ }
8571
8743
  function selectScanFiles(entries, manifest, excludePaths, limits = SCAN_LIMITS) {
8572
8744
  const declared = manifest?.design?.literalScan?.globs;
8573
8745
  const globs = declared && declared.length > 0 ? [...declared] : DEFAULT_SCAN_GLOBS;
8574
- const ignore = [...ALWAYS_IGNORE, ...manifest?.design?.literalScan?.ignore ?? []];
8746
+ const ignore = [...ALWAYS_IGNORE, ...tokenDefinitionGlobs(manifest), ...manifest?.design?.literalScan?.ignore ?? []];
8575
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);
8576
8748
  const files = [];
8577
8749
  let bytes = 0;
@@ -8751,16 +8923,16 @@ function readContrast(raw, issues) {
8751
8923
  return void 0;
8752
8924
  }
8753
8925
  const out = [];
8754
- raw.forEach((p3, i) => {
8755
- if (!isObject(p3) || !isString(p3.fg) || !isString(p3.bg) || !p3.fg || !p3.bg) {
8926
+ raw.forEach((p4, i) => {
8927
+ if (!isObject(p4) || !isString(p4.fg) || !isString(p4.bg) || !p4.fg || !p4.bg) {
8756
8928
  issues.push({ severity: "error", message: `design.contrast[${i}] needs fg and bg token paths.` });
8757
8929
  return;
8758
8930
  }
8759
- const pair = { fg: p3.fg, bg: p3.bg };
8760
- if (typeof p3.min === "number" && p3.min >= 1 && p3.min <= 21) pair.min = p3.min;
8761
- else if (p3.min !== void 0) issues.push({ severity: "error", message: `design.contrast[${i}].min must be a ratio from 1 to 21.` });
8762
- if (typeof p3.large === "boolean") pair.large = p3.large;
8763
- if (isString(p3.use)) pair.use = p3.use;
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;
8764
8936
  out.push(pair);
8765
8937
  });
8766
8938
  return out;
@@ -8808,10 +8980,14 @@ function readScanManifest(manifest) {
8808
8980
  }
8809
8981
  if (d.literalScan !== void 0) {
8810
8982
  const ls = d.literalScan;
8811
- if (!isObject(ls) || ls.globs !== void 0 && !isStringList(ls.globs) || ls.ignore !== void 0 && !isStringList(ls.ignore)) {
8812
- issues.push({ severity: "error", message: "design.literalScan.globs and .ignore must be lists of file globs." });
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." });
8813
8985
  } else {
8814
- design.literalScan = { ...ls.globs ? { globs: ls.globs } : {}, ...ls.ignore ? { ignore: ls.ignore } : {} };
8986
+ design.literalScan = {
8987
+ ...ls.globs ? { globs: ls.globs } : {},
8988
+ ...ls.ignore ? { ignore: ls.ignore } : {},
8989
+ ...ls.tokenDefinitions ? { tokenDefinitions: ls.tokenDefinitions } : {}
8990
+ };
8815
8991
  }
8816
8992
  }
8817
8993
  const contrast = readContrast(d.contrast, issues);
@@ -8859,10 +9035,10 @@ function listRepoFiles2(root) {
8859
9035
  paths = walk(root);
8860
9036
  }
8861
9037
  const entries = [];
8862
- for (const p3 of paths) {
9038
+ for (const p4 of paths) {
8863
9039
  try {
8864
- const st = statSync3(join6(root, p3));
8865
- if (st.isFile()) entries.push({ path: p3, size: st.size });
9040
+ const st = statSync3(join6(root, p4));
9041
+ if (st.isFile()) entries.push({ path: p4, size: st.size });
8866
9042
  } catch {
8867
9043
  }
8868
9044
  }
@@ -8914,7 +9090,7 @@ function workspacePatterns(root, pkg) {
8914
9090
  const out = [];
8915
9091
  const ws = pkg?.workspaces;
8916
9092
  const list = Array.isArray(ws) ? ws : isObject2(ws) && Array.isArray(ws.packages) ? ws.packages : [];
8917
- for (const p3 of list) if (typeof p3 === "string") out.push(p3);
9093
+ for (const p4 of list) if (typeof p4 === "string") out.push(p4);
8918
9094
  const yaml = readCapped(root, "pnpm-workspace.yaml");
8919
9095
  if (yaml) {
8920
9096
  let inPackages = false;
@@ -8928,7 +9104,7 @@ function workspacePatterns(root, pkg) {
8928
9104
  if (item) out.push(item[1]);
8929
9105
  }
8930
9106
  }
8931
- return out.map((p3) => p3.trim().replace(/^\.\//, "").replace(/\/+$/, "")).filter(Boolean);
9107
+ return out.map((p4) => p4.trim().replace(/^\.\//, "").replace(/\/+$/, "")).filter(Boolean);
8932
9108
  }
8933
9109
  function scriptRunner(files) {
8934
9110
  if (files.has("pnpm-lock.yaml")) return "pnpm";
@@ -8971,25 +9147,25 @@ function starterManifest(root) {
8971
9147
  const files = tracked.length ? tracked : walk(root);
8972
9148
  const fileSet = new Set(files);
8973
9149
  const pkg = readJson(join6(root, "package.json"));
8974
- const deps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
9150
+ const deps2 = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
8975
9151
  const rootScripts = Object.entries(pkg?.scripts ?? {}).filter((e) => typeof e[1] === "string");
8976
- const native = files.some((f) => /^capacitor\.config\.(ts|js|json)$/.test(f)) || "expo" in deps || "react-native" in deps || files.some((f) => f.startsWith("android/") || f.startsWith("ios/"));
8977
- const platforms = ["web", ...files.some((f) => f.startsWith("android/")) || "expo" in deps ? ["android"] : [], ...files.some((f) => f.startsWith("ios/")) || "expo" in deps ? ["ios"] : []];
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"] : []];
8978
9154
  const archived = files.filter((f) => f.endsWith("/ARCHIVED.md")).map((f) => f.slice(0, -"/ARCHIVED.md".length));
8979
9155
  const isArchived = (f) => archived.some((d) => f.startsWith(`${d}/`));
8980
9156
  const live = (f) => !FIXTURE_DIR.test(f) && !isArchived(f);
8981
9157
  const tokenPaths = files.filter((f) => live(f) && (/\.tokens\.json$/.test(f) || /(^|\/)dtcg\/[^/]+\.json$/.test(f))).slice(0, 20);
8982
9158
  const workflows = files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f));
8983
9159
  const hasSource = (dir, ext) => files.some((f) => f.startsWith(`${dir}/`) && live(f) && ext.test(f));
8984
- const rootIsApp = files.some((f) => /^(next|vite)\.config\.(js|mjs|cjs|ts|mts)$/.test(f)) || "expo" in deps || rootScripts.some(([name]) => name === "dev" || name === "build");
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");
8985
9161
  const rootDirs = rootIsApp ? ROOT_APP_DIRS.filter((d) => hasSource(d, /\.(ts|tsx|css)$/)) : [];
8986
9162
  const patterns = workspacePatterns(root, pkg);
8987
9163
  let packageRoots;
8988
9164
  if (patterns.length) {
8989
- const include = patterns.filter((p3) => !p3.startsWith("!"));
8990
- const exclude = patterns.filter((p3) => p3.startsWith("!")).map((p3) => p3.slice(1));
9165
+ const include = patterns.filter((p4) => !p4.startsWith("!"));
9166
+ const exclude = patterns.filter((p4) => p4.startsWith("!")).map((p4) => p4.slice(1));
8991
9167
  const pkgDirs = [...new Set(files.filter((f) => f.endsWith("/package.json") && !f.includes("node_modules/")).map((f) => f.slice(0, -"/package.json".length)))];
8992
- packageRoots = pkgDirs.filter((d) => !isArchived(`${d}/`) && include.some((p3) => matchGlob(d, p3)) && !exclude.some((p3) => matchGlob(d, p3))).sort().flatMap((d) => hasSource(`${d}/src`, /\.(tsx|css)$/) ? [`${d}/src`] : hasSource(d, /\.(tsx|css)$/) ? [d] : []);
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] : []);
8993
9169
  } else {
8994
9170
  packageRoots = [...new Set(files.filter((f) => live(f) && /^(apps|packages)\/[^/]+\/src\/.+\.(tsx|css)$/.test(f)).map((f) => f.split("/").slice(0, 3).join("/")))];
8995
9171
  }
@@ -9024,7 +9200,7 @@ function starterManifest(root) {
9024
9200
  }))
9025
9201
  };
9026
9202
  }
9027
- const envExample = ENV_EXAMPLES.find((p3) => existsSync3(join6(root, p3)));
9203
+ const envExample = ENV_EXAMPLES.find((p4) => existsSync3(join6(root, p4)));
9028
9204
  if (envExample) manifest.env = { environments: ["production"], required: [], example: envExample };
9029
9205
  const sourceDirs = tokens.filter((t) => t.role === "source").map((t) => `${t.path.split("/").slice(0, -1).join("/") || "."}/**`);
9030
9206
  manifest.change = { allowPaths: [MANIFEST, ...new Set(sourceDirs)] };
@@ -9062,11 +9238,11 @@ function checkRecipe(root) {
9062
9238
  const manifest = parsed;
9063
9239
  files[MANIFEST] = text2;
9064
9240
  const change = isObject2(manifest.change) ? manifest.change : {};
9065
- for (const p3 of asStringList(change.allowPaths)) {
9066
- if (p3.startsWith(".github") || /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|[^/]*\.lock)$/.test(p3)) {
9067
- issues.push({ severity: "error", message: `change.allowPaths entry "${p3}" points at files Mushi never writes (workflows, lockfiles).`, path: MANIFEST });
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 });
9068
9244
  }
9069
- if (/(^|\/)\.env(?!\.example)/.test(p3)) issues.push({ severity: "error", message: `change.allowPaths entry "${p3}" would let a PR touch env files.`, path: MANIFEST });
9245
+ if (/(^|\/)\.env(?!\.example)/.test(p4)) issues.push({ severity: "error", message: `change.allowPaths entry "${p4}" would let a PR touch env files.`, path: MANIFEST });
9070
9246
  }
9071
9247
  const shaped = readScanManifest(manifest);
9072
9248
  issues.push(...shaped.issues);
@@ -9081,14 +9257,14 @@ function checkRecipe(root) {
9081
9257
  const judged = judgingSet(tokens);
9082
9258
  const tokenCount = judged?.tokens.length ?? 0;
9083
9259
  let pushedBytes = text2.length;
9084
- const addPush = (p3) => {
9085
- if (files[p3] !== void 0 || Object.keys(files).length >= PUSH_MAX_FILES) return;
9086
- const f = read(p3, PUSH_MAX_FILE_BYTES);
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);
9087
9263
  if (f.kind !== "file" || pushedBytes + f.text.length > PUSH_MAX_TOTAL_BYTES) return;
9088
- files[p3] = f.text;
9264
+ files[p4] = f.text;
9089
9265
  pushedBytes += f.text.length;
9090
9266
  };
9091
- for (const p3 of tokenFilePaths(tokens)) addPush(p3);
9267
+ for (const p4 of tokenFilePaths(tokens)) addPush(p4);
9092
9268
  const design = isObject2(manifest.design) ? manifest.design : {};
9093
9269
  for (const c of Array.isArray(design.css) ? design.css : []) {
9094
9270
  const rel = isObject2(c) && typeof c.path === "string" ? normalizeRepoPath(c.path) : null;
@@ -9099,9 +9275,9 @@ function checkRecipe(root) {
9099
9275
  if (judged) {
9100
9276
  const pick = selectScanFiles(tree, scanManifest, [...tokenFilePaths(tokens), MANIFEST]);
9101
9277
  const texts = /* @__PURE__ */ new Map();
9102
- for (const p3 of pick.files) {
9103
- const f = read(p3, Number.POSITIVE_INFINITY);
9104
- texts.set(p3, f.kind === "file" ? f.text : null);
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);
9105
9281
  }
9106
9282
  const result = computeDeviance({ manifest: scanManifest, tokens }, texts);
9107
9283
  findings = result.findings;
@@ -9675,7 +9851,7 @@ function registerPortfolioCommands(program2) {
9675
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) => {
9676
9852
  const config = requireConfig();
9677
9853
  const gid = requireUuid(groupId, "group id");
9678
- const ids = (projectIds ?? []).map((p3) => requireUuid(p3, "project id"));
9854
+ const ids = (projectIds ?? []).map((p4) => requireUuid(p4, "project id"));
9679
9855
  const result = await apiCall(
9680
9856
  `/v1/admin/orgs/${orgSegment(opts.org)}/project-groups/${gid}/projects`,
9681
9857
  config,
@@ -10477,16 +10653,16 @@ function registerRepoCommands(program2) {
10477
10653
  const projectId2 = resolveProjectId(opts.projectId, config.projectId);
10478
10654
  const preview = await apiCall(`/v1/admin/projects/${projectId2}/codebase/diagram/publish-preview`, config);
10479
10655
  if (!preview.ok) die(preview);
10480
- const p3 = preview.data;
10481
- if (!p3.can_publish) {
10482
- throw new MushiCliError("E_INVALID_INPUT", p3.publish_blocked_reason ?? "This diagram cannot be published.", "publish it from the console instead");
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");
10483
10659
  }
10484
- console.log(`Will publish to ${p3.url}${p3.repo_private ? " (the repo is private on GitHub)" : ""}.`);
10660
+ console.log(`Will publish to ${p4.url}${p4.repo_private ? " (the repo is private on GitHub)" : ""}.`);
10485
10661
  requireYes(opts.yes, "Publishing makes the diagram public.");
10486
10662
  const result = await apiCall(
10487
10663
  `/v1/admin/projects/${projectId2}/codebase/diagram/publish`,
10488
10664
  config,
10489
- { method: "POST", body: JSON.stringify({ diagram_id: p3.diagram_id, payload_hash: p3.payload_hash }) },
10665
+ { method: "POST", body: JSON.stringify({ diagram_id: p4.diagram_id, payload_hash: p4.payload_hash }) },
10490
10666
  { timeoutMs: PUBLISH_TIMEOUT_MS2 }
10491
10667
  );
10492
10668
  if (!result.ok) die(result);
@@ -10705,6 +10881,15 @@ function parseOnOff(raw, flag) {
10705
10881
  if (v === "off" || v === "false" || v === "no") return false;
10706
10882
  throw new MushiCliError("E_INVALID_INPUT", `${flag} must be on or off`);
10707
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
+ }
10708
10893
  function renderSettings(s) {
10709
10894
  const lines = [
10710
10895
  `Threshold: ${s.threshold} (the actions below fire when the design score is above it)`,
@@ -10748,6 +10933,23 @@ function registerDesignCommands(program2) {
10748
10933
  console.log("Saved.");
10749
10934
  for (const line of renderSettings(result.data)) console.log(line);
10750
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
+ });
10751
10953
  }
10752
10954
 
10753
10955
  // src/commands/ux.ts
@@ -10766,7 +10968,7 @@ function uxCommandLine(args, env = process.env, platform = process.platform, npx
10766
10968
  const local = env.MUSHI_UX_BIN?.trim();
10767
10969
  if (local) return { cmd: process.execPath, argv: [local, ...args], shell: false };
10768
10970
  const npxArgs = ["--yes", "--package", UX_PACKAGE, "mushi-ux", ...args];
10769
- const found = (typeof npxCli === "string" ? [npxCli] : npxCli).find((p3) => hasFile(p3));
10971
+ const found = (typeof npxCli === "string" ? [npxCli] : npxCli).find((p4) => hasFile(p4));
10770
10972
  if (found) return { cmd: process.execPath, argv: [found, ...npxArgs], shell: false };
10771
10973
  if (platform === "win32") return { cmd: "npx", argv: npxArgs.map(cmdQuote), shell: true };
10772
10974
  return { cmd: "npx", argv: npxArgs, shell: false };
@@ -10884,6 +11086,1963 @@ function registerUxRunsCommands(program2) {
10884
11086
  });
10885
11087
  }
10886
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
+
10887
13046
  // src/index.ts
10888
13047
  installSignalHandlers();
10889
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) => {
@@ -10949,6 +13108,8 @@ registerSpendCommands(program);
10949
13108
  registerDesignCommands(program);
10950
13109
  registerUxCommands(program);
10951
13110
  registerUxRunsCommands(program);
13111
+ registerRunnerCommands(program);
13112
+ registerVerifyCommands(program);
10952
13113
  registerCompletionCommand(program);
10953
13114
  program.parseAsync().catch((err) => {
10954
13115
  printAndExit(err);