@mushi-mushi/cli 0.28.0 → 0.29.1

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
@@ -6,6 +6,7 @@ import {
6
6
  FULL_CLI_KEY_SCOPES,
7
7
  MCP_CLIENTS,
8
8
  MCP_CLIENT_LABEL,
9
+ MushiCliError,
9
10
  SDK_KEY_LABEL,
10
11
  SDK_KEY_SCOPES,
11
12
  apiCall,
@@ -52,11 +53,11 @@ import {
52
53
  setGlobalOutputFormat,
53
54
  trySaveKeyToKeychain,
54
55
  writeMcpServerEntry
55
- } from "./chunk-FJETUM25.js";
56
+ } from "./chunk-MDYYLRWB.js";
56
57
  import {
57
58
  MUSHI_CLI_VERSION,
58
59
  MUSHI_MCP_PIN_SPEC
59
- } from "./chunk-UVO4A2G6.js";
60
+ } from "./chunk-HHOPCVNI.js";
60
61
  import {
61
62
  FRAMEWORKS,
62
63
  detectFramework,
@@ -108,13 +109,13 @@ async function startDeviceAuth(endpoint, clientId) {
108
109
  headers: { "Content-Type": "application/json" },
109
110
  body: JSON.stringify(clientId ? { client_id: clientId } : {})
110
111
  });
111
- const json = await res.json().catch(() => null);
112
- if (!res.ok || !json?.ok || !json.data) {
112
+ const json2 = await res.json().catch(() => null);
113
+ if (!res.ok || !json2?.ok || !json2.data) {
113
114
  throw new Error(
114
- json?.error?.message ?? `Could not start browser sign-in (HTTP ${res.status}).`
115
+ json2?.error?.message ?? `Could not start browser sign-in (HTTP ${res.status}).`
115
116
  );
116
117
  }
117
- return json.data;
118
+ return json2.data;
118
119
  }
119
120
  async function pollDeviceToken(endpoint, deviceCode) {
120
121
  const base = trimTrailingSlash(endpoint);
@@ -124,11 +125,11 @@ async function pollDeviceToken(endpoint, deviceCode) {
124
125
  headers: { "Content-Type": "application/json" },
125
126
  body: JSON.stringify({ device_code: deviceCode })
126
127
  });
127
- const json = await res.json().catch(() => ({}));
128
- if (res.ok && json.ok && json.data?.cli_token) {
129
- return { status: "approved", cliToken: json.data.cli_token, userId: json.data.user_id };
128
+ const json2 = await res.json().catch(() => ({}));
129
+ if (res.ok && json2.ok && json2.data?.cli_token) {
130
+ return { status: "approved", cliToken: json2.data.cli_token, userId: json2.data.user_id };
130
131
  }
131
- switch (json.error) {
132
+ switch (json2.error) {
132
133
  case "authorization_pending":
133
134
  return { status: "pending" };
134
135
  case "slow_down": {
@@ -144,7 +145,7 @@ async function pollDeviceToken(endpoint, deviceCode) {
144
145
  default:
145
146
  return {
146
147
  status: "error",
147
- message: json.error_description ?? json.error ?? `HTTP ${res.status}`,
148
+ message: json2.error_description ?? json2.error ?? `HTTP ${res.status}`,
148
149
  // 5xx is a server hiccup the next poll may clear; 408 (gateway
149
150
  // timeout) is equally transient. Any other 4xx is a definitive
150
151
  // rejection (already claimed / invalid_grant) that won't fix
@@ -249,11 +250,11 @@ async function listProjects(endpoint, cliToken) {
249
250
  `Could not reach ${base}/v1/cli/projects: ${err instanceof Error ? err.message : String(err)}`
250
251
  );
251
252
  }
252
- const json = await res.json().catch(() => null);
253
- if (res.ok && json?.ok) return json.data?.projects ?? [];
253
+ const json2 = await res.json().catch(() => null);
254
+ if (res.ok && json2?.ok) return json2.data?.projects ?? [];
254
255
  throw new DeviceAuthRequestError(
255
256
  "list_projects",
256
- json?.error?.message ?? `Could not list projects (HTTP ${res.status}).`,
257
+ json2?.error?.message ?? `Could not list projects (HTTP ${res.status}).`,
257
258
  res.status
258
259
  );
259
260
  });
@@ -265,11 +266,11 @@ async function createProject(endpoint, cliToken, name, opts = {}) {
265
266
  headers: cliAuthHeaders(cliToken),
266
267
  body: JSON.stringify({ name, ...opts.scopes ? { scopes: opts.scopes } : {} })
267
268
  });
268
- const json = await res.json().catch(() => null);
269
- if (!res.ok || !json?.ok || !json.data) {
270
- throw new Error(json?.error?.message ?? `Could not create project (HTTP ${res.status}).`);
269
+ const json2 = await res.json().catch(() => null);
270
+ if (!res.ok || !json2?.ok || !json2.data) {
271
+ throw new Error(json2?.error?.message ?? `Could not create project (HTTP ${res.status}).`);
271
272
  }
272
- const { id, name: created, slug, apiKey } = json.data;
273
+ const { id, name: created, slug, apiKey } = json2.data;
273
274
  return { id, name: created, slug, apiKey: apiKey ?? null };
274
275
  }
275
276
  async function mintProjectKey(endpoint, cliToken, projectId, opts = {}) {
@@ -290,11 +291,11 @@ async function mintProjectKey(endpoint, cliToken, projectId, opts = {}) {
290
291
  `Could not reach ${base}/v1/cli/projects/\u2026/keys: ${err instanceof Error ? err.message : String(err)}`
291
292
  );
292
293
  }
293
- const json = await res.json().catch(() => null);
294
- if (res.ok && json?.ok && json.data?.key) return json.data.key;
294
+ const json2 = await res.json().catch(() => null);
295
+ if (res.ok && json2?.ok && json2.data?.key) return json2.data.key;
295
296
  throw new DeviceAuthRequestError(
296
297
  "mint_key",
297
- json?.error?.message ?? `Could not mint an SDK key (HTTP ${res.status}).`,
298
+ json2?.error?.message ?? `Could not mint an SDK key (HTTP ${res.status}).`,
298
299
  res.status
299
300
  );
300
301
  }
@@ -317,16 +318,16 @@ async function revokeProjectKey(endpoint, cliToken, projectId, keyPrefix, newKey
317
318
  `Could not reach ${url}: ${err instanceof Error ? err.message : String(err)}`
318
319
  );
319
320
  }
320
- const json = await res.json().catch(() => null);
321
- if (res.ok && json?.ok) {
321
+ const json2 = await res.json().catch(() => null);
322
+ if (res.ok && json2?.ok) {
322
323
  return {
323
- revoked: json.revoked ?? 0,
324
- revokedAt: json.revokedAt
324
+ revoked: json2.revoked ?? 0,
325
+ revokedAt: json2.revokedAt
325
326
  };
326
327
  }
327
328
  throw new DeviceAuthRequestError(
328
329
  "revoke_key",
329
- json?.error?.message ?? `Could not revoke key (HTTP ${res.status}).`,
330
+ json2?.error?.message ?? `Could not revoke key (HTTP ${res.status}).`,
330
331
  res.status
331
332
  );
332
333
  }
@@ -438,9 +439,9 @@ async function resolveProjectChoice(projectsList, ask) {
438
439
  console.log(` ${projectsList.length + 1}. Create a new project`);
439
440
  console.log("");
440
441
  const choice = await ask(` Pick a project [1-${projectsList.length + 1}]: `);
441
- const num = parseInt(choice, 10);
442
- if (num >= 1 && num <= projectsList.length) {
443
- const picked = projectsList[num - 1];
442
+ const num2 = parseInt(choice, 10);
443
+ if (num2 >= 1 && num2 <= projectsList.length) {
444
+ const picked = projectsList[num2 - 1];
444
445
  return { kind: "picked", id: picked.id, name: picked.name };
445
446
  }
446
447
  }
@@ -551,7 +552,7 @@ error: Could not start login session: ${err instanceof Error ? err.message : Str
551
552
  if (process.stdin.isTTY) {
552
553
  const { createInterface } = await import("readline");
553
554
  const rl = createInterface({ input: process.stdin, output: process.stdout });
554
- const ask = (q) => new Promise((resolve3) => rl.question(q, (a) => resolve3(a.trim())));
555
+ const ask = (q) => new Promise((resolve7) => rl.question(q, (a) => resolve7(a.trim())));
555
556
  choice = await resolveProjectChoice(projectsList, ask);
556
557
  rl.close();
557
558
  } else {
@@ -867,12 +868,12 @@ async function checkCliAuthPath(config, doFetch = globalThis.fetch) {
867
868
  body: JSON.stringify({}),
868
869
  signal: AbortSignal.timeout(8e3)
869
870
  });
870
- const json = await res.json().catch(() => null);
871
- const ok = res.ok && json?.ok === true;
871
+ const json2 = await res.json().catch(() => null);
872
+ const ok = res.ok && json2?.ok === true;
872
873
  checks.push({
873
874
  name: "Saved credentials valid",
874
875
  ok,
875
- detail: ok ? `API key resolves to project "${json?.data?.project_name ?? config.projectId}".` : `${json?.error?.message ?? `whoami \u2192 HTTP ${res.status}`} \u2014 re-run \`npx mushi-mushi\` to sign in again.`
876
+ detail: ok ? `API key resolves to project "${json2?.data?.project_name ?? config.projectId}".` : `${json2?.error?.message ?? `whoami \u2192 HTTP ${res.status}`} \u2014 re-run \`npx mushi-mushi\` to sign in again.`
876
877
  });
877
878
  } catch (err) {
878
879
  checks.push({
@@ -894,9 +895,9 @@ async function checkCliAuthPath(config, doFetch = globalThis.fetch) {
894
895
  async function checkSdkInstall(cwd) {
895
896
  try {
896
897
  const { readFile: readFile3 } = await import("fs/promises");
897
- const { join: join5, resolve: resolve3 } = await import("path");
898
- const root = resolve3(cwd);
899
- const pkgPath = join5(root, "package.json");
898
+ const { join: join10, resolve: resolve7 } = await import("path");
899
+ const root = resolve7(cwd);
900
+ const pkgPath = join10(root, "package.json");
900
901
  const pkg = JSON.parse(await readFile3(pkgPath, "utf8"));
901
902
  const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
902
903
  const sdks = [
@@ -1106,9 +1107,9 @@ async function checkHostAppWiring(cwd) {
1106
1107
  const checks = [];
1107
1108
  try {
1108
1109
  const { readFile: readFile3, access } = await import("fs/promises");
1109
- const { join: join5, resolve: resolve3 } = await import("path");
1110
- const root = resolve3(cwd);
1111
- const pkgPath = join5(root, "package.json");
1110
+ const { join: join10, resolve: resolve7 } = await import("path");
1111
+ const root = resolve7(cwd);
1112
+ const pkgPath = join10(root, "package.json");
1112
1113
  const pkg = JSON.parse(await readFile3(pkgPath, "utf8"));
1113
1114
  const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
1114
1115
  const isCapHybrid = Boolean(deps["@capacitor/core"] && deps["react"]);
@@ -1116,7 +1117,7 @@ async function checkHostAppWiring(cwd) {
1116
1117
  let envContent = "";
1117
1118
  for (const f of envCandidates) {
1118
1119
  try {
1119
- envContent = await readFile3(join5(root, f), "utf8");
1120
+ envContent = await readFile3(join10(root, f), "utf8");
1120
1121
  break;
1121
1122
  } catch {
1122
1123
  }
@@ -1131,9 +1132,9 @@ async function checkHostAppWiring(cwd) {
1131
1132
  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"
1132
1133
  });
1133
1134
  let mcpFound = null;
1134
- for (const rel of [".mcp.json", join5(".cursor", "mcp.json")]) {
1135
+ for (const rel of [".mcp.json", join10(".cursor", "mcp.json")]) {
1135
1136
  try {
1136
- await access(join5(root, rel));
1137
+ await access(join10(root, rel));
1137
1138
  mcpFound = rel;
1138
1139
  break;
1139
1140
  } catch {
@@ -1175,9 +1176,9 @@ var HASH_ROUTER_PATTERNS = [
1175
1176
  var HASH_TEMPLATE_PATTERN = /['"`]#\//;
1176
1177
  async function scanDirForPattern(dir, patterns, maxDepth = 6, maxFiles = 2e3) {
1177
1178
  const { readdir: readdir2, readFile: readFile3 } = await import("fs/promises");
1178
- const { join: join5 } = await import("path");
1179
+ const { join: join10 } = await import("path");
1179
1180
  let count = 0;
1180
- async function walk(d, depth) {
1181
+ async function walk2(d, depth) {
1181
1182
  if (depth > maxDepth || count > maxFiles) return null;
1182
1183
  let entries = [];
1183
1184
  try {
@@ -1187,9 +1188,9 @@ async function scanDirForPattern(dir, patterns, maxDepth = 6, maxFiles = 2e3) {
1187
1188
  }
1188
1189
  for (const e of entries) {
1189
1190
  if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "dist") continue;
1190
- const full = join5(d, e.name);
1191
+ const full = join10(d, e.name);
1191
1192
  if (e.isDirectory()) {
1192
- const found = await walk(full, depth + 1);
1193
+ const found = await walk2(full, depth + 1);
1193
1194
  if (found) return found;
1194
1195
  } else if (/\.[cm]?[jt]sx?$/.test(e.name)) {
1195
1196
  count++;
@@ -1202,12 +1203,12 @@ async function scanDirForPattern(dir, patterns, maxDepth = 6, maxFiles = 2e3) {
1202
1203
  }
1203
1204
  return null;
1204
1205
  }
1205
- const { resolve: resolve3 } = await import("path");
1206
+ const { resolve: resolve7 } = await import("path");
1206
1207
  for (const sub of ["src", "app", "pages", "lib", "components"]) {
1207
- const found = await walk(join5(resolve3(dir), sub), 0);
1208
+ const found = await walk2(join10(resolve7(dir), sub), 0);
1208
1209
  if (found) return found;
1209
1210
  }
1210
- return walk(resolve3(dir), 0);
1211
+ return walk2(resolve7(dir), 0);
1211
1212
  }
1212
1213
  async function checkHashRouterCapture(cwd) {
1213
1214
  const hashFile = await scanDirForPattern(cwd, HASH_ROUTER_PATTERNS);
@@ -1240,13 +1241,13 @@ function normalizeProjectPath(path) {
1240
1241
  }
1241
1242
  async function collectMcpSources(root, home) {
1242
1243
  const { readFile: readFile3 } = await import("fs/promises");
1243
- const { join: join5 } = await import("path");
1244
+ const { join: join10 } = await import("path");
1244
1245
  const sources = [];
1245
1246
  const invalid = [];
1246
1247
  const plainFiles = [
1247
- { path: join5(root, ".mcp.json"), claudeCode: true },
1248
- { path: join5(root, ".cursor", "mcp.json"), claudeCode: false },
1249
- { path: join5(home, ".cursor", "mcp.json"), claudeCode: false }
1248
+ { path: join10(root, ".mcp.json"), claudeCode: true },
1249
+ { path: join10(root, ".cursor", "mcp.json"), claudeCode: false },
1250
+ { path: join10(home, ".cursor", "mcp.json"), claudeCode: false }
1250
1251
  ];
1251
1252
  for (const file of plainFiles) {
1252
1253
  let raw;
@@ -1263,7 +1264,7 @@ async function collectMcpSources(root, home) {
1263
1264
  invalid.push(file.path);
1264
1265
  }
1265
1266
  }
1266
- const claudeJsonPath = join5(home, ".claude.json");
1267
+ const claudeJsonPath = join10(home, ".claude.json");
1267
1268
  try {
1268
1269
  const parsed = JSON.parse(await readFile3(claudeJsonPath, "utf8"));
1269
1270
  if (isJsonObject(parsed)) {
@@ -1314,9 +1315,9 @@ function assessMushiEntry(source, name, entry, cliKeySaved) {
1314
1315
  }
1315
1316
  async function checkMcpConfig(config, cwd, doFetch = globalThis.fetch, home) {
1316
1317
  const checks = [];
1317
- const { resolve: resolve3 } = await import("path");
1318
+ const { resolve: resolve7 } = await import("path");
1318
1319
  const { homedir } = await import("os");
1319
- const root = resolve3(cwd);
1320
+ const root = resolve7(cwd);
1320
1321
  const { sources, invalid } = await collectMcpSources(root, home ?? homedir());
1321
1322
  if (invalid.length > 0) {
1322
1323
  checks.push({
@@ -1440,12 +1441,12 @@ async function checkOnboardingStatus(config, _consoleBase, cwd, doFetch = global
1440
1441
  }
1441
1442
  try {
1442
1443
  const { readFile: readFile3 } = await import("fs/promises");
1443
- const { join: join5, resolve: resolve3 } = await import("path");
1444
- const root = resolve3(cwd);
1444
+ const { join: join10, resolve: resolve7 } = await import("path");
1445
+ const root = resolve7(cwd);
1445
1446
  let envContent = "";
1446
1447
  for (const f of [".env.local", ".env"]) {
1447
1448
  try {
1448
- envContent = await readFile3(join5(root, f), "utf8");
1449
+ envContent = await readFile3(join10(root, f), "utf8");
1449
1450
  break;
1450
1451
  } catch {
1451
1452
  }
@@ -1511,7 +1512,7 @@ async function checkOnboardingStatus(config, _consoleBase, cwd, doFetch = global
1511
1512
  }
1512
1513
  async function checkVersionDrift(cwd, doFetch) {
1513
1514
  const checks = [];
1514
- const { MUSHI_CLI_VERSION: MUSHI_CLI_VERSION2 } = await import("./version-VR37VEE4.js");
1515
+ const { MUSHI_CLI_VERSION: MUSHI_CLI_VERSION2 } = await import("./version-FYBMIU3T.js");
1515
1516
  if (MUSHI_CLI_VERSION2 === "0.0.0-dev") return checks;
1516
1517
  const parse2 = (v) => {
1517
1518
  const m = v.replace(/^[~^]/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
@@ -1541,8 +1542,8 @@ async function checkVersionDrift(cwd, doFetch) {
1541
1542
  }
1542
1543
  try {
1543
1544
  const { readFile: readFile3 } = await import("fs/promises");
1544
- const { join: join5 } = await import("path");
1545
- const pkg = JSON.parse(await readFile3(join5(cwd, "package.json"), "utf8"));
1545
+ const { join: join10 } = await import("path");
1546
+ const pkg = JSON.parse(await readFile3(join10(cwd, "package.json"), "utf8"));
1546
1547
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1547
1548
  const candidates = Object.entries(deps).filter(([name]) => name.startsWith("@mushi-mushi/")).filter(([, v]) => /^[~^]?\d/.test(v));
1548
1549
  const drifted = (await Promise.all(
@@ -2252,7 +2253,7 @@ async function installPackages(pm, packages, cwd) {
2252
2253
  function runCommand(pm, packages, cwd) {
2253
2254
  const verb = pm === "npm" ? "install" : "add";
2254
2255
  const command = process.platform === "win32" ? `${pm}.cmd` : pm;
2255
- return new Promise((resolve3, reject) => {
2256
+ return new Promise((resolve7, reject) => {
2256
2257
  const child = spawn(command, [verb, ...packages], {
2257
2258
  stdio: "inherit",
2258
2259
  shell: false,
@@ -2261,7 +2262,7 @@ function runCommand(pm, packages, cwd) {
2261
2262
  });
2262
2263
  child.on("error", reject);
2263
2264
  child.on("exit", (code) => {
2264
- if (code === 0) resolve3();
2265
+ if (code === 0) resolve7();
2265
2266
  else reject(new Error(`${pm} exited with code ${code ?? "null"}`));
2266
2267
  });
2267
2268
  });
@@ -2435,7 +2436,7 @@ async function maybeOfferConnect(credentials, options, _consoleBase) {
2435
2436
  if (p.isCancel(answer) || !answer) return;
2436
2437
  const endpoint = resolveCloudEndpoint(options.endpoint);
2437
2438
  try {
2438
- const { runConnect: runConnect2 } = await import("./connect-63GYZLFM.js");
2439
+ const { runConnect: runConnect2 } = await import("./connect-RIPZ4B5Y.js");
2439
2440
  const saved = loadConfig();
2440
2441
  const result = await runConnect2(
2441
2442
  {
@@ -3342,26 +3343,26 @@ import { join as join3, relative, basename } from "path";
3342
3343
  import * as p2 from "@clack/prompts";
3343
3344
  async function findMapFiles(dir) {
3344
3345
  const results = [];
3345
- async function walk(current) {
3346
+ async function walk2(current) {
3346
3347
  const entries = await readdir(current, { withFileTypes: true });
3347
3348
  for (const e of entries) {
3348
3349
  const full = join3(current, e.name);
3349
3350
  if (e.isDirectory()) {
3350
- await walk(full);
3351
+ await walk2(full);
3351
3352
  } else if (e.isFile() && (e.name.endsWith(".js.map") || e.name.endsWith(".css.map"))) {
3352
3353
  results.push(full);
3353
3354
  }
3354
3355
  }
3355
3356
  }
3356
- await walk(dir);
3357
+ await walk2(dir);
3357
3358
  return results;
3358
3359
  }
3359
3360
  function fileHash(path) {
3360
- return new Promise((resolve3, reject) => {
3361
+ return new Promise((resolve7, reject) => {
3361
3362
  const hash = createHash("sha256");
3362
3363
  const stream = createReadStream(path);
3363
3364
  stream.on("data", (chunk) => hash.update(chunk));
3364
- stream.on("end", () => resolve3(hash.digest("hex")));
3365
+ stream.on("end", () => resolve7(hash.digest("hex")));
3365
3366
  stream.on("error", reject);
3366
3367
  });
3367
3368
  }
@@ -3397,8 +3398,8 @@ async function uploadFile(filePath, release, endpoint, apiKey, debugId) {
3397
3398
  { headers: { Authorization: `Bearer ${apiKey}`, "X-Mushi-Api-Key": apiKey } }
3398
3399
  );
3399
3400
  if (checkRes.ok) {
3400
- const json = await checkRes.json();
3401
- if (json.exists) return { ok: true, skipped: true };
3401
+ const json2 = await checkRes.json();
3402
+ if (json2.exists) return { ok: true, skipped: true };
3402
3403
  }
3403
3404
  } catch {
3404
3405
  }
@@ -3561,15 +3562,15 @@ Examples:
3561
3562
  const config = requireConfig({ needsProject: true });
3562
3563
  const { readdir: readdir2, readFile: readFile3, stat } = await import("fs/promises");
3563
3564
  const nodePath2 = await import("path");
3564
- const SKIP = /node_modules|\.git|dist|build|\.next|\.turbo|coverage/;
3565
+ const SKIP2 = /node_modules|\.git|dist|build|\.next|\.turbo|coverage/;
3565
3566
  const EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs"]);
3566
3567
  const MAX_FILE_BYTES = 5e5;
3567
- async function* walk(dir) {
3568
+ async function* walk2(dir) {
3568
3569
  const entries = await readdir2(dir, { withFileTypes: true });
3569
3570
  for (const e of entries) {
3570
3571
  const full = nodePath2.join(dir, e.name);
3571
- if (SKIP.test(full)) continue;
3572
- if (e.isDirectory()) yield* walk(full);
3572
+ if (SKIP2.test(full)) continue;
3573
+ if (e.isDirectory()) yield* walk2(full);
3573
3574
  else if (EXTS.has(nodePath2.extname(e.name))) yield full;
3574
3575
  }
3575
3576
  }
@@ -3577,7 +3578,7 @@ Examples:
3577
3578
  let bytes = 0;
3578
3579
  let errors = 0;
3579
3580
  const root = nodePath2.resolve(path);
3580
- for await (const file of walk(root)) {
3581
+ for await (const file of walk2(root)) {
3581
3582
  const lang = nodePath2.extname(file).slice(1);
3582
3583
  if (opts.language && opts.language !== lang) continue;
3583
3584
  const stats = await stat(file);
@@ -3587,24 +3588,24 @@ Examples:
3587
3588
  continue;
3588
3589
  }
3589
3590
  const source = await readFile3(file, "utf8");
3590
- const relative2 = nodePath2.relative(root, file).replaceAll("\\", "/");
3591
+ const relative4 = nodePath2.relative(root, file).replaceAll("\\", "/");
3591
3592
  count++;
3592
3593
  bytes += source.length;
3593
3594
  if (opts.dryRun) {
3594
- if (!opts.json) process.stdout.write(` ${relative2} (${source.length} bytes)
3595
+ if (!opts.json) process.stdout.write(` ${relative4} (${source.length} bytes)
3595
3596
  `);
3596
3597
  continue;
3597
3598
  }
3598
3599
  const result = await apiCall("/v1/sync/codebase/upload", config, {
3599
3600
  method: "POST",
3600
- body: JSON.stringify({ projectId: config.projectId, filePath: relative2, source })
3601
+ body: JSON.stringify({ projectId: config.projectId, filePath: relative4, source })
3601
3602
  });
3602
3603
  if (!result.ok) {
3603
3604
  errors++;
3604
- process.stderr.write(` FAIL ${relative2}: ${result.error.message}
3605
+ process.stderr.write(` FAIL ${relative4}: ${result.error.message}
3605
3606
  `);
3606
3607
  } else if (!opts.json) {
3607
- process.stdout.write(` ok ${relative2} \u2192 ${result.data.chunks} chunks
3608
+ process.stdout.write(` ok ${relative4} \u2192 ${result.data.chunks} chunks
3608
3609
  `);
3609
3610
  }
3610
3611
  }
@@ -3733,7 +3734,7 @@ error: Could not start browser sign-in: ${err instanceof Error ? err.message : S
3733
3734
  const projectsList = await listProjects(endpoint, cliToken);
3734
3735
  const { createInterface } = await import("readline");
3735
3736
  const rl = createInterface({ input: process.stdin, output: process.stdout });
3736
- const ask = (q) => new Promise((resolve3) => rl.question(q, (a) => resolve3(a.trim())));
3737
+ const ask = (q) => new Promise((resolve7) => rl.question(q, (a) => resolve7(a.trim())));
3737
3738
  let chosen;
3738
3739
  let projectId;
3739
3740
  let projectName;
@@ -3747,9 +3748,9 @@ error: Could not start browser sign-in: ${err instanceof Error ? err.message : S
3747
3748
  console.log(` ${projectsList.length + 1}. Create a new project`);
3748
3749
  console.log("");
3749
3750
  const choice = await ask(` Pick a project [1-${projectsList.length + 1}]: `);
3750
- const num = parseInt(choice, 10);
3751
- if (num >= 1 && num <= projectsList.length) {
3752
- chosen = projectsList[num - 1];
3751
+ const num2 = parseInt(choice, 10);
3752
+ if (num2 >= 1 && num2 <= projectsList.length) {
3753
+ chosen = projectsList[num2 - 1];
3753
3754
  projectId = chosen.id;
3754
3755
  projectName = chosen.name;
3755
3756
  }
@@ -4160,7 +4161,7 @@ If you are not signed in yet, setup runs browser sign-in first, then writes the
4160
4161
  const { createInterface } = await import("readline");
4161
4162
  const rl = createInterface({ input: process.stdin, output: process.stdout });
4162
4163
  const answer = await new Promise(
4163
- (resolve3) => rl.question(" Upgrade this key now via browser sign-in? [Y/n] ", (a) => resolve3(a.trim().toLowerCase()))
4164
+ (resolve7) => rl.question(" Upgrade this key now via browser sign-in? [Y/n] ", (a) => resolve7(a.trim().toLowerCase()))
4164
4165
  );
4165
4166
  rl.close();
4166
4167
  if (answer === "" || answer === "y" || answer === "yes") {
@@ -4230,10 +4231,10 @@ Note: ${configRelPath} holds no secrets \u2014 safe to commit and share with you
4230
4231
  const existing = await readFile3(gitignorePath, "utf8").catch(() => "");
4231
4232
  const lines = existing.split(/\r?\n/);
4232
4233
  if (!lines.some((l) => l.trim() === ignoreLine)) {
4233
- const sep = existing.endsWith("\n") || existing === "" ? "" : "\n";
4234
+ const sep3 = existing.endsWith("\n") || existing === "" ? "" : "\n";
4234
4235
  await writeFile3(
4235
4236
  gitignorePath,
4236
- `${existing}${sep}# Mushi MCP config holds a plaintext API key
4237
+ `${existing}${sep3}# Mushi MCP config holds a plaintext API key
4237
4238
  ${ignoreLine}
4238
4239
  `,
4239
4240
  "utf8"
@@ -4257,6 +4258,41 @@ Note: ${configRelPath} contains your Mushi API key.`);
4257
4258
  }
4258
4259
 
4259
4260
  // src/commands/fix.ts
4261
+ function githubOwnerName(url) {
4262
+ const cleaned = url.trim().replace(/^git@github\.com:/i, "https://github.com/").replace(/\/+$/, "").replace(/\.git$/i, "");
4263
+ const match = cleaned.match(/github\.com\/([^/]+)\/([^/]+)/i);
4264
+ return match ? `${match[1]}/${match[2]}`.toLowerCase() : null;
4265
+ }
4266
+ async function resolveTargetRepoId(repo, cfg) {
4267
+ if (!repo.includes("/")) return requireUuid(repo, "--repo");
4268
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
4269
+ throw new MushiCliError("E_INVALID_INPUT", "--repo must be a repo id or owner/name", "e.g. --repo acme/app-backend");
4270
+ }
4271
+ if (!cfg.projectId) {
4272
+ throw new MushiCliError(
4273
+ "E_INVALID_INPUT",
4274
+ "--repo owner/name needs a project to look the repo up in",
4275
+ "pass --project-id or set MUSHI_PROJECT_ID, or pass the repo id instead"
4276
+ );
4277
+ }
4278
+ const res = await apiCall(
4279
+ `/v1/admin/repo/repos?project_id=${encodeURIComponent(cfg.projectId)}`,
4280
+ cfg
4281
+ );
4282
+ if (!res.ok) {
4283
+ console.error("Error listing linked repos:", res.error.message);
4284
+ process.exit(1);
4285
+ }
4286
+ const linked = res.data ?? [];
4287
+ const matches = linked.filter((r) => githubOwnerName(r.repo_url) === repo.toLowerCase());
4288
+ if (matches.length === 1) return matches[0].id;
4289
+ const names = linked.map((r) => githubOwnerName(r.repo_url) ?? r.repo_url).join(", ") || "none";
4290
+ throw new MushiCliError(
4291
+ "E_INVALID_INPUT",
4292
+ matches.length === 0 ? `${repo} is not linked to this project` : `${repo} matches ${matches.length} linked repos; pass the repo id`,
4293
+ `linked repos: ${names}`
4294
+ );
4295
+ }
4260
4296
  function registerFixCommands(program2) {
4261
4297
  const fixCmd = program2.command("fix").description("Dispatch an agentic fix for a report");
4262
4298
  fixCmd.argument("<reportId>", "Report UUID to fix").option(
@@ -4272,12 +4308,18 @@ function registerFixCommands(program2) {
4272
4308
  ).option(
4273
4309
  "--wait",
4274
4310
  "Poll until terminal state and exit non-zero on error/cancelled (CI-friendly)"
4311
+ ).option(
4312
+ "--repo <repoId|owner/name>",
4313
+ "Linked repo to open the fix PR against (a project with several repos). Default: the project's primary repo"
4275
4314
  ).option("-e, --endpoint <url>", "API endpoint (overrides MUSHI_API_ENDPOINT)").option("--api-key <key>", "API key (overrides MUSHI_API_KEY)").option("--project-id <id>", "Project ID (overrides MUSHI_PROJECT_ID)").addHelpText("after", `
4276
4315
  Examples:
4277
4316
  mushi fix abc123 --agent cursor_cloud --wait
4278
4317
  mushi fix abc123 --agent cursor_cloud --model composer-latest --no-auto-pr
4279
4318
  mushi fix abc123 --agent claude_code
4280
4319
 
4320
+ # A project with a frontend and a backend repo: send the fix to the backend
4321
+ mushi fix abc123 --repo acme/app-backend
4322
+
4281
4323
  # CI: fail the pipeline if the fix errors
4282
4324
  mushi fix $REPORT_ID --agent cursor_cloud --wait && echo "Fix PR opened"`).action(async (reportId, opts) => {
4283
4325
  requireUuid(reportId, "reportId");
@@ -4285,6 +4327,7 @@ Examples:
4285
4327
  if (opts.endpoint) cfg.endpoint = opts.endpoint;
4286
4328
  if (opts.apiKey) cfg.apiKey = opts.apiKey;
4287
4329
  if (opts.projectId) cfg.projectId = opts.projectId;
4330
+ const targetRepoId = opts.repo ? await resolveTargetRepoId(opts.repo, cfg) : void 0;
4288
4331
  const isTTY = process.stdout.isTTY;
4289
4332
  const emitEvent = (type, data) => {
4290
4333
  if (isTTY) {
@@ -4294,11 +4337,17 @@ Examples:
4294
4337
  process.stdout.write(JSON.stringify({ type, ...data }) + "\n");
4295
4338
  }
4296
4339
  };
4297
- emitEvent("dispatch.start", { reportId, agent: opts.agent, model: opts.model ?? null });
4340
+ emitEvent("dispatch.start", {
4341
+ reportId,
4342
+ agent: opts.agent,
4343
+ model: opts.model ?? null,
4344
+ targetRepoId: targetRepoId ?? null
4345
+ });
4298
4346
  const body = {
4299
4347
  reportId,
4300
4348
  projectId: cfg.projectId,
4301
- agent: opts.agent
4349
+ agent: opts.agent,
4350
+ ...targetRepoId ? { targetRepoId } : {}
4302
4351
  };
4303
4352
  if (opts.agent === "cursor_cloud") {
4304
4353
  if (opts.model) body.cursorModel = opts.model;
@@ -4313,13 +4362,13 @@ Examples:
4313
4362
  console.error("Error dispatching fix:", result.error.message);
4314
4363
  process.exit(1);
4315
4364
  }
4316
- const { fixId, status, agentId, runId, prUrl } = result.data;
4317
- emitEvent("dispatch.ok", { fixId, status, agentId, runId, prUrl });
4365
+ const { dispatchId, fixId, status, agentId, runId, prUrl } = result.data;
4366
+ emitEvent("dispatch.ok", { dispatchId, fixId, status, agentId, runId, prUrl });
4318
4367
  if (!opts.wait) {
4319
4368
  process.exit(0);
4320
4369
  }
4321
- if (!fixId) {
4322
- console.error("No fixId returned \u2014 cannot poll.");
4370
+ if (!dispatchId) {
4371
+ console.error("No dispatchId returned \u2014 cannot poll.");
4323
4372
  process.exit(1);
4324
4373
  }
4325
4374
  const POLL_MS = 5e3;
@@ -4328,7 +4377,7 @@ Examples:
4328
4377
  for (let i = 0; i < MAX_POLLS; i++) {
4329
4378
  await new Promise((r) => setTimeout(r, POLL_MS));
4330
4379
  const pollResult = await apiCall(
4331
- `/v1/admin/fixes/${fixId}`,
4380
+ `/v1/admin/fixes/dispatch/${dispatchId}`,
4332
4381
  cfg
4333
4382
  );
4334
4383
  if (!pollResult.ok) {
@@ -5694,9 +5743,108 @@ Recent runs for story ${storyId.slice(0, 8)}\u2026:
5694
5743
  });
5695
5744
  }
5696
5745
 
5746
+ // src/command-helpers.ts
5747
+ function orgSegment(org) {
5748
+ if (!org || org === "current") return "current";
5749
+ return encodeURIComponent(requireUuid(org, "organization id"));
5750
+ }
5751
+ function dieOrgError(result) {
5752
+ const { code } = result.error;
5753
+ if (code === "ORG_REQUIRED") {
5754
+ const orgs = Array.isArray(result.error["organizations"]) ? result.error["organizations"] : [];
5755
+ if (orgs.length > 0) {
5756
+ process.stderr.write("\n Pick one and pass it with --org:\n");
5757
+ for (const o of orgs) {
5758
+ process.stderr.write(` --org ${String(o.id)} ${typeof o.name === "string" ? o.name : ""}
5759
+ `);
5760
+ }
5761
+ process.stderr.write("\n");
5762
+ }
5763
+ } else if (code.endsWith("_NEEDS_ACCOUNT_KEY")) {
5764
+ process.stderr.write(
5765
+ "\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"
5766
+ );
5767
+ }
5768
+ die(result);
5769
+ }
5770
+ function requireYes(yes, action) {
5771
+ if (yes) return;
5772
+ throw new MushiCliError(
5773
+ "E_INVALID_INPUT",
5774
+ `${action} Re-run with --yes to go ahead.`,
5775
+ "nothing was sent"
5776
+ );
5777
+ }
5778
+ function resolveProjectId(flag, configured) {
5779
+ const raw = flag ?? configured;
5780
+ if (!raw) {
5781
+ throw new MushiCliError(
5782
+ "E_PROJECT_MISSING",
5783
+ "Project ID not configured.",
5784
+ "pass --project-id <uuid> or run `mushi login --project-id <uuid>`"
5785
+ );
5786
+ }
5787
+ return requireUuid(raw, "project id");
5788
+ }
5789
+ function oneLine(text2, max = 80) {
5790
+ const flat = String(text2 ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/\s+/g, " ").trim();
5791
+ return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
5792
+ }
5793
+
5697
5794
  // src/commands/audit.ts
5795
+ var KNOWN_GATES = [
5796
+ "dead_handler",
5797
+ "mock_leak",
5798
+ "api_contract",
5799
+ "crawl",
5800
+ "status_claim",
5801
+ "spec_drift",
5802
+ "orphan_endpoint",
5803
+ "unknown_call",
5804
+ "schema_drift",
5805
+ "code_health",
5806
+ "design_drift",
5807
+ "ci_drift",
5808
+ "deploy_drift",
5809
+ "env_drift",
5810
+ "portfolio_radar",
5811
+ "portfolio_radar_ci",
5812
+ "store_review"
5813
+ ];
5814
+ var SEVERITIES = ["info", "warn", "error"];
5815
+ var AUDIT_TIMEOUT_MS = 3e4;
5816
+ function renderGateFindings(data, includeAllowlisted) {
5817
+ const gateOf = new Map(data.runs.map((r) => [r.id, r.gate]));
5818
+ const shown = data.findings.filter((f) => includeAllowlisted || !f.allowlisted);
5819
+ if (data.runs.length === 0) return ["No gate runs yet for this project."];
5820
+ if (shown.length === 0) return [`${data.runs.length} recent run(s), no open findings.`];
5821
+ const lines = [];
5822
+ for (const f of shown) {
5823
+ const where = f.file_path ? `${f.file_path}${f.line ? `:${f.line}` : ""}` : "";
5824
+ lines.push(` ${f.severity.toUpperCase().padEnd(5)} ${(gateOf.get(f.gate_run_id) ?? "?").padEnd(18)} ${f.rule_id ?? ""} ${where}`);
5825
+ lines.push(` ${oneLine(f.message, 110)} [${f.id}]${f.allowlisted ? " (allowlisted)" : ""}`);
5826
+ }
5827
+ lines.push(`${shown.length} finding(s). Why one fired and how to fix it: mushi audit explain <id>`);
5828
+ return lines;
5829
+ }
5830
+ function renderExplanation(e) {
5831
+ const lines = [
5832
+ `${e.severity.toUpperCase()} \u2014 ${e.gateLabel}${e.rule ? `: ${e.rule.title}` : e.ruleId ? ` (${e.ruleId})` : ""}`,
5833
+ ` What this checks: ${e.gateMeaning}`
5834
+ ];
5835
+ if (e.rule) lines.push(` What it prevents: ${e.rule.prevents}`);
5836
+ lines.push(` Why it fired: ${oneLine(e.reason, 300)}`);
5837
+ const where = e.location.filePath ? `${e.location.filePath}${e.location.line ? `:${e.location.line}` : ""}` : e.location.target;
5838
+ if (where) lines.push(` Where: ${where}`);
5839
+ if (e.fix.text) lines.push(` Fix: ${oneLine(e.fix.text, 300)}`);
5840
+ if (e.fix.command) lines.push(` Run: ${e.fix.command}`);
5841
+ if (e.fix.consolePath) lines.push(` In the console: ${e.fix.consolePath}`);
5842
+ lines.push(` State: ${e.state} \u2014 ${e.stateReason}`);
5843
+ lines.push(` From run: ${e.run.status} ${fmtDate(e.run.completedAt)}${e.run.commitSha ? ` @ ${e.run.commitSha.slice(0, 7)}` : ""}`);
5844
+ return lines;
5845
+ }
5698
5846
  function registerAuditCommands(program2) {
5699
- program2.command("audit").description("Run a full-stack health audit for the current project").option("--json", "Machine-readable JSON output").option("--project-id <id>", "Project ID to audit (defaults to MUSHI_PROJECT_ID from config)").addHelpText("after", `
5847
+ const audit = program2.command("audit").description("Run a full-stack health audit for the current project").option("--json", "Machine-readable JSON output").option("--project-id <id>", "Project ID to audit (defaults to MUSHI_PROJECT_ID from config)").addHelpText("after", `
5700
5848
  Description:
5701
5849
  Fans out to the Mushi backend to run a full-stack health audit:
5702
5850
  \u2022 DB schema + Supabase advisors (requires Supabase PAT in API Keys)
@@ -5714,96 +5862,111 @@ Description:
5714
5862
  Examples:
5715
5863
  mushi audit
5716
5864
  mushi audit --json
5717
- mushi audit --project-id abc123`).action(async (opts) => {
5865
+ mushi audit --project-id abc123
5866
+ mushi audit findings --gate code_health --severity error
5867
+ mushi audit explain <finding id>`).action(async (opts) => {
5718
5868
  const config = requireConfig();
5719
5869
  const rawProjectId = opts.projectId ?? config.projectId;
5720
5870
  if (!rawProjectId) {
5721
- process.stderr.write("error: project ID required. Run `mushi login` or pass --project-id\n");
5722
- process.exit(1);
5871
+ throw new MushiCliError("E_PROJECT_MISSING", "Project ID not configured.", "pass --project-id <id> or run `mushi login --project-id <id>`");
5723
5872
  }
5724
5873
  let endpoint;
5725
5874
  let projectId;
5875
+ let apiKey;
5726
5876
  try {
5727
5877
  endpoint = sanitizeEndpoint(config.endpoint);
5728
5878
  projectId = sanitizeProjectId(rawProjectId);
5879
+ apiKey = sanitizeApiKey(config.apiKey);
5729
5880
  } catch (err) {
5730
- const msg = err instanceof Error ? err.message : String(err);
5731
- process.stderr.write(`error: ${msg}
5732
- `);
5733
- process.exit(2);
5734
- }
5735
- const headers = {
5736
- "Content-Type": "application/json",
5737
- "X-Mushi-Project-Id": projectId
5738
- };
5739
- const apiKey = config.apiKey ?? null;
5740
- if (apiKey) {
5741
- headers["X-Mushi-Api-Key"] = sanitizeApiKey(apiKey);
5742
- } else {
5743
- process.stderr.write("error: no API key found. Run `mushi login` or set MUSHI_API_KEY.\n");
5744
- process.exit(1);
5881
+ throw new MushiCliError("E_INVALID_INPUT", err instanceof Error ? err.message : String(err));
5745
5882
  }
5746
- if (!opts.json) process.stdout.write("Running full-stack audit\u2026 ");
5747
- try {
5748
- const controller = new AbortController();
5749
- const timer = setTimeout(() => controller.abort(), 3e4);
5750
- const res = await fetch(
5751
- `${endpoint}/v1/admin/projects/${projectId}/audit`,
5752
- { method: "POST", headers, body: "{}", signal: controller.signal }
5753
- );
5754
- clearTimeout(timer);
5755
- const body = await res.json();
5756
- if (!res.ok || !body.ok) {
5757
- if (opts.json) {
5758
- console.log(JSON.stringify(body));
5759
- process.exit(1);
5760
- }
5761
- process.stdout.write("FAIL\n");
5762
- process.stderr.write(`error: ${body.error?.message ?? `HTTP ${res.status}`}
5763
- `);
5883
+ const json2 = outputIsJson(opts.json);
5884
+ if (!json2) process.stdout.write("Running full-stack audit\u2026 ");
5885
+ const result = await apiCall(
5886
+ `/v1/admin/projects/${projectId}/audit`,
5887
+ { ...config, endpoint, apiKey },
5888
+ { method: "POST", body: "{}", headers: { "X-Mushi-Project-Id": projectId } },
5889
+ { timeoutMs: AUDIT_TIMEOUT_MS }
5890
+ );
5891
+ if (!result.ok) {
5892
+ if (json2) {
5893
+ console.log(JSON.stringify({ ok: false, error: result.error }));
5764
5894
  process.exit(1);
5765
5895
  }
5766
- if (opts.json) {
5767
- console.log(JSON.stringify(body.data, null, 2));
5768
- return;
5769
- }
5770
- const data = body.data;
5771
- const overallLabel = data.summary.overall === "fail" ? "FAIL" : data.summary.overall === "warn" ? "WARN" : "OK";
5772
- process.stdout.write(`${overallLabel}
5896
+ process.stdout.write("FAIL\n");
5897
+ die(result);
5898
+ }
5899
+ if (json2) {
5900
+ console.log(JSON.stringify(result.data, null, 2));
5901
+ return;
5902
+ }
5903
+ const data = result.data;
5904
+ const overallLabel = data.summary.overall === "fail" ? "FAIL" : data.summary.overall === "warn" ? "WARN" : "OK";
5905
+ process.stdout.write(`${overallLabel}
5773
5906
 
5774
5907
  `);
5775
- console.log(`Full-Stack Audit \u2014 ${new Date(data.audit_at).toLocaleString()}`);
5776
- console.log(`Backend linked: ${data.backend_linked ? "yes" : "no (configure Supabase PAT + project ref)"}`);
5777
- console.log(`Summary: ${data.summary.error_count} error(s) \xB7 ${data.summary.warn_count} warning(s)
5908
+ console.log(`Full-Stack Audit \u2014 ${new Date(data.audit_at).toLocaleString()}`);
5909
+ console.log(`Backend linked: ${data.backend_linked ? "yes" : "no (configure Supabase PAT + project ref)"}`);
5910
+ console.log(`Summary: ${data.summary.error_count} error(s) \xB7 ${data.summary.warn_count} warning(s)
5778
5911
  `);
5779
- if (data.findings.length === 0) {
5780
- console.log(" OK No findings. Your project looks healthy.");
5781
- } else {
5782
- for (const f of data.findings) {
5783
- const icon = f.severity === "error" ? "FAIL" : f.severity === "warn" ? "WARN" : "INFO";
5784
- console.log(` ${icon} ${f.title}`);
5785
- console.log(` ${f.detail.slice(0, 120)}${f.detail.length > 120 ? "\u2026" : ""}`);
5786
- }
5912
+ if (data.findings.length === 0) {
5913
+ console.log(" OK No findings. Your project looks healthy.");
5914
+ } else {
5915
+ for (const f of data.findings) {
5916
+ const icon = f.severity === "error" ? "FAIL" : f.severity === "warn" ? "WARN" : "INFO";
5917
+ console.log(` ${icon} ${f.title}`);
5918
+ console.log(` ${f.detail.slice(0, 120)}${f.detail.length > 120 ? "\u2026" : ""}`);
5787
5919
  }
5788
- if (data.gate_runs.length > 0) {
5789
- console.log("\nGate Results:");
5790
- for (const run2 of data.gate_runs) {
5791
- const g = run2.status === "pass" ? "OK" : run2.status === "fail" ? "FAIL" : "SKIP";
5792
- console.log(` ${g} ${run2.gate.padEnd(22)} ${run2.status} (${run2.findings_count} finding${run2.findings_count !== 1 ? "s" : ""})`);
5793
- }
5920
+ }
5921
+ if (data.gate_runs.length > 0) {
5922
+ console.log("\nGate Results:");
5923
+ for (const run2 of data.gate_runs) {
5924
+ const g = run2.status === "pass" ? "OK" : run2.status === "fail" ? "FAIL" : "SKIP";
5925
+ console.log(` ${g} ${run2.gate.padEnd(22)} ${run2.status} (${run2.findings_count} finding${run2.findings_count !== 1 ? "s" : ""})`);
5794
5926
  }
5795
- if (data.summary.overall === "fail") process.exit(1);
5796
- } catch (err) {
5797
- const msg = err instanceof Error ? err.message : String(err);
5798
- if (opts.json) {
5799
- console.log(JSON.stringify({ ok: false, error: msg }));
5800
- } else {
5801
- process.stdout.write("ERROR\n");
5802
- process.stderr.write(`error: ${msg}
5927
+ }
5928
+ console.log("\nEach gate finding with its file and line: mushi audit findings");
5929
+ if (data.summary.overall === "fail") process.exit(1);
5930
+ });
5931
+ audit.command("findings").description("Every finding from the recent gate runs, with file and line").option("--gate <gate>", `Only this gate (known: ${KNOWN_GATES.join(", ")})`).option("--severity <level>", "Only this severity: info | warn | error").option("--all", "Include allowlisted findings").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (_local, cmd) => {
5932
+ const opts = cmd.optsWithGlobals();
5933
+ if (opts.gate && !KNOWN_GATES.includes(opts.gate)) {
5934
+ process.stderr.write(`warning: this CLI does not know the gate "${opts.gate}" (known: ${KNOWN_GATES.join(", ")}). Asking the server anyway.
5803
5935
  `);
5936
+ }
5937
+ if (opts.severity && !SEVERITIES.includes(opts.severity)) {
5938
+ throw new MushiCliError("E_INVALID_INPUT", "--severity must be info, warn or error");
5939
+ }
5940
+ const config = requireConfig();
5941
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
5942
+ const qs = new URLSearchParams();
5943
+ if (opts.gate) qs.set("gate", opts.gate);
5944
+ if (opts.severity) qs.set("severity", opts.severity);
5945
+ const suffix = qs.toString() ? `?${qs}` : "";
5946
+ const result = await apiCall(`/v1/admin/inventory/${projectId}/findings${suffix}`, config);
5947
+ if (!result.ok) {
5948
+ if (result.error.code === "feature_not_in_plan") {
5949
+ process.stderr.write("The per-finding list is not on your plan. `mushi audit` (no subcommand) still runs the summary audit.\n");
5804
5950
  }
5805
- process.exit(1);
5951
+ die(result);
5806
5952
  }
5953
+ if (outputIsJson(opts.json)) {
5954
+ console.log(JSON.stringify(result.data, null, 2));
5955
+ return;
5956
+ }
5957
+ for (const line of renderGateFindings(result.data, opts.all === true)) console.log(line);
5958
+ });
5959
+ audit.command("explain <findingId>").description("Why one finding fired, what it prevents, and how to fix it").option("--json", "Machine-readable JSON output").action(async (findingId, _local, cmd) => {
5960
+ const opts = cmd.optsWithGlobals();
5961
+ const id = requireUuid(findingId, "finding id");
5962
+ const config = requireConfig();
5963
+ const result = await apiCall(`/v1/admin/findings/${id}`, config);
5964
+ if (!result.ok) die(result);
5965
+ if (outputIsJson(opts.json)) {
5966
+ console.log(JSON.stringify(result.data, null, 2));
5967
+ return;
5968
+ }
5969
+ for (const line of renderExplanation(result.data)) console.log(line);
5807
5970
  });
5808
5971
  }
5809
5972
 
@@ -6676,6 +6839,3661 @@ function registerProfileCommands(program2) {
6676
6839
  });
6677
6840
  }
6678
6841
 
6842
+ // src/commands/radar.ts
6843
+ import { execFileSync } from "child_process";
6844
+ import { resolve as resolve4 } from "path";
6845
+
6846
+ // src/radar/scan.ts
6847
+ import { readdirSync as readdirSync2, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
6848
+ import { isAbsolute, join as join5, relative as relative2, resolve as resolve3, sep } from "path";
6849
+
6850
+ // src/radar/repo-scan-core.ts
6851
+ var REPO_SCAN_PATHS = [
6852
+ "android/variables.gradle",
6853
+ "android/build.gradle",
6854
+ "android/app/build.gradle",
6855
+ "android/app/build.gradle.kts",
6856
+ "app.json",
6857
+ "app.config.json",
6858
+ "eas.json",
6859
+ "ios/App/App.xcodeproj/project.pbxproj",
6860
+ "ios/App/Podfile",
6861
+ "ios/Podfile",
6862
+ ".github/workflows/"
6863
+ ];
6864
+ function isRepoScanPath(path) {
6865
+ const p3 = normalizePath(path);
6866
+ if (REPO_SCAN_PATHS.includes(p3)) return true;
6867
+ if (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(p3)) return true;
6868
+ return /^ios\/(?:[^/]+\/)?[^/]+\.xcodeproj\/project\.pbxproj$/.test(p3);
6869
+ }
6870
+ function normalizePath(path) {
6871
+ return path.replace(/\\/g, "/").replace(/^\.\//, "");
6872
+ }
6873
+ function uncommentedLines(text2, syntax) {
6874
+ if (!text2) return [];
6875
+ const lines = text2.split(/\r?\n/);
6876
+ const out = [];
6877
+ let inBlock = false;
6878
+ for (const raw of lines) {
6879
+ let line = raw;
6880
+ if (syntax === "gradle" || syntax === "sql" || syntax === "js") {
6881
+ if (inBlock) {
6882
+ const end = line.indexOf("*/");
6883
+ if (end === -1) {
6884
+ out.push("");
6885
+ continue;
6886
+ }
6887
+ line = line.slice(end + 2);
6888
+ inBlock = false;
6889
+ }
6890
+ line = line.replace(/\/\*.*?\*\//g, "");
6891
+ const open = line.indexOf("/*");
6892
+ if (open !== -1) {
6893
+ line = line.slice(0, open);
6894
+ inBlock = true;
6895
+ }
6896
+ }
6897
+ if (syntax === "sql") {
6898
+ const dash = line.indexOf("--");
6899
+ if (dash !== -1 && (line.slice(0, dash).split("'").length - 1) % 2 === 0) line = line.slice(0, dash);
6900
+ }
6901
+ const t = line.trim();
6902
+ const whole = (syntax === "gradle" || syntax === "js") && t.startsWith("//") || syntax === "sql" && (t.startsWith("--") || t.startsWith("//")) || (syntax === "yaml" || syntax === "ruby" || syntax === "py") && t.startsWith("#");
6903
+ out.push(whole ? "" : line);
6904
+ }
6905
+ return out;
6906
+ }
6907
+ var SCANNED_EXT = /\.(sql|ts|tsx|js|mjs|cjs|py)$/i;
6908
+ var STORAGE_OBJECTS = String.raw`(?:"?storage"?\s*\.\s*"?objects"?)`;
6909
+ var SQL_DELETE = new RegExp(String.raw`\bdelete\s+from\s+(?:only\s+)?${STORAGE_OBJECTS}(?![\w"])`, "gi");
6910
+ var SQL_TRUNCATE = new RegExp(String.raw`\btruncate\s+(?:table\s+)?(?:only\s+)?${STORAGE_OBJECTS}(?![\w"])`, "gi");
6911
+ var CLIENT_FROM_OBJECTS = /\.from\(\s*['"`]objects['"`]\s*\)/;
6912
+ var CLIENT_SCHEMA_STORAGE = /\.schema\(\s*['"`]storage['"`]\s*\)/;
6913
+ var CLIENT_DELETE = /\.delete\(/;
6914
+ var STORAGE_FIX = "Delete the file through the Storage API instead: `supabase.storage.from('<bucket>').remove(['<path>'])`. A SQL delete removes the row but leaves the file in the bucket, still billed (https://supabase.com/docs/guides/storage/management/delete-objects). To clean up files already orphaned, list them through the Storage API and remove them there.";
6915
+ function syntaxFor(path) {
6916
+ if (/\.sql$/i.test(path)) return "sql";
6917
+ if (/\.py$/i.test(path)) return "py";
6918
+ return "js";
6919
+ }
6920
+ function lineOfOffset(text2, offset) {
6921
+ let line = 1;
6922
+ for (let i = 0; i < offset; i++) if (text2.charCodeAt(i) === 10) line++;
6923
+ return line;
6924
+ }
6925
+ function scanStorageSqlDelete(path, text2) {
6926
+ const file = normalizePath(path);
6927
+ if (!SCANNED_EXT.test(file)) return [];
6928
+ const syntax = syntaxFor(file);
6929
+ const lines = uncommentedLines(text2, syntax);
6930
+ const clean = lines.join("\n");
6931
+ const findings = [];
6932
+ const seen = /* @__PURE__ */ new Set();
6933
+ const original = text2.split(/\r?\n/);
6934
+ const add = (line, how) => {
6935
+ if (seen.has(line)) return;
6936
+ seen.add(line);
6937
+ findings.push({
6938
+ ruleId: "storage_sql_delete",
6939
+ severity: "warn",
6940
+ message: `${file}:${line} ${how}. The row goes, but the file stays in the bucket and keeps costing money.`,
6941
+ target: file,
6942
+ filePath: file,
6943
+ line,
6944
+ fix: STORAGE_FIX,
6945
+ evidence: { snippet: (original[line - 1] ?? "").trim().slice(0, 200) }
6946
+ });
6947
+ };
6948
+ for (const re of [SQL_DELETE, SQL_TRUNCATE]) {
6949
+ re.lastIndex = 0;
6950
+ for (const m of clean.matchAll(re)) {
6951
+ add(lineOfOffset(clean, m.index ?? 0), re === SQL_DELETE ? "deletes storage rows with SQL" : "truncates storage.objects with SQL");
6952
+ }
6953
+ }
6954
+ if (syntax !== "sql") {
6955
+ for (let i = 0; i < lines.length; i++) {
6956
+ if (!CLIENT_FROM_OBJECTS.test(lines[i])) continue;
6957
+ const before = lines.slice(Math.max(0, i - 5), i + 1).join("\n");
6958
+ const after = lines.slice(i, i + 4).join("\n");
6959
+ if (CLIENT_SCHEMA_STORAGE.test(before) && CLIENT_DELETE.test(after)) {
6960
+ add(i + 1, "deletes rows from storage.objects through a client pointed at the 'storage' schema");
6961
+ }
6962
+ }
6963
+ }
6964
+ return findings.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
6965
+ }
6966
+
6967
+ // src/radar/secret-patterns.ts
6968
+ var SECRET_PATTERNS = [
6969
+ { re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, label: "private key" },
6970
+ { re: /sk-ant-[a-zA-Z0-9_-]{20,}/, label: "Anthropic key" },
6971
+ // Project, service-account and admin keys (sk-proj-, sk-svcacct-, sk-admin-) and the legacy sk-<48>.
6972
+ { re: /sk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}|[A-Za-z0-9]{20,})/, label: "OpenAI-style key" },
6973
+ { re: /(?:AKIA|ASIA)[0-9A-Z]{16}/, label: "AWS access key id" },
6974
+ { re: /gh[pousr]_[A-Za-z0-9]{20,}/, label: "GitHub token" },
6975
+ { re: /github_pat_[A-Za-z0-9_]{20,}/, label: "GitHub fine-grained token" },
6976
+ { re: /crsr_[A-Za-z0-9]{32,}/, label: "Cursor API key" },
6977
+ { re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, label: "JWT" },
6978
+ { re: /postgres(?:ql)?:\/\/[^:\s]+:[^@\s]+@/, label: "database connection string" },
6979
+ { re: /xox[baprs]-[A-Za-z0-9-]{10,}/, label: "Slack token" },
6980
+ { re: /\bmushi_[A-Za-z0-9]{24,}/, label: "Mushi API key" },
6981
+ { re: /\b(?:sk|rk)_live_[A-Za-z0-9]{16,}/, label: "Stripe live key" },
6982
+ // Supabase's secret API key bypasses RLS like service_role; sb_publishable_ is public by design.
6983
+ { re: /\bsb_secret_[A-Za-z0-9_-]{20,}/, label: "Supabase secret key" }
6984
+ ];
6985
+ var WORD = /[A-Za-z0-9_-]/;
6986
+ function findSecrets(text2, max = 50) {
6987
+ const out = [];
6988
+ const lineStarts = [0];
6989
+ for (let i = 0; i < text2.length; i++) if (text2.charCodeAt(i) === 10) lineStarts.push(i + 1);
6990
+ const lineOf = (offset) => {
6991
+ let lo = 0;
6992
+ let hi = lineStarts.length - 1;
6993
+ while (lo < hi) {
6994
+ const mid = lo + hi + 1 >> 1;
6995
+ if (lineStarts[mid] <= offset) lo = mid;
6996
+ else hi = mid - 1;
6997
+ }
6998
+ return lo + 1;
6999
+ };
7000
+ const taken = [];
7001
+ for (const { re, label } of SECRET_PATTERNS) {
7002
+ const g = new RegExp(re.source, "g");
7003
+ for (const m of text2.matchAll(g)) {
7004
+ const start = m.index ?? 0;
7005
+ const end = start + m[0].length;
7006
+ if (start > 0 && WORD.test(text2[start - 1]) && WORD.test(m[0][0])) continue;
7007
+ if (taken.some(([s, e]) => start < e && end > s)) continue;
7008
+ taken.push([start, end]);
7009
+ out.push({ label, line: lineOf(start), value: m[0] });
7010
+ if (out.length >= max) return out;
7011
+ }
7012
+ }
7013
+ return out.sort((a, b) => a.line - b.line);
7014
+ }
7015
+
7016
+ // src/radar/scan.ts
7017
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "out", "coverage", ".turbo", ".expo", "Pods", ".gradle", "DerivedData", ".venv", "venv", "__pycache__"]);
7018
+ var MAX_FILES = 2e4;
7019
+ var MAX_SCAN_BYTES = 1024 * 1024;
7020
+ var MAX_CONFIG_BYTES = 256 * 1024;
7021
+ var MAX_CONFIG_FILES = 40;
7022
+ function listRepoFiles(root, limit = MAX_FILES) {
7023
+ const out = [];
7024
+ let truncated = false;
7025
+ let unreadable = 0;
7026
+ readdirSync2(root);
7027
+ const walk2 = (dir) => {
7028
+ if (out.length >= limit) {
7029
+ truncated = true;
7030
+ return;
7031
+ }
7032
+ let entries;
7033
+ try {
7034
+ entries = readdirSync2(dir, { withFileTypes: true });
7035
+ } catch {
7036
+ unreadable++;
7037
+ return;
7038
+ }
7039
+ for (const e of entries) {
7040
+ if (out.length >= limit) {
7041
+ truncated = true;
7042
+ return;
7043
+ }
7044
+ if (e.isSymbolicLink()) continue;
7045
+ const full = join5(dir, e.name);
7046
+ if (e.isDirectory()) {
7047
+ if (!SKIP_DIRS.has(e.name)) walk2(full);
7048
+ } else if (e.isFile()) {
7049
+ out.push(relative2(root, full).split(sep).join("/"));
7050
+ }
7051
+ }
7052
+ };
7053
+ walk2(root);
7054
+ return { files: out, truncated, unreadable };
7055
+ }
7056
+ var BUNDLE_DIR_NAMES = /* @__PURE__ */ new Set(["dist", "build", "out", "web-build", "storybook-static"]);
7057
+ var DOT_CLIENT_OUTPUTS = {
7058
+ ".next": ["static"],
7059
+ ".output": ["public"],
7060
+ // Nuxt / Nitro
7061
+ ".svelte-kit": ["output/client"],
7062
+ ".vercel": ["output/static"]
7063
+ // Vercel Build Output API
7064
+ };
7065
+ var BUNDLE_SKIP_DIRS = /* @__PURE__ */ new Set(["server", "node_modules", "intermediates", "tmp", "kotlin", "cache", ".cache"]);
7066
+ var BUNDLE_EXT = /\.(?:js|mjs|cjs|html|bundle|jsbundle)$/i;
7067
+ var MAX_BUNDLE_FILES = 5e3;
7068
+ var MAX_BUNDLE_FILE_BYTES = 5 * 1024 * 1024;
7069
+ var MAX_BUNDLE_ROOT_DEPTH = 4;
7070
+ function findBundleRoots(root) {
7071
+ const roots = [];
7072
+ const walk2 = (dir, depth) => {
7073
+ if (depth > MAX_BUNDLE_ROOT_DEPTH) return;
7074
+ let entries;
7075
+ try {
7076
+ entries = readdirSync2(dir, { withFileTypes: true });
7077
+ } catch {
7078
+ return;
7079
+ }
7080
+ for (const e of entries) {
7081
+ if (!e.isDirectory() || e.isSymbolicLink()) continue;
7082
+ const full = join5(dir, e.name);
7083
+ const rel = relative2(root, full).split(sep).join("/");
7084
+ if (BUNDLE_DIR_NAMES.has(e.name)) {
7085
+ roots.push(rel);
7086
+ continue;
7087
+ }
7088
+ const clientParts = DOT_CLIENT_OUTPUTS[e.name];
7089
+ if (clientParts) {
7090
+ for (const part of clientParts) {
7091
+ try {
7092
+ if (statSync2(join5(full, part)).isDirectory()) roots.push(`${rel}/${part}`);
7093
+ } catch {
7094
+ }
7095
+ }
7096
+ continue;
7097
+ }
7098
+ if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue;
7099
+ walk2(full, depth + 1);
7100
+ }
7101
+ };
7102
+ walk2(root, 1);
7103
+ return roots.sort();
7104
+ }
7105
+ function isClientSafeMatch(m) {
7106
+ if (m.label === "Mushi API key") return true;
7107
+ if (m.label !== "JWT") return false;
7108
+ const payload = m.value.split(".")[1] ?? "";
7109
+ try {
7110
+ const json2 = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
7111
+ const role = JSON.parse(json2).role;
7112
+ return typeof role !== "string" || role === "anon" || role === "authenticated";
7113
+ } catch {
7114
+ return true;
7115
+ }
7116
+ }
7117
+ function bundleRootsFromFlags(root, dirs) {
7118
+ const roots = [];
7119
+ for (const d of dirs) {
7120
+ const full = resolve3(root, d);
7121
+ const rel = relative2(root, full);
7122
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) throw new Error(`--bundle-dir ${d} must be a folder inside ${root}`);
7123
+ if (!statSync2(full).isDirectory()) throw new Error(`--bundle-dir ${d} is not a folder`);
7124
+ roots.push(rel.split(sep).join("/"));
7125
+ }
7126
+ return [...new Set(roots)].sort();
7127
+ }
7128
+ function scanBuiltBundles(root, readFile3 = (p3) => readFileSync3(p3, "utf8"), bundleDirs) {
7129
+ const roots = bundleDirs && bundleDirs.length > 0 ? bundleRootsFromFlags(root, bundleDirs) : findBundleRoots(root);
7130
+ const out = { roots, scannedFiles: 0, truncated: false, unreadable: 0, findings: [] };
7131
+ const walk2 = (dir) => {
7132
+ if (out.truncated) return;
7133
+ let entries;
7134
+ try {
7135
+ entries = readdirSync2(dir, { withFileTypes: true });
7136
+ } catch {
7137
+ out.unreadable++;
7138
+ return;
7139
+ }
7140
+ for (const e of entries) {
7141
+ if (out.scannedFiles >= MAX_BUNDLE_FILES) {
7142
+ out.truncated = true;
7143
+ return;
7144
+ }
7145
+ if (e.isSymbolicLink()) continue;
7146
+ const full = join5(dir, e.name);
7147
+ if (e.isDirectory()) {
7148
+ if (!BUNDLE_SKIP_DIRS.has(e.name)) walk2(full);
7149
+ continue;
7150
+ }
7151
+ if (!e.isFile() || !BUNDLE_EXT.test(e.name)) continue;
7152
+ let text2;
7153
+ try {
7154
+ if (statSync2(full).size > MAX_BUNDLE_FILE_BYTES) {
7155
+ out.unreadable++;
7156
+ continue;
7157
+ }
7158
+ text2 = readFile3(full);
7159
+ } catch {
7160
+ out.unreadable++;
7161
+ continue;
7162
+ }
7163
+ out.scannedFiles++;
7164
+ const rel = relative2(root, full).split(sep).join("/");
7165
+ for (const m of findSecrets(text2)) {
7166
+ if (!isClientSafeMatch(m)) out.findings.push({ filePath: rel, line: m.line, label: m.label });
7167
+ }
7168
+ }
7169
+ };
7170
+ for (const r of roots) walk2(join5(root, r));
7171
+ return out;
7172
+ }
7173
+ function scanLocalRepo(root, readFile3 = (p3) => readFileSync3(p3, "utf8"), opts = {}) {
7174
+ const { files, truncated, unreadable: unreadableDirs } = listRepoFiles(root);
7175
+ let unreadable = unreadableDirs;
7176
+ const findings = [];
7177
+ const configFiles = {};
7178
+ let scannedFiles = 0;
7179
+ for (const rel of files) {
7180
+ const full = join5(root, rel);
7181
+ let size = 0;
7182
+ try {
7183
+ size = statSync2(full).size;
7184
+ } catch {
7185
+ unreadable++;
7186
+ continue;
7187
+ }
7188
+ const wantConfig = isRepoScanPath(rel) && size <= MAX_CONFIG_BYTES && Object.keys(configFiles).length < MAX_CONFIG_FILES;
7189
+ const wantScan = size <= MAX_SCAN_BYTES && /\.(sql|ts|tsx|js|mjs|cjs|py)$/i.test(rel);
7190
+ if (!wantConfig && !wantScan) continue;
7191
+ let text2;
7192
+ try {
7193
+ text2 = readFile3(full);
7194
+ } catch {
7195
+ unreadable++;
7196
+ continue;
7197
+ }
7198
+ if (wantConfig) configFiles[rel] = text2;
7199
+ if (!wantScan) continue;
7200
+ scannedFiles++;
7201
+ findings.push(...scanStorageSqlDelete(rel, text2));
7202
+ }
7203
+ return { scannedFiles, truncated, unreadable, findings, configFiles, bundle: scanBuiltBundles(root, readFile3, opts.bundleDirs) };
7204
+ }
7205
+ var MAX_PUSH_FINDINGS = 200;
7206
+ var MIN_BUNDLE_FINDINGS = 50;
7207
+ function toIngestBody(scan, commitSha) {
7208
+ const scanned = ["storage_sql_delete"];
7209
+ const partial = [];
7210
+ if (scan.truncated || scan.unreadable > 0 || scan.scannedFiles === 0) partial.push("storage_sql_delete");
7211
+ if (scan.bundle.scannedFiles > 0) {
7212
+ scanned.push("key_in_client_bundle");
7213
+ if (scan.bundle.truncated || scan.bundle.unreadable > 0) partial.push("key_in_client_bundle");
7214
+ }
7215
+ const seen = /* @__PURE__ */ new Set();
7216
+ const bundle = scan.bundle.findings.filter((f) => {
7217
+ const k = `${f.filePath}\0${f.label}`;
7218
+ if (seen.has(k)) return false;
7219
+ seen.add(k);
7220
+ return true;
7221
+ });
7222
+ const bundleTake = Math.min(bundle.length, Math.max(MIN_BUNDLE_FINDINGS, MAX_PUSH_FINDINGS - scan.findings.length));
7223
+ const storageTake = Math.min(scan.findings.length, MAX_PUSH_FINDINGS - bundleTake);
7224
+ if (storageTake < scan.findings.length && !partial.includes("storage_sql_delete")) partial.push("storage_sql_delete");
7225
+ if (bundleTake < bundle.length && !partial.includes("key_in_client_bundle")) partial.push("key_in_client_bundle");
7226
+ const findings = [
7227
+ ...scan.findings.slice(0, storageTake).map((f) => ({ ruleId: f.ruleId, filePath: f.filePath, line: f.line })),
7228
+ ...bundle.slice(0, bundleTake).map((f) => ({ ruleId: "key_in_client_bundle", filePath: f.filePath, line: f.line, kind: f.label }))
7229
+ ];
7230
+ return {
7231
+ ...commitSha && /^[0-9a-f]{7,64}$/i.test(commitSha) ? { commitSha } : {},
7232
+ scanned,
7233
+ ...partial.length ? { partial } : {},
7234
+ findings,
7235
+ files: scan.configFiles
7236
+ };
7237
+ }
7238
+
7239
+ // src/commands/radar.ts
7240
+ function headSha(dir) {
7241
+ const env = process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? null;
7242
+ if (env) return env;
7243
+ try {
7244
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
7245
+ } catch {
7246
+ return null;
7247
+ }
7248
+ }
7249
+ function registerRadarCommands(program2) {
7250
+ const radar = program2.command("radar").description("Hole checks that catch problems before a user hits them (store rules, storage, domains)");
7251
+ radar.command("scan").description("Scan this repo and its built app for storage deletes done in SQL and secret keys, and read the build settings the store rules check").option("--dir <path>", "Repo root to scan", ".").option("--bundle-dir <path...>", "Built client folders to scan for secret keys instead of finding them (repeatable; e.g. to leave out a server-side dist)").option("--push", "Send the results to Mushi (run this in your existing CI job, after the build step)").option("--json", "Machine-readable JSON output").addHelpText("after", `
7252
+ What it checks here:
7253
+ storage_sql_delete deleting storage.objects rows with SQL leaves the files in
7254
+ the bucket, still billed. Delete through the Storage API.
7255
+ key_in_client_bundle a secret key (OpenAI, Stripe live, Supabase secret, \u2026) in
7256
+ the built app (dist, build, out, .next/static,
7257
+ .output/public, .svelte-kit/output/client,
7258
+ .vercel/output/static, native JS bundles, or the
7259
+ folders given with --bundle-dir). Anyone who opens
7260
+ the app can read it. Public keys
7261
+ by design (Supabase anon key, Mushi SDK key) are not flagged.
7262
+ Only where and which kind of key is sent, never the key.
7263
+ With --push, Mushi also checks your Android target SDK and iOS build settings
7264
+ against the current Google Play and App Store rules, from the build files only.
7265
+
7266
+ Add one step to your existing CI job, after the build step (no new job needed):
7267
+ - run: npx mushi-mushi radar scan --push
7268
+ env:
7269
+ MUSHI_API_KEY: \${{ secrets.MUSHI_INGEST_KEY }}`).action(async (opts) => {
7270
+ const root = resolve4(opts.dir);
7271
+ let scan;
7272
+ try {
7273
+ scan = scanLocalRepo(root, void 0, { bundleDirs: opts.bundleDir });
7274
+ } catch (err) {
7275
+ process.stderr.write(`error: could not scan ${root}: ${err.message}
7276
+ `);
7277
+ process.exitCode = 1;
7278
+ return;
7279
+ }
7280
+ const json2 = outputIsJson(opts.json);
7281
+ if (!opts.push) {
7282
+ const b = scan.bundle;
7283
+ if (json2) {
7284
+ console.log(JSON.stringify({ scannedFiles: scan.scannedFiles, truncated: scan.truncated, findings: scan.findings, configFiles: Object.keys(scan.configFiles), bundle: b }, null, 2));
7285
+ } else {
7286
+ console.log(`Scanned ${scan.scannedFiles} files${scan.truncated ? " (stopped at the file limit)" : ""}${scan.unreadable ? ` (${scan.unreadable} could not be read)` : ""}.`);
7287
+ if (scan.findings.length === 0) console.log("No storage deletes done in SQL.");
7288
+ for (const f of scan.findings) console.log(` WARN ${f.message}`);
7289
+ if (b.scannedFiles === 0) {
7290
+ console.log("No built app found (dist, build, out, .next/static, .output/public, ...), so secret keys in the bundle were not checked. Run this after your build step, or name the folder with --bundle-dir.");
7291
+ } else {
7292
+ console.log(`Scanned ${b.scannedFiles} built files in ${b.roots.join(", ")}${b.truncated ? " (stopped at the file limit)" : ""}${b.unreadable ? ` (${b.unreadable} could not be read)` : ""}.`);
7293
+ if (b.findings.length === 0) console.log("No secret keys in the built app.");
7294
+ for (const f of b.findings) console.log(` ERROR ${f.filePath}:${f.line} contains a ${f.label}. Revoke it and move the call behind your server.`);
7295
+ }
7296
+ console.log(`Build files for the store rules: ${Object.keys(scan.configFiles).join(", ") || "none found"}`);
7297
+ console.log("Run with --push to send these to Mushi.");
7298
+ }
7299
+ if (scan.findings.length > 0 || b.findings.length > 0) process.exitCode = 1;
7300
+ return;
7301
+ }
7302
+ const config = requireConfig();
7303
+ const result = await apiCall(
7304
+ "/v1/ingest/radar",
7305
+ config,
7306
+ { method: "POST", body: JSON.stringify(toIngestBody(scan, headSha(root))) }
7307
+ );
7308
+ if (!result.ok) die(result);
7309
+ if (json2) {
7310
+ console.log(JSON.stringify(result.data, null, 2));
7311
+ } else {
7312
+ console.log(`Sent to Mushi (${result.data.status}).`);
7313
+ for (const r of result.data.results) console.log(` ${r.state.toUpperCase().padEnd(8)} ${r.ruleId}: ${r.reason}`);
7314
+ }
7315
+ });
7316
+ radar.command("show").description("Show every hole check for this project and what each one found").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
7317
+ const config = requireConfig({ needsProject: !opts.projectId });
7318
+ const projectId = requireUuid(opts.projectId ?? config.projectId, "project id");
7319
+ const result = await apiCall(`/v1/admin/projects/${projectId}/radar`, config);
7320
+ if (!result.ok) die(result);
7321
+ if (outputIsJson(opts.json)) {
7322
+ console.log(JSON.stringify(result.data, null, 2));
7323
+ return;
7324
+ }
7325
+ for (const d of result.data.detectors) {
7326
+ const label = d.state === "ok" ? "OK" : d.state === "finding" ? "FOUND" : d.state === "error" ? "ERROR" : "NOT CHECKED";
7327
+ console.log(`${label.padEnd(12)} ${d.title} \u2014 ${d.reason}`);
7328
+ for (const f of d.findings) console.log(` ${f.severity.toUpperCase()}: ${f.message}`);
7329
+ }
7330
+ });
7331
+ }
7332
+
7333
+ // src/commands/recipe.ts
7334
+ import { execFileSync as execFileSync3 } from "child_process";
7335
+ import { writeFileSync as writeFileSync3 } from "fs";
7336
+ import { join as join7, resolve as resolve5 } from "path";
7337
+
7338
+ // src/recipe/local.ts
7339
+ import { execFileSync as execFileSync2 } from "child_process";
7340
+ import { existsSync as existsSync3, readdirSync as readdirSync3, readFileSync as readFileSync4, statSync as statSync3 } from "fs";
7341
+ import { join as join6, relative as relative3, sep as sep2 } from "path";
7342
+
7343
+ // src/recipe/engine/design-color.ts
7344
+ var clamp01 = (n) => Math.min(1, Math.max(0, n));
7345
+ function parseHex(input) {
7346
+ const m = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(input.trim());
7347
+ if (!m) return null;
7348
+ let h = m[1];
7349
+ if (h.length <= 4) h = h.split("").map((c) => c + c).join("");
7350
+ const n = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
7351
+ return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) : 1 };
7352
+ }
7353
+ function toHexByte(v) {
7354
+ return Math.round(clamp01(v) * 255).toString(16).padStart(2, "0").toUpperCase();
7355
+ }
7356
+ function toHex(c) {
7357
+ const base = `#${toHexByte(c.r)}${toHexByte(c.g)}${toHexByte(c.b)}`;
7358
+ return c.a < 1 ? `${base}${toHexByte(c.a)}` : base;
7359
+ }
7360
+ function num(token, scale) {
7361
+ const t = token.trim();
7362
+ if (t === "none") return 0;
7363
+ const pct = /^(-?[\d.]+)%$/.exec(t);
7364
+ if (pct) return Number(pct[1]) / 100 * scale;
7365
+ const deg = /^(-?[\d.]+)(deg|turn|rad|grad)?$/.exec(t);
7366
+ if (!deg) return null;
7367
+ const v = Number(deg[1]);
7368
+ if (!Number.isFinite(v)) return null;
7369
+ switch (deg[2]) {
7370
+ case "turn":
7371
+ return v * 360;
7372
+ case "rad":
7373
+ return v * 180 / Math.PI;
7374
+ case "grad":
7375
+ return v * 0.9;
7376
+ default:
7377
+ return v;
7378
+ }
7379
+ }
7380
+ function splitArgs(body) {
7381
+ const [main, alpha] = body.split("/");
7382
+ const parts = main.includes(",") ? main.split(",") : main.trim().split(/\s+/);
7383
+ const cleaned = parts.map((p3) => p3.trim()).filter(Boolean);
7384
+ if (alpha === void 0 && cleaned.length === 4) return { parts: cleaned.slice(0, 3), alpha: cleaned[3] };
7385
+ return { parts: cleaned, alpha: alpha?.trim() ?? null };
7386
+ }
7387
+ function alphaOf(token) {
7388
+ if (token == null) return 1;
7389
+ const v = num(token, 1);
7390
+ return v == null ? 1 : clamp01(v);
7391
+ }
7392
+ function hslToRgb(h, s, l) {
7393
+ const k = (n) => (n + h / 30) % 12;
7394
+ const a = s * Math.min(l, 1 - l);
7395
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
7396
+ return [f(0), f(8), f(4)];
7397
+ }
7398
+ var linearToSrgb = (x) => x <= 31308e-7 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
7399
+ var srgbToLinear = (x) => x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
7400
+ function oklabToRgb(L, A, B) {
7401
+ const l_ = L + 0.3963377774 * A + 0.2158037573 * B;
7402
+ const m_ = L - 0.1055613458 * A - 0.0638541728 * B;
7403
+ const s_ = L - 0.0894841775 * A - 1.291485548 * B;
7404
+ const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
7405
+ return [
7406
+ linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
7407
+ linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
7408
+ linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
7409
+ ];
7410
+ }
7411
+ function rgbToOklab(c) {
7412
+ const r = srgbToLinear(c.r), g = srgbToLinear(c.g), b = srgbToLinear(c.b);
7413
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
7414
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
7415
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
7416
+ return [
7417
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
7418
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
7419
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
7420
+ ];
7421
+ }
7422
+ function parseCssColor(input) {
7423
+ const s = input.trim().replace(/_/g, " ");
7424
+ if (s.startsWith("#")) return parseHex(s);
7425
+ const fn = /^(rgba?|hsla?|hwb|oklab|oklch)\(\s*([^()]*)\)$/i.exec(s);
7426
+ if (!fn) return null;
7427
+ const kind = fn[1].toLowerCase();
7428
+ const { parts, alpha } = splitArgs(fn[2]);
7429
+ if (parts.length !== 3) return null;
7430
+ const a = alphaOf(alpha);
7431
+ if (kind === "rgb" || kind === "rgba") {
7432
+ const ch = parts.map((p3) => num(p3, 255));
7433
+ if (ch.some((v) => v == null)) return null;
7434
+ const [r2, g2, b2] = ch;
7435
+ return { r: clamp01(r2 / 255), g: clamp01(g2 / 255), b: clamp01(b2 / 255), a };
7436
+ }
7437
+ if (kind === "hsl" || kind === "hsla" || kind === "hwb") {
7438
+ const h = num(parts[0], 360);
7439
+ const x = num(parts[1].endsWith("%") ? parts[1] : `${parts[1]}%`, 1);
7440
+ const y = num(parts[2].endsWith("%") ? parts[2] : `${parts[2]}%`, 1);
7441
+ if (h == null || x == null || y == null) return null;
7442
+ const hue = (h % 360 + 360) % 360;
7443
+ if (kind === "hwb") {
7444
+ const w = clamp01(x), bl = clamp01(y);
7445
+ if (w + bl >= 1) {
7446
+ const grey = w / (w + bl);
7447
+ return { r: grey, g: grey, b: grey, a };
7448
+ }
7449
+ const [r3, g3, b3] = hslToRgb(hue, 1, 0.5).map((v) => v * (1 - w - bl) + w);
7450
+ return { r: r3, g: g3, b: b3, a };
7451
+ }
7452
+ const [r2, g2, b2] = hslToRgb(hue, clamp01(x), clamp01(y));
7453
+ return { r: r2, g: g2, b: b2, a };
7454
+ }
7455
+ const L = num(parts[0], 1);
7456
+ if (L == null) return null;
7457
+ if (kind === "oklab") {
7458
+ const A = num(parts[1], 0.4), B = num(parts[2], 0.4);
7459
+ if (A == null || B == null) return null;
7460
+ const [r2, g2, b2] = oklabToRgb(L, A, B);
7461
+ return { r: clamp01(r2), g: clamp01(g2), b: clamp01(b2), a };
7462
+ }
7463
+ const C = num(parts[1], 0.4), H = num(parts[2], 360);
7464
+ if (C == null || H == null) return null;
7465
+ const rad = H * Math.PI / 180;
7466
+ const [r, g, b] = oklabToRgb(L, C * Math.cos(rad), C * Math.sin(rad));
7467
+ return { r: clamp01(r), g: clamp01(g), b: clamp01(b), a };
7468
+ }
7469
+ function relativeLuminance(c) {
7470
+ return 0.2126 * srgbToLinear(c.r) + 0.7152 * srgbToLinear(c.g) + 0.0722 * srgbToLinear(c.b);
7471
+ }
7472
+ function composite(fg, bg) {
7473
+ return {
7474
+ r: fg.r * fg.a + bg.r * (1 - fg.a),
7475
+ g: fg.g * fg.a + bg.g * (1 - fg.a),
7476
+ b: fg.b * fg.a + bg.b * (1 - fg.a),
7477
+ a: 1
7478
+ };
7479
+ }
7480
+ function contrastRatio(fg, bg) {
7481
+ const f = fg.a < 1 ? composite(fg, { ...bg, a: 1 }) : fg;
7482
+ const L1 = relativeLuminance(f);
7483
+ const L2 = relativeLuminance({ ...bg, a: 1 });
7484
+ const ratio = (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05);
7485
+ return Math.round(ratio * 100) / 100;
7486
+ }
7487
+ function colorDistance(x, y) {
7488
+ const [l1, a1, b1] = rgbToOklab(x);
7489
+ const [l2, a2, b2] = rgbToOklab(y);
7490
+ const d = Math.sqrt((l1 - l2) ** 2 + (a1 - a2) ** 2 + (b1 - b2) ** 2) * 100;
7491
+ return Math.round((d + Math.abs(x.a - y.a) * 50) * 100) / 100;
7492
+ }
7493
+ function sameRgb(x, y) {
7494
+ return toHex({ ...x, a: 1 }) === toHex({ ...y, a: 1 });
7495
+ }
7496
+
7497
+ // src/recipe/engine/dtcg.ts
7498
+ var MUSHI_EXTENSION_KEY = "us.kensaur.mushi";
7499
+ var ALIAS_RE = /^\{([^{}]+)\}$/;
7500
+ var MAX_ISSUES = 200;
7501
+ function isPlainObject(v) {
7502
+ return typeof v === "object" && v !== null && !Array.isArray(v);
7503
+ }
7504
+ function aliasTarget(v) {
7505
+ if (typeof v === "string") {
7506
+ const m = ALIAS_RE.exec(v.trim());
7507
+ return m ? m[1].trim() : null;
7508
+ }
7509
+ if (isPlainObject(v) && typeof v.$ref === "string" && Object.keys(v).length === 1) {
7510
+ const ref = v.$ref.replace(/^#\/?/, "").replace(/\/\$value$/, "");
7511
+ return ref.split("/").filter(Boolean).join(".") || null;
7512
+ }
7513
+ return null;
7514
+ }
7515
+ function collect(node, segments, inheritedType, file, out, issues) {
7516
+ const groupType = typeof node.$type === "string" ? node.$type : inheritedType;
7517
+ for (const [key, child] of Object.entries(node)) {
7518
+ if (key.startsWith("$")) continue;
7519
+ if (!isPlainObject(child)) continue;
7520
+ const keySegs = key.split(".").filter((s) => s.length > 0);
7521
+ if (keySegs.length > 1 && issues.length < MAX_ISSUES) {
7522
+ issues.push({ severity: "info", code: "token_nonconformant", message: `"${key}" uses dots in a name (DTCG 2025.10 forbids it); read as ${keySegs.join(" \u203A ")}.`, path: [...segments, ...keySegs].join("."), file: file.path });
7523
+ }
7524
+ const next = [...segments, ...keySegs];
7525
+ if ("$value" in child) {
7526
+ const ext = isPlainObject(child.$extensions) && isPlainObject(child.$extensions[MUSHI_EXTENSION_KEY]) ? child.$extensions[MUSHI_EXTENSION_KEY] : null;
7527
+ out.push({
7528
+ segments: next,
7529
+ path: next.join("."),
7530
+ rawValue: child.$value,
7531
+ type: typeof child.$type === "string" ? child.$type : groupType,
7532
+ description: typeof child.$description === "string" ? child.$description : null,
7533
+ ext,
7534
+ file: file.path,
7535
+ role: file.role
7536
+ });
7537
+ } else {
7538
+ collect(child, next, groupType, file, out, issues);
7539
+ }
7540
+ }
7541
+ }
7542
+ function inferType(v) {
7543
+ if (typeof v === "string") {
7544
+ if (/^#[0-9a-f]{3,8}$/i.test(v.trim()) || /^(rgba?|hsla?|oklch|oklab|hwb)\(/i.test(v.trim())) return "color";
7545
+ if (/^-?[\d.]+(px|rem|em)$/.test(v.trim())) return "dimension";
7546
+ if (/^-?[\d.]+(ms|s)$/.test(v.trim())) return "duration";
7547
+ }
7548
+ if (typeof v === "number") return "number";
7549
+ if (isPlainObject(v) && "colorSpace" in v) return "color";
7550
+ if (isPlainObject(v) && "unit" in v && "value" in v) return "dimension";
7551
+ return null;
7552
+ }
7553
+ function fmtNum(n) {
7554
+ return Number.isInteger(n) ? String(n) : String(Math.round(n * 1e3) / 1e3);
7555
+ }
7556
+ function normalizeColor(v, note2) {
7557
+ if (typeof v === "string") {
7558
+ const s = v.trim();
7559
+ if (/^#([0-9a-f]{3}|[0-9a-f]{4})$/i.test(s)) note2("hex_shorthand", `"${s}" is hex shorthand; read as ${toHex(parseCssColor(s))}.`);
7560
+ const c = parseCssColor(s);
7561
+ if (!c) {
7562
+ note2("color_unparseable", `"${s}" is not a colour Mushi can read.`);
7563
+ return { value: v, display: s, hex: null, px: null };
7564
+ }
7565
+ note2("legacy_color_string", "colour given as a string; DTCG 2025.10 uses {colorSpace, components}.");
7566
+ const hex = toHex(c);
7567
+ return { value: { colorSpace: "srgb", components: [c.r, c.g, c.b], alpha: c.a, hex }, display: hex, hex, px: null };
7568
+ }
7569
+ if (isPlainObject(v)) {
7570
+ const alpha = typeof v.alpha === "number" ? v.alpha : 1;
7571
+ if (typeof v.hex === "string") {
7572
+ const c = parseCssColor(v.hex);
7573
+ if (c) {
7574
+ const hex = toHex({ ...c, a: alpha });
7575
+ return { value: v, display: hex, hex, px: null };
7576
+ }
7577
+ }
7578
+ const comps = Array.isArray(v.components) ? v.components : null;
7579
+ const space = typeof v.colorSpace === "string" ? v.colorSpace.toLowerCase() : "srgb";
7580
+ if (comps && comps.length === 3 && comps.every((x) => typeof x === "number")) {
7581
+ const [a, b, c] = comps;
7582
+ let rgba = null;
7583
+ if (space === "srgb" || space === "display-p3") {
7584
+ if (space === "display-p3") note2("color_space_approximated", "display-p3 shown as sRGB.");
7585
+ rgba = { r: a, g: b, b: c, a: alpha };
7586
+ } else if (space === "oklch") {
7587
+ rgba = parseCssColor(`oklch(${a} ${b} ${c})`);
7588
+ } else if (space === "oklab") {
7589
+ rgba = parseCssColor(`oklab(${a} ${b} ${c})`);
7590
+ } else if (space === "hsl") {
7591
+ rgba = parseCssColor(`hsl(${a} ${b}% ${c}%)`);
7592
+ }
7593
+ if (rgba) {
7594
+ const hex = toHex({ ...rgba, a: alpha });
7595
+ return { value: v, display: hex, hex, px: null };
7596
+ }
7597
+ }
7598
+ }
7599
+ note2("color_unparseable", "colour value is not readable.");
7600
+ return { value: v, display: JSON.stringify(v), hex: null, px: null };
7601
+ }
7602
+ function normalizeMeasure(v, kind, note2) {
7603
+ let value = null;
7604
+ let unit = null;
7605
+ if (typeof v === "string") {
7606
+ const m = /^(-?[\d.]+)\s*([a-z%]*)$/i.exec(v.trim());
7607
+ if (m) {
7608
+ value = Number(m[1]);
7609
+ unit = m[2] || (kind === "dimension" ? "px" : "ms");
7610
+ note2(kind === "dimension" ? "legacy_dimension_string" : "legacy_duration_string", `"${v}" is a string; DTCG 2025.10 uses {value, unit}.`);
7611
+ }
7612
+ } else if (typeof v === "number") {
7613
+ value = v;
7614
+ unit = kind === "dimension" ? "px" : "ms";
7615
+ note2("unitless_number", `${v} has no unit; read as ${unit}.`);
7616
+ } else if (isPlainObject(v) && typeof v.value === "number" && typeof v.unit === "string") {
7617
+ value = v.value;
7618
+ unit = v.unit;
7619
+ }
7620
+ if (value == null || unit == null || !Number.isFinite(value)) {
7621
+ note2(`${kind}_unparseable`, `${kind} value is not readable.`);
7622
+ return { value: v, display: typeof v === "string" ? v : JSON.stringify(v), hex: null, px: null };
7623
+ }
7624
+ if (kind === "duration" && unit === "s") {
7625
+ value = value * 1e3;
7626
+ unit = "ms";
7627
+ }
7628
+ const px = kind === "dimension" ? unit === "px" ? value : unit === "rem" || unit === "em" ? value * 16 : null : null;
7629
+ return { value: { value, unit }, display: `${fmtNum(value)}${unit}`, hex: null, px };
7630
+ }
7631
+ function displayOf(v) {
7632
+ if (typeof v === "string") return v;
7633
+ if (typeof v === "number") return fmtNum(v);
7634
+ if (Array.isArray(v)) return v.map(displayOf).join(", ");
7635
+ if (isPlainObject(v)) {
7636
+ if (typeof v.hex === "string") return v.hex;
7637
+ if (typeof v.value === "number" && typeof v.unit === "string") return `${fmtNum(v.value)}${v.unit}`;
7638
+ }
7639
+ return JSON.stringify(v);
7640
+ }
7641
+ function normalizeByType(type, v, note2) {
7642
+ switch (type) {
7643
+ case "color":
7644
+ return normalizeColor(v, note2);
7645
+ case "dimension":
7646
+ return normalizeMeasure(v, "dimension", note2);
7647
+ case "duration":
7648
+ return normalizeMeasure(v, "duration", note2);
7649
+ case "fontFamily": {
7650
+ const list = Array.isArray(v) ? v.map(String) : typeof v === "string" ? v.split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")) : [];
7651
+ return { value: list, display: list.join(", "), hex: null, px: null };
7652
+ }
7653
+ case "cubicBezier": {
7654
+ const ok = Array.isArray(v) && v.length === 4 && v.every((x) => typeof x === "number");
7655
+ if (!ok) note2("cubicBezier_unparseable", "cubicBezier needs four numbers.");
7656
+ return { value: v, display: ok ? `cubic-bezier(${v.map(fmtNum).join(", ")})` : displayOf(v), hex: null, px: null };
7657
+ }
7658
+ case "typography": {
7659
+ if (isPlainObject(v)) {
7660
+ const fam = displayOf(v.fontFamily);
7661
+ const size = displayOf(v.fontSize);
7662
+ const lh = v.lineHeight != null ? `/${displayOf(v.lineHeight)}` : "";
7663
+ const w = v.fontWeight != null ? ` ${displayOf(v.fontWeight)}` : "";
7664
+ return { value: v, display: `${size}${lh}${w} ${fam}`.trim(), hex: null, px: null };
7665
+ }
7666
+ return { value: v, display: displayOf(v), hex: null, px: null };
7667
+ }
7668
+ default:
7669
+ return { value: v, display: displayOf(v), hex: null, px: null };
7670
+ }
7671
+ }
7672
+ function normalizeTokenSet(files) {
7673
+ const issues = [];
7674
+ const raws = [];
7675
+ for (const file of files) {
7676
+ let parsed;
7677
+ try {
7678
+ parsed = JSON.parse(file.text);
7679
+ } catch (err) {
7680
+ issues.push({ severity: "error", code: "token_file_invalid_json", message: `not valid JSON: ${String(err).slice(0, 160)}`, file: file.path });
7681
+ continue;
7682
+ }
7683
+ if (!isPlainObject(parsed)) {
7684
+ issues.push({ severity: "error", code: "token_file_not_object", message: "a token file must be a JSON object", file: file.path });
7685
+ continue;
7686
+ }
7687
+ collect(parsed, [], null, file, raws, issues);
7688
+ }
7689
+ const byPath = /* @__PURE__ */ new Map();
7690
+ for (const r of raws) {
7691
+ const prev = byPath.get(r.path);
7692
+ if (prev && issues.length < MAX_ISSUES) {
7693
+ issues.push({ severity: "warn", code: "token_duplicate", message: `${r.path} is defined in ${prev.file} and ${r.file}; the later file wins.`, path: r.path, file: r.file });
7694
+ }
7695
+ byPath.set(r.path, r);
7696
+ }
7697
+ const resolved = /* @__PURE__ */ new Map();
7698
+ const resolving = /* @__PURE__ */ new Set();
7699
+ const resolveToken = (path, from) => {
7700
+ if (resolved.has(path)) return resolved.get(path);
7701
+ const tok = byPath.get(path);
7702
+ if (!tok) {
7703
+ if (issues.length < MAX_ISSUES) issues.push({ severity: "warn", code: "alias_unresolved", message: `${from.path} points at {${path}}, which does not exist.`, path: from.path, file: from.file });
7704
+ return null;
7705
+ }
7706
+ if (resolving.has(path)) {
7707
+ if (issues.length < MAX_ISSUES) issues.push({ severity: "error", code: "alias_cycle", message: `alias cycle through ${path}.`, path: from.path, file: from.file });
7708
+ return null;
7709
+ }
7710
+ resolving.add(path);
7711
+ const value = resolveValue(tok.rawValue, tok);
7712
+ resolving.delete(path);
7713
+ const targetType = aliasTarget(tok.rawValue) ? resolved.get(aliasTarget(tok.rawValue))?.type ?? null : null;
7714
+ const out = value === void 0 ? null : { value, type: tok.type ?? targetType ?? inferType(value) };
7715
+ resolved.set(path, out);
7716
+ return out;
7717
+ };
7718
+ const resolveValue = (v, owner) => {
7719
+ const target = aliasTarget(v);
7720
+ if (target) {
7721
+ const r = resolveToken(target, owner);
7722
+ return r ? r.value : void 0;
7723
+ }
7724
+ if (Array.isArray(v)) return v.map((x) => resolveValue(x, owner));
7725
+ if (isPlainObject(v)) {
7726
+ const o = {};
7727
+ for (const [k, x] of Object.entries(v)) o[k] = resolveValue(x, owner);
7728
+ return o;
7729
+ }
7730
+ return v;
7731
+ };
7732
+ const tokens = [];
7733
+ for (const tok of byPath.values()) {
7734
+ const r = resolveToken(tok.path, tok);
7735
+ const alias = aliasTarget(tok.rawValue);
7736
+ let type = r?.type ?? tok.type ?? null;
7737
+ if (!tok.type && !alias) {
7738
+ const inferred = inferType(tok.rawValue);
7739
+ if (inferred && issues.length < MAX_ISSUES) {
7740
+ issues.push({ severity: "info", code: "token_missing_type", message: `${tok.path} has no $type; read as ${inferred}.`, path: tok.path, file: tok.file });
7741
+ }
7742
+ type = type ?? inferred;
7743
+ }
7744
+ const noted = /* @__PURE__ */ new Set();
7745
+ const note2 = (code, msg) => {
7746
+ if (alias || noted.has(code) || issues.length >= MAX_ISSUES) return;
7747
+ noted.add(code);
7748
+ issues.push({ severity: code.endsWith("unparseable") ? "warn" : "info", code, message: `${tok.path}: ${msg}`, path: tok.path, file: tok.file });
7749
+ };
7750
+ const norm = r ? normalizeByType(type, r.value, note2) : { value: null, display: alias ? `{${alias}} (unresolved)` : "unresolved", hex: null, px: null };
7751
+ const ext = tok.ext;
7752
+ const str = (k) => ext && typeof ext[k] === "string" ? ext[k] : null;
7753
+ tokens.push({
7754
+ path: tok.path,
7755
+ type,
7756
+ value: norm.value,
7757
+ display: norm.display,
7758
+ hex: norm.hex,
7759
+ px: norm.px,
7760
+ aliasOf: alias,
7761
+ cssVar: str("cssVar"),
7762
+ ts: str("ts"),
7763
+ rn: str("rn"),
7764
+ description: tok.description,
7765
+ file: tok.file,
7766
+ role: tok.role,
7767
+ group: tok.segments[0] ?? ""
7768
+ });
7769
+ }
7770
+ tokens.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
7771
+ return { tokens, issues };
7772
+ }
7773
+ function declaredFontFamilies(tokens) {
7774
+ const out = /* @__PURE__ */ new Set();
7775
+ const add = (v) => {
7776
+ if (Array.isArray(v)) v.forEach(add);
7777
+ else if (typeof v === "string") v.split(",").forEach((f) => out.add(f.trim().replace(/^['"]|['"]$/g, "").toLowerCase()));
7778
+ };
7779
+ for (const t of tokens) {
7780
+ if (t.type === "fontFamily") add(t.value);
7781
+ else if (t.type === "typography" && isPlainObject(t.value)) add(t.value.fontFamily);
7782
+ }
7783
+ out.delete("");
7784
+ return out;
7785
+ }
7786
+
7787
+ // src/recipe/engine/recipe-glob.ts
7788
+ function normalizeRepoPath(input) {
7789
+ if (typeof input !== "string") return null;
7790
+ const p3 = input.trim();
7791
+ if (p3.length === 0 || p3.length > 512) return null;
7792
+ if (p3.startsWith("/") || p3.startsWith("~")) return null;
7793
+ if (p3.includes("\\") || p3.includes("%")) return null;
7794
+ if (/^[A-Za-z]:/.test(p3)) return null;
7795
+ if (/[\u0000-\u001f\u007f]/.test(p3)) return null;
7796
+ const segments = p3.split("/");
7797
+ for (const seg of segments) {
7798
+ if (seg === "" || seg === "." || seg === "..") return null;
7799
+ }
7800
+ return segments.join("/");
7801
+ }
7802
+ function escapeRegex(ch) {
7803
+ return /[.+^$()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
7804
+ }
7805
+ var regexCache = /* @__PURE__ */ new Map();
7806
+ function globToRegExp(glob) {
7807
+ const cached = regexCache.get(glob);
7808
+ if (cached) return cached;
7809
+ let out = "";
7810
+ let inBrace = false;
7811
+ for (let i = 0; i < glob.length; i++) {
7812
+ const ch = glob[i];
7813
+ if (ch === "*") {
7814
+ if (glob[i + 1] === "*") {
7815
+ if (glob[i + 2] === "/") {
7816
+ out += "(?:[^/]+/)*";
7817
+ i += 2;
7818
+ } else {
7819
+ out += ".*";
7820
+ i += 1;
7821
+ }
7822
+ } else {
7823
+ out += "[^/]*";
7824
+ }
7825
+ } else if (ch === "?") {
7826
+ out += "[^/]";
7827
+ } else if (ch === "{") {
7828
+ if (inBrace) throw new Error(`nested braces are not supported: ${glob}`);
7829
+ inBrace = true;
7830
+ out += "(?:";
7831
+ } else if (ch === "}" && inBrace) {
7832
+ inBrace = false;
7833
+ out += ")";
7834
+ } else if (ch === "," && inBrace) {
7835
+ out += "|";
7836
+ } else {
7837
+ out += escapeRegex(ch);
7838
+ }
7839
+ }
7840
+ if (inBrace) throw new Error(`unbalanced brace in glob: ${glob}`);
7841
+ const re = new RegExp(`^${out}$`);
7842
+ regexCache.set(glob, re);
7843
+ return re;
7844
+ }
7845
+ function matchGlob(path, glob) {
7846
+ try {
7847
+ return globToRegExp(glob).test(path);
7848
+ } catch {
7849
+ return false;
7850
+ }
7851
+ }
7852
+ function matchAny(path, globs) {
7853
+ return globs.some((g) => matchGlob(path, g));
7854
+ }
7855
+
7856
+ // src/recipe/engine/design-deviance.ts
7857
+ var DEVIANCE_K = 3;
7858
+ var SEVERITY_WEIGHT = { error: 3, warn: 2, info: 1 };
7859
+ var LITERAL_RULES = [
7860
+ "off_token_color",
7861
+ "off_token_font",
7862
+ "off_scale_spacing",
7863
+ "off_scale_radius",
7864
+ "raw_interactive_element"
7865
+ ];
7866
+ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
7867
+ "serif",
7868
+ "sans-serif",
7869
+ "monospace",
7870
+ "cursive",
7871
+ "fantasy",
7872
+ "system-ui",
7873
+ "ui-sans-serif",
7874
+ "ui-serif",
7875
+ "ui-monospace",
7876
+ "ui-rounded",
7877
+ "emoji",
7878
+ "math",
7879
+ "fangsong",
7880
+ "-apple-system",
7881
+ "blinkmacsystemfont",
7882
+ "inherit",
7883
+ "initial",
7884
+ "unset",
7885
+ "revert",
7886
+ "revert-layer",
7887
+ "none"
7888
+ ]);
7889
+ var SPACE_GROUP_RE = /^(space|spacing|spacer|gap|gutter|inset)$/i;
7890
+ var RADIUS_SEG_RE = /^(radius|radii|rounded|corner|corners|borderradius)$/i;
7891
+ function buildDevianceContext(tokens, rules, componentGlobs = []) {
7892
+ const colors = [];
7893
+ const spacing = [];
7894
+ const radius = [];
7895
+ for (const t of tokens) {
7896
+ if (t.type === "color" && t.hex) {
7897
+ const rgba = parseCssColor(t.hex);
7898
+ if (rgba) colors.push({ token: t, rgba });
7899
+ }
7900
+ if (t.type === "dimension" && t.px != null) {
7901
+ const segs = t.path.split(".");
7902
+ if (segs.some((s) => SPACE_GROUP_RE.test(s))) spacing.push({ token: t, px: t.px });
7903
+ if (segs.some((s) => RADIUS_SEG_RE.test(s))) radius.push({ token: t, px: t.px });
7904
+ }
7905
+ }
7906
+ return {
7907
+ rules: new Map(rules.map((r) => [r.id, r])),
7908
+ colors,
7909
+ families: declaredFontFamilies(tokens),
7910
+ spacing,
7911
+ radius,
7912
+ componentGlobs: [...componentGlobs]
7913
+ };
7914
+ }
7915
+ function ruleApplicable(rule, ctx, declaredPairs = 0) {
7916
+ switch (rule) {
7917
+ case "off_token_color":
7918
+ return ctx.colors.length > 0;
7919
+ case "off_token_font":
7920
+ return ctx.families.size > 0;
7921
+ case "off_scale_spacing":
7922
+ return ctx.spacing.length > 0;
7923
+ case "off_scale_radius":
7924
+ return ctx.radius.length > 0;
7925
+ case "contrast_below_aa":
7926
+ return declaredPairs > 0;
7927
+ case "raw_interactive_element":
7928
+ return true;
7929
+ }
7930
+ }
7931
+ function langOf(path) {
7932
+ if (/\.(css|scss|sass|less|pcss)$/i.test(path)) return "css";
7933
+ if (/\.(tsx?|jsx?|mjs|cjs|mts|cts|vue|svelte|astro)$/i.test(path)) return "js";
7934
+ return "other";
7935
+ }
7936
+ function maskComments(text2, lang) {
7937
+ const out = text2.split("");
7938
+ const blank = (from, to) => {
7939
+ for (let k = from; k < to; k++) if (out[k] !== "\n" && out[k] !== "\r") out[k] = " ";
7940
+ };
7941
+ let i = 0;
7942
+ const n = text2.length;
7943
+ while (i < n) {
7944
+ const c = text2[i];
7945
+ const d = text2[i + 1];
7946
+ if (c === "/" && d === "*") {
7947
+ const end = text2.indexOf("*/", i + 2);
7948
+ const stop = end === -1 ? n : end + 2;
7949
+ blank(i, stop);
7950
+ i = stop;
7951
+ continue;
7952
+ }
7953
+ if (lang === "js") {
7954
+ if (c === "/" && d === "/") {
7955
+ let end = text2.indexOf("\n", i);
7956
+ if (end === -1) end = n;
7957
+ blank(i, end);
7958
+ i = end;
7959
+ continue;
7960
+ }
7961
+ if (c === '"' || c === "'" || c === "`") {
7962
+ let j = i + 1;
7963
+ while (j < n && text2[j] !== c) {
7964
+ if (text2[j] === "\\") j++;
7965
+ else if (c !== "`" && text2[j] === "\n") break;
7966
+ j++;
7967
+ }
7968
+ i = j + 1;
7969
+ continue;
7970
+ }
7971
+ }
7972
+ i++;
7973
+ }
7974
+ return out.join("");
7975
+ }
7976
+ function lineIndex(text2) {
7977
+ const starts = [0];
7978
+ for (let i = 0; i < text2.length; i++) if (text2[i] === "\n") starts.push(i + 1);
7979
+ return starts;
7980
+ }
7981
+ function position(starts, offset) {
7982
+ let lo = 0, hi = starts.length - 1;
7983
+ while (lo < hi) {
7984
+ const mid = lo + hi + 1 >> 1;
7985
+ if (starts[mid] <= offset) lo = mid;
7986
+ else hi = mid - 1;
7987
+ }
7988
+ return { line: lo + 1, col: offset - starts[lo] + 1 };
7989
+ }
7990
+ function toPx(raw) {
7991
+ const m = /^(-?[\d.]+)(px|rem)?$/.exec(raw.trim());
7992
+ if (!m) return null;
7993
+ const v = Math.abs(Number(m[1]));
7994
+ if (!Number.isFinite(v)) return null;
7995
+ return m[2] === "rem" ? v * 16 : v;
7996
+ }
7997
+ function allowedValue(rule, raw) {
7998
+ const v = raw.trim().toLowerCase();
7999
+ return rule.allowValues.some((a) => a.trim().toLowerCase() === v);
8000
+ }
8001
+ function nearestScale(scale, px) {
8002
+ let best = null;
8003
+ for (const s of scale) {
8004
+ if (!best || Math.abs(s.px - px) < Math.abs(best.px - px) || Math.abs(s.px - px) === Math.abs(best.px - px) && s.token.path < best.token.path) best = s;
8005
+ }
8006
+ return best ? { token: best.token.path, cssVar: best.token.cssVar, ts: best.token.ts, value: best.token.display, distance: Math.round(Math.abs(best.px - px) * 100) / 100 } : null;
8007
+ }
8008
+ function nearestColor(ctx, c) {
8009
+ let best = null;
8010
+ const rank = (t) => t.cssVar ? 0 : t.ts ? 1 : 2;
8011
+ for (const { token, rgba } of ctx.colors) {
8012
+ const d = colorDistance({ ...c, a: 1 }, { ...rgba, a: 1 });
8013
+ if (!best || d < best.d - 1e-9 || Math.abs(d - best.d) < 1e-9 && (rank(token) < rank(best.token) || rank(token) === rank(best.token) && token.path < best.token.path)) best = { token, d };
8014
+ }
8015
+ return best ? { token: best.token.path, cssVar: best.token.cssVar, ts: best.token.ts, value: best.token.display, distance: best.d } : null;
8016
+ }
8017
+ var HEX_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![0-9a-zA-Z_-])/g;
8018
+ var COLOR_FN_RE = /\b(?:rgba?|hsla?|hwb|oklab|oklch|lab|lch|color)\((?:[^()]|\([^()]*\))*\)/gi;
8019
+ var ANCHOR_CONTEXT_RE = /(?:href|to|id|htmlFor|hash|anchor|target|selector|querySelector\(|getElementById\()\s*[=:]?\s*\{?\s*$/i;
8020
+ var HEX_PREV_OK = /[\s(\[,:='"`{]/;
8021
+ var SHORT_HEX_AFTER = /^(?:solid|dashed|dotted|double|groove|ridge|inset|outset|none|transparent|-?[\d.]+(?:px|rem|em|%)?)$/i;
8022
+ function judgeColorLiteral(ctx, rule, literal) {
8023
+ if (allowedValue(rule, literal) || /var\(|\bfrom\b/i.test(literal)) return null;
8024
+ const c = parseCssColor(literal);
8025
+ if (c && ctx.colors.some((t) => sameRgb(t.rgba, c))) return null;
8026
+ return {
8027
+ value: literal,
8028
+ message: c ? `Colour ${literal} is not in your tokens.` : `Colour ${literal} could not be read as sRGB, so it cannot match a token.`,
8029
+ suggestion: c ? nearestColor(ctx, c) : null
8030
+ };
8031
+ }
8032
+ function judgeFontName(ctx, rule, name) {
8033
+ if (!name || /^\d+$/.test(name) || GENERIC_FAMILIES.has(name.toLowerCase())) return null;
8034
+ if (ctx.families.has(name.toLowerCase()) || allowedValue(rule, name)) return null;
8035
+ const body = [...ctx.families][0] ?? null;
8036
+ return { value: name, message: `Font family "${name}" is not in your tokens.`, suggestion: body ? { token: "font.family", cssVar: null, ts: null, value: body, distance: null } : null };
8037
+ }
8038
+ function judgeScaleValue(rule, scale, raw, what) {
8039
+ if (allowedValue(rule, raw)) return null;
8040
+ const px = toPx(raw);
8041
+ if (px == null || px === 0) return null;
8042
+ if (scale.some((s) => Math.abs(s.px - px) < 0.01)) return null;
8043
+ if (allowedValue(rule, `${px}px`)) return null;
8044
+ return { value: raw.trim(), message: `${what} ${raw.trim()} is off your ${what.toLowerCase()} scale.`, suggestion: nearestScale(scale, px) };
8045
+ }
8046
+ function judgeRawElement(rule, tag) {
8047
+ const prim = rule.primitives?.[tag] ?? null;
8048
+ return {
8049
+ value: `<${tag}>`,
8050
+ message: prim ? `Raw <${tag}> bypasses your ${prim} primitive.` : `Raw <${tag}> outside your component primitives.`,
8051
+ suggestion: prim ? { token: prim, cssVar: null, ts: null, value: prim, distance: null } : null
8052
+ };
8053
+ }
8054
+ function scanSourceFile(path, text2, ctx) {
8055
+ const lang = langOf(path);
8056
+ const lines = text2.length === 0 ? 0 : text2.split("\n").length;
8057
+ if (lang === "other") return { findings: [], lines };
8058
+ const masked = maskComments(text2, lang);
8059
+ const starts = lineIndex(text2);
8060
+ const findings = [];
8061
+ const active = (id) => {
8062
+ const r = ctx.rules.get(id);
8063
+ if (!r || !r.enabled) return null;
8064
+ if (r.allowFiles.length > 0 && matchAny(path, r.allowFiles)) return null;
8065
+ return r;
8066
+ };
8067
+ const push = (rule, offset, verdict) => {
8068
+ if (!verdict) return;
8069
+ const { line, col } = position(starts, offset);
8070
+ findings.push({ rule_id: rule.id, severity: rule.severity, file_path: path, line, col, value: verdict.value, message: verdict.message, suggestion: verdict.suggestion });
8071
+ };
8072
+ const colorRule = active("off_token_color");
8073
+ if (colorRule && ctx.colors.length > 0) {
8074
+ const seen = /* @__PURE__ */ new Set();
8075
+ const judge = (offset, literal) => {
8076
+ if (seen.has(offset)) return;
8077
+ seen.add(offset);
8078
+ push(colorRule, offset, judgeColorLiteral(ctx, colorRule, literal));
8079
+ };
8080
+ const inValue = (at) => {
8081
+ const before = masked.slice(Math.max(0, at - 80), at);
8082
+ if (lang === "js") {
8083
+ if (!/['"`][^'"`\n]*$/.test(before)) return false;
8084
+ return !ANCHOR_CONTEXT_RE.test(before.replace(/['"`][^'"`\n]*$/, ""));
8085
+ }
8086
+ if (/url\(\s*['"]?$/i.test(before)) return false;
8087
+ const lineBefore = before.slice(before.lastIndexOf("\n") + 1);
8088
+ return /[:(,]/.test(lineBefore);
8089
+ };
8090
+ for (const m of masked.matchAll(HEX_RE)) {
8091
+ const at = m.index ?? 0;
8092
+ const prev = at > 0 ? masked[at - 1] : " ";
8093
+ if (!HEX_PREV_OK.test(prev) || !inValue(at)) continue;
8094
+ if (lang === "js" && m[0].length <= 5 && /\s/.test(prev)) {
8095
+ const word = /([\w.%-]+)\s+$/.exec(masked.slice(Math.max(0, at - 30), at))?.[1] ?? "";
8096
+ if (!SHORT_HEX_AFTER.test(word)) continue;
8097
+ }
8098
+ judge(at, m[0]);
8099
+ }
8100
+ for (const m of masked.matchAll(COLOR_FN_RE)) {
8101
+ if (inValue(m.index ?? 0)) judge(m.index ?? 0, m[0]);
8102
+ }
8103
+ }
8104
+ const fontRule = active("off_token_font");
8105
+ if (fontRule && ctx.families.size > 0) {
8106
+ const check = (offset, list) => {
8107
+ if (/var\(|\$\{/.test(list)) return;
8108
+ for (const fam of list.split(",")) {
8109
+ const name = fam.trim().replace(/^['"]|['"]$/g, "").replace(/_/g, " ").trim();
8110
+ push(fontRule, offset, judgeFontName(ctx, fontRule, name));
8111
+ }
8112
+ };
8113
+ const re = lang === "css" ? /font-family\s*:\s*([^;{}]+)/gi : /fontFamily\s*:\s*(['"`])([^'"`]+)\1/g;
8114
+ for (const m of masked.matchAll(re)) check(m.index ?? 0, lang === "css" ? m[1] : m[2]);
8115
+ if (lang === "js") {
8116
+ for (const m of masked.matchAll(/(?<![\w-])font-\[([^\]\s]+)\]/g)) check(m.index ?? 0, m[1].replace(/^['"]|['"]$/g, ""));
8117
+ }
8118
+ }
8119
+ const scaleRule = (id, scale, cssProp, jsProp, twClass, what) => {
8120
+ const rule = active(id);
8121
+ if (!rule || scale.length === 0) return;
8122
+ const judge = (offset, raw) => push(rule, offset, judgeScaleValue(rule, scale, raw, what));
8123
+ if (lang === "css") {
8124
+ for (const m of masked.matchAll(cssProp)) {
8125
+ const valueStart = (m.index ?? 0) + m[0].length - m[m.length - 1].length;
8126
+ let local = 0;
8127
+ for (const part of m[m.length - 1].split(/(\s+)/)) {
8128
+ if (/^-?[\d.]+(px|rem)$/.test(part)) judge(valueStart + local, part);
8129
+ local += part.length;
8130
+ }
8131
+ }
8132
+ } else {
8133
+ for (const m of masked.matchAll(jsProp)) {
8134
+ const raw = m[2];
8135
+ judge((m.index ?? 0) + m[0].length - raw.length, /^-?[\d.]+$/.test(raw) ? `${raw}px` : raw);
8136
+ }
8137
+ for (const m of masked.matchAll(twClass)) judge(m.index ?? 0, m[m.length - 1]);
8138
+ }
8139
+ };
8140
+ scaleRule(
8141
+ "off_scale_spacing",
8142
+ ctx.spacing,
8143
+ /(?<![\w-])(?:padding|margin|gap|row-gap|column-gap)(?:-(?:top|right|bottom|left|inline|block)(?:-start|-end)?)?\s*:\s*([^;{}]+)/gi,
8144
+ /\b(padding|paddingTop|paddingBottom|paddingLeft|paddingRight|paddingHorizontal|paddingVertical|paddingInline|paddingBlock|paddingStart|paddingEnd|margin|marginTop|marginBottom|marginLeft|marginRight|marginHorizontal|marginVertical|marginInline|marginBlock|marginStart|marginEnd|gap|rowGap|columnGap)\s*:\s*['"]?(-?\d+(?:\.\d+)?(?:px|rem)?)(?![\w.%])/g,
8145
+ /(?<![\w-])-?(?:p|px|py|pt|pr|pb|pl|ps|pe|m|mx|my|mt|mr|mb|ml|ms|me|gap|gap-x|gap-y|space-x|space-y)-\[(-?[\d.]+(?:px|rem))\]/g,
8146
+ "Spacing"
8147
+ );
8148
+ scaleRule(
8149
+ "off_scale_radius",
8150
+ ctx.radius,
8151
+ /(?<![\w-])border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius\s*:\s*([^;{}]+)/gi,
8152
+ /\b(borderRadius|borderTopLeftRadius|borderTopRightRadius|borderBottomLeftRadius|borderBottomRightRadius)\s*:\s*['"]?(\d+(?:\.\d+)?(?:px|rem)?)(?![\w.%])/g,
8153
+ /(?<![\w-])rounded(?:-(?:t|r|b|l|tl|tr|bl|br|s|e|ss|se|es|ee))?-\[([\d.]+(?:px|rem))\]/g,
8154
+ "Radius"
8155
+ );
8156
+ const rawRule = active("raw_interactive_element");
8157
+ if (rawRule && /\.(tsx|jsx)$/i.test(path) && !matchAny(path, ctx.componentGlobs)) {
8158
+ for (const m of masked.matchAll(/<(button|input|select|textarea)\b/g)) push(rawRule, m.index ?? 0, judgeRawElement(rawRule, m[1]));
8159
+ }
8160
+ return { findings, lines };
8161
+ }
8162
+ function stripBraces(ref) {
8163
+ return ref.trim().replace(/^\{|\}$/g, "");
8164
+ }
8165
+ function evaluateContrast(tokens, pairs) {
8166
+ const byPath = new Map(tokens.map((t) => [t.path, t]));
8167
+ return pairs.map((p3) => {
8168
+ const fgPath = stripBraces(p3.fg);
8169
+ const bgPath = stripBraces(p3.bg);
8170
+ const min = p3.min ?? (p3.large ? 3 : 4.5);
8171
+ const fg = byPath.get(fgPath);
8172
+ const bg = byPath.get(bgPath);
8173
+ const base = { fg: fgPath, bg: bgPath, min, use: p3.use ?? null };
8174
+ const fc = fg?.hex ? parseCssColor(fg.hex) : null;
8175
+ const bc = bg?.hex ? parseCssColor(bg.hex) : null;
8176
+ if (!fc || !bc) {
8177
+ const missing = !fg ? fgPath : !bg ? bgPath : !fc ? fgPath : bgPath;
8178
+ return { ...base, fgHex: fg?.hex ?? null, bgHex: bg?.hex ?? null, ratio: null, pass: null, problem: `${missing} is not a resolvable colour token` };
8179
+ }
8180
+ const ratio = contrastRatio(fc, bc);
8181
+ return { ...base, fgHex: fg.hex, bgHex: bg.hex, ratio, pass: ratio >= min, problem: null };
8182
+ });
8183
+ }
8184
+ function contrastFindings(results, tokens, rule) {
8185
+ if (!rule?.enabled) return [];
8186
+ const byPath = new Map(tokens.map((t) => [t.path, t]));
8187
+ return results.filter((r) => r.pass === false).map((r) => ({
8188
+ rule_id: "contrast_below_aa",
8189
+ severity: rule.severity,
8190
+ file_path: byPath.get(r.fg)?.file ?? null,
8191
+ line: null,
8192
+ col: null,
8193
+ value: `${r.fg} on ${r.bg}`,
8194
+ message: `${r.fg} on ${r.bg} is ${r.ratio}:1; it needs ${r.min}:1${r.use ? ` (${r.use})` : ""}.`,
8195
+ suggestion: null
8196
+ }));
8197
+ }
8198
+ function computeDevianceScore(input) {
8199
+ const kloc = Math.max(1, input.scannedLines / 1e3);
8200
+ let num2 = 0;
8201
+ let den = 0;
8202
+ const breakdown = input.rules.map((r) => {
8203
+ const count = input.counts[r.id] ?? 0;
8204
+ const weight = SEVERITY_WEIGHT[r.severity];
8205
+ let applicable = input.applicable[r.id] ?? false;
8206
+ let density = null;
8207
+ let penalty = 0;
8208
+ if (r.id === "contrast_below_aa") {
8209
+ applicable = applicable && input.contrast.declared > 0;
8210
+ if (applicable) {
8211
+ density = input.contrast.failing / input.contrast.declared;
8212
+ penalty = density;
8213
+ }
8214
+ } else {
8215
+ applicable = applicable && input.scannedFiles > 0;
8216
+ if (applicable) {
8217
+ density = Math.round(count / kloc * 1e3) / 1e3;
8218
+ penalty = 1 - Math.exp(-(count / kloc) / DEVIANCE_K);
8219
+ }
8220
+ }
8221
+ if (r.enabled && applicable) {
8222
+ num2 += weight * penalty;
8223
+ den += weight;
8224
+ }
8225
+ return { rule: r.id, enabled: r.enabled, applicable, severity: r.severity, weight, count, density, penalty: Math.round(penalty * 1e3) / 1e3 };
8226
+ });
8227
+ return { score: den === 0 ? null : Math.round(100 * num2 / den), breakdown };
8228
+ }
8229
+ var SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
8230
+ function sortFindings(findings) {
8231
+ return findings.sort(
8232
+ (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || (a.file_path ?? "").localeCompare(b.file_path ?? "") || (a.line ?? 0) - (b.line ?? 0) || (a.col ?? 0) - (b.col ?? 0)
8233
+ );
8234
+ }
8235
+
8236
+ // src/recipe/engine/design-engine-types.ts
8237
+ var DESIGN_RULE_IDS = [
8238
+ "off_token_color",
8239
+ "off_token_font",
8240
+ "off_scale_spacing",
8241
+ "off_scale_radius",
8242
+ "contrast_below_aa",
8243
+ "raw_interactive_element"
8244
+ ];
8245
+
8246
+ // src/recipe/engine/design-rules.ts
8247
+ var DEFAULT_DESIGN_RULES = {
8248
+ off_token_color: { enabled: true, severity: "warn", allowValues: [] },
8249
+ off_token_font: { enabled: true, severity: "warn", allowValues: [] },
8250
+ off_scale_spacing: { enabled: true, severity: "info", allowValues: ["0", "0px"] },
8251
+ off_scale_radius: { enabled: true, severity: "info", allowValues: ["0", "0px", "50%", "100%"] },
8252
+ contrast_below_aa: { enabled: true, severity: "error", allowValues: [] },
8253
+ raw_interactive_element: { enabled: false, severity: "info", allowValues: [] }
8254
+ };
8255
+ function effectiveDesignRules(manifest) {
8256
+ const declared = manifest?.design?.rules ?? {};
8257
+ return DESIGN_RULE_IDS.map((id) => {
8258
+ const d = DEFAULT_DESIGN_RULES[id];
8259
+ const m = declared[id];
8260
+ const cfg = {
8261
+ id,
8262
+ enabled: m?.enabled ?? d.enabled,
8263
+ severity: m?.severity ?? d.severity,
8264
+ allowValues: m?.allowValues ?? d.allowValues,
8265
+ allowFiles: m?.allowFiles ?? [],
8266
+ fromManifest: m !== void 0
8267
+ };
8268
+ if (id === "raw_interactive_element") cfg.primitives = m?.primitives ?? {};
8269
+ return cfg;
8270
+ });
8271
+ }
8272
+
8273
+ // src/recipe/engine/design-set-plan.ts
8274
+ var MAX_TOKEN_FILES = 40;
8275
+ var MAX_TOKEN_FILE_BYTES = 1024 * 1024;
8276
+ var DIRECTION_RE = /^(.*?(?:^|\/)directions\/)([^/]+)\/([^/]+\.json)$/;
8277
+ function directionOf(path) {
8278
+ const m = DIRECTION_RE.exec(path);
8279
+ return m ? { base: m[1], name: m[2], file: m[3] } : null;
8280
+ }
8281
+ function planTokenSets(declared, treePaths, directions = []) {
8282
+ const issues = [];
8283
+ const safe = declared.map((t) => ({ ...t, path: normalizeRepoPath(t.path) })).filter((t) => t.path !== null);
8284
+ const sources = safe.filter((t) => t.role === "source");
8285
+ const exports = safe.filter((t) => t.role === "export");
8286
+ const sets = [];
8287
+ const dirs = sources.map((t) => directionOf(t.path));
8288
+ const sameDirection = sources.length > 0 && dirs.every((d) => d && d.base === dirs[0].base && d.name === dirs[0].name);
8289
+ const declaredActive = directions.find((d) => d.status === "active");
8290
+ const activeName = declaredActive?.name ?? (sameDirection ? dirs[0].name : "default");
8291
+ if (sources.length > 0) {
8292
+ sets.push({
8293
+ name: activeName,
8294
+ kind: sameDirection || declaredActive ? "direction" : "default",
8295
+ active: true,
8296
+ files: sources.map((t) => ({ path: t.path, role: "source", generator: t.generator ?? null })),
8297
+ note: declaredActive?.note ?? null
8298
+ });
8299
+ }
8300
+ if (directions.length > 0) {
8301
+ for (const d of directions) {
8302
+ if (d.status !== "inactive" || d.name === activeName) continue;
8303
+ const files = d.tokens.map((p3) => normalizeRepoPath(p3)).filter((p3) => p3 !== null);
8304
+ sets.push({ name: d.name, kind: "direction", active: false, files: files.map((path) => ({ path, role: "source", generator: null })), note: d.note ?? null });
8305
+ }
8306
+ } else if (sameDirection) {
8307
+ const base = dirs[0].base;
8308
+ const siblings = /* @__PURE__ */ new Map();
8309
+ for (const p3 of treePaths) {
8310
+ if (!p3.startsWith(base)) continue;
8311
+ const d = directionOf(p3);
8312
+ if (!d || d.base !== base || d.name === activeName) continue;
8313
+ if (!/\.tokens\.json$|^tokens\.json$/.test(d.file)) continue;
8314
+ const list = siblings.get(d.name) ?? [];
8315
+ list.push(p3);
8316
+ siblings.set(d.name, list);
8317
+ }
8318
+ for (const name of [...siblings.keys()].sort()) {
8319
+ sets.push({
8320
+ name,
8321
+ kind: "direction",
8322
+ active: false,
8323
+ files: siblings.get(name).sort().map((path) => ({ path, role: "source", generator: null }))
8324
+ });
8325
+ }
8326
+ }
8327
+ if (exports.length > 0) {
8328
+ sets.push({
8329
+ name: "export",
8330
+ kind: "export",
8331
+ active: sources.length === 0,
8332
+ files: exports.map((t) => ({ path: t.path, role: "export", generator: t.generator ?? null }))
8333
+ });
8334
+ }
8335
+ let budget = MAX_TOKEN_FILES;
8336
+ const capped = [];
8337
+ for (const s of sets) {
8338
+ if (s.files.length > budget) {
8339
+ issues.push({ severity: "warn", code: "too_many_token_files", message: `Only ${MAX_TOKEN_FILES} token files are read per refresh; set "${s.name}" was skipped.` });
8340
+ continue;
8341
+ }
8342
+ budget -= s.files.length;
8343
+ capped.push(s);
8344
+ }
8345
+ return { sets: capped, issues };
8346
+ }
8347
+ function judgingSet(stored) {
8348
+ if (!stored) return null;
8349
+ return stored.sets.find((s) => s.active && s.tokens.length > 0) ?? stored.sets.find((s) => s.kind === "export" && s.tokens.length > 0) ?? null;
8350
+ }
8351
+
8352
+ // src/recipe/engine/design-scan.ts
8353
+ var SCAN_LIMITS = { maxFiles: 1500, maxBytes: 12 * 1024 * 1024, maxFileBytes: 256 * 1024, maxStoredFindings: 500 };
8354
+ var DEFAULT_SCAN_GLOBS = ["**/*.{css,scss,ts,tsx,js,jsx}"];
8355
+ var ALWAYS_IGNORE = [
8356
+ "**/node_modules/**",
8357
+ "**/dist/**",
8358
+ "**/build/**",
8359
+ "**/.next/**",
8360
+ "**/out/**",
8361
+ "**/coverage/**",
8362
+ "**/vendor/**",
8363
+ "**/*.d.ts",
8364
+ "**/*.min.*",
8365
+ "**/*.map",
8366
+ "**/*.generated.*",
8367
+ "**/android/**",
8368
+ "**/ios/**"
8369
+ ];
8370
+ function selectScanFiles(entries, manifest, excludePaths, limits = SCAN_LIMITS) {
8371
+ const declared = manifest?.design?.literalScan?.globs;
8372
+ const globs = declared && declared.length > 0 ? [...declared] : DEFAULT_SCAN_GLOBS;
8373
+ const ignore = [...ALWAYS_IGNORE, ...manifest?.design?.literalScan?.ignore ?? []];
8374
+ 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);
8375
+ const files = [];
8376
+ let bytes = 0;
8377
+ for (const e of matched) {
8378
+ if (files.length >= limits.maxFiles || bytes + e.size > limits.maxBytes) break;
8379
+ files.push(e.path);
8380
+ bytes += e.size;
8381
+ }
8382
+ return { files, matched: matched.length, truncated: files.length < matched.length };
8383
+ }
8384
+ function tokenFilePaths(tokens) {
8385
+ return (tokens?.sets ?? []).flatMap((s) => s.files.map((f) => f.path));
8386
+ }
8387
+ function scoreDeviance(input, literal) {
8388
+ const tokens = judgingSet(input.tokens)?.tokens ?? [];
8389
+ const rules = effectiveDesignRules(input.manifest);
8390
+ const ctx = buildDevianceContext(tokens, rules, input.manifest?.design?.components?.globs ?? []);
8391
+ const contrast = evaluateContrast(tokens, input.manifest?.design?.contrast ?? []);
8392
+ const found = contrastFindings(contrast, tokens, rules.find((r) => r.id === "contrast_below_aa"));
8393
+ const counts = {};
8394
+ for (const r of LITERAL_RULES) if (literal.counts[r]) counts[r] = literal.counts[r];
8395
+ if (found.length > 0) counts.contrast_below_aa = found.length;
8396
+ const judged = contrast.filter((c) => c.pass !== null);
8397
+ const applicable = {};
8398
+ for (const r of [...LITERAL_RULES, "contrast_below_aa"]) applicable[r] = ruleApplicable(r, ctx, judged.length);
8399
+ const { score, breakdown } = computeDevianceScore({
8400
+ rules,
8401
+ counts,
8402
+ scannedLines: literal.scannedLines,
8403
+ scannedFiles: literal.scannedFiles,
8404
+ applicable,
8405
+ contrast: { declared: judged.length, failing: judged.filter((c) => c.pass === false).length }
8406
+ });
8407
+ return { contrastFindings: found, counts, score, breakdown };
8408
+ }
8409
+ function computeDeviance(input, texts) {
8410
+ const tokens = judgingSet(input.tokens)?.tokens ?? [];
8411
+ const rules = effectiveDesignRules(input.manifest);
8412
+ const ctx = buildDevianceContext(tokens, rules, input.manifest?.design?.components?.globs ?? []);
8413
+ const literal = [];
8414
+ let scannedLines = 0;
8415
+ let scannedFiles = 0;
8416
+ for (const [path, text2] of texts) {
8417
+ if (text2 == null) continue;
8418
+ const r = scanSourceFile(path, text2, ctx);
8419
+ scannedLines += r.lines;
8420
+ scannedFiles++;
8421
+ literal.push(...r.findings);
8422
+ }
8423
+ const literalCounts = {};
8424
+ for (const f of literal) literalCounts[f.rule_id] = (literalCounts[f.rule_id] ?? 0) + 1;
8425
+ const scored = scoreDeviance(input, { counts: literalCounts, scannedLines, scannedFiles });
8426
+ return {
8427
+ findings: sortFindings([...literal, ...scored.contrastFindings]),
8428
+ counts: scored.counts,
8429
+ score: scored.score,
8430
+ breakdown: scored.breakdown,
8431
+ scannedLines,
8432
+ scannedFiles
8433
+ };
8434
+ }
8435
+ function readScanTokens(manifest, treePaths, readFile3) {
8436
+ const plan = planTokenSets(manifest.design?.tokens ?? [], treePaths, manifest.design?.directions ?? []);
8437
+ const issues = [...plan.issues];
8438
+ const sets = [];
8439
+ for (const set of plan.sets) {
8440
+ if (!set.active && set.kind !== "export") {
8441
+ sets.push({ name: set.name, active: set.active, kind: set.kind, files: set.files, tokens: [] });
8442
+ continue;
8443
+ }
8444
+ const files = [];
8445
+ for (const f of set.files) {
8446
+ const file = readFile3(f.path, MAX_TOKEN_FILE_BYTES);
8447
+ if (file.kind === "absent") {
8448
+ issues.push({ severity: set.active ? "error" : "warn", code: "TOKEN_FILE_MISSING", message: `${f.path} is listed but does not exist.`, file: f.path });
8449
+ continue;
8450
+ }
8451
+ if (file.kind === "too_large") {
8452
+ issues.push({ severity: "error", code: "TOKEN_FILE_TOO_LARGE", message: `${f.path} is ${file.size} bytes; the cap is ${MAX_TOKEN_FILE_BYTES}.`, file: f.path });
8453
+ continue;
8454
+ }
8455
+ files.push({ path: f.path, role: f.role, text: file.text });
8456
+ }
8457
+ const norm = normalizeTokenSet(files);
8458
+ issues.push(...norm.issues);
8459
+ sets.push({ name: set.name, active: set.active, kind: set.kind, files: set.files, tokens: norm.tokens });
8460
+ }
8461
+ return { tokens: { sets }, issues };
8462
+ }
8463
+
8464
+ // src/file-io.ts
8465
+ import { closeSync, fstatSync, openSync, readSync, writeFileSync as writeFileSync2 } from "fs";
8466
+ var CHUNK_BYTES = 64 * 1024;
8467
+ function readTextFileCapped(path, maxBytes) {
8468
+ let fd;
8469
+ try {
8470
+ fd = openSync(path, "r");
8471
+ } catch {
8472
+ return { kind: "absent" };
8473
+ }
8474
+ try {
8475
+ const stat = fstatSync(fd);
8476
+ if (!stat.isFile()) return { kind: "absent" };
8477
+ if (stat.size > maxBytes) return { kind: "too_large", size: stat.size };
8478
+ const chunks = [];
8479
+ const chunk = Buffer.alloc(Math.min(CHUNK_BYTES, maxBytes + 1));
8480
+ let total = 0;
8481
+ for (; ; ) {
8482
+ const read = readSync(fd, chunk, 0, chunk.length, null);
8483
+ if (read === 0) break;
8484
+ total += read;
8485
+ if (total > maxBytes) return { kind: "too_large", size: total };
8486
+ chunks.push(Buffer.from(chunk.subarray(0, read)));
8487
+ }
8488
+ return { kind: "file", text: Buffer.concat(chunks).toString("utf8") };
8489
+ } finally {
8490
+ closeSync(fd);
8491
+ }
8492
+ }
8493
+ function writeUserOutputFile(path, text2, opts) {
8494
+ if (typeof text2 !== "string") {
8495
+ throw new MushiCliError("E_API_ERROR", "The server sent something other than text, so nothing was written.");
8496
+ }
8497
+ if (Buffer.byteLength(text2, "utf8") > opts.maxBytes) {
8498
+ throw new MushiCliError("E_API_ERROR", `The server sent more than ${Math.round(opts.maxBytes / (1024 * 1024))} MB, so nothing was written.`);
8499
+ }
8500
+ try {
8501
+ writeFileSync2(path, text2, { encoding: "utf8", mode: opts.mode ?? 420 });
8502
+ } catch (err) {
8503
+ throw new MushiCliError("E_FILE_PERMISSION", `Could not write ${path}`, "pick a path you can write to", err);
8504
+ }
8505
+ }
8506
+
8507
+ // src/recipe/manifest-shape.ts
8508
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
8509
+ var isString = (v) => typeof v === "string";
8510
+ var isStringList = (v) => Array.isArray(v) && v.every(isString);
8511
+ var SEVERITIES2 = ["info", "warn", "error"];
8512
+ function readRules(raw, issues, where) {
8513
+ if (raw === void 0) return void 0;
8514
+ if (!isObject(raw)) {
8515
+ issues.push({ severity: "error", message: `${where} must be an object of rule settings.` });
8516
+ return void 0;
8517
+ }
8518
+ const out = {};
8519
+ for (const [id, value] of Object.entries(raw)) {
8520
+ if (!isObject(value)) {
8521
+ issues.push({ severity: "error", message: `${where}.${id} must be an object.` });
8522
+ continue;
8523
+ }
8524
+ const rule = {};
8525
+ if (value.enabled !== void 0) {
8526
+ if (typeof value.enabled === "boolean") rule.enabled = value.enabled;
8527
+ else issues.push({ severity: "error", message: `${where}.${id}.enabled must be true or false.` });
8528
+ }
8529
+ if (value.severity !== void 0) {
8530
+ if (SEVERITIES2.includes(value.severity)) rule.severity = value.severity;
8531
+ else issues.push({ severity: "error", message: `${where}.${id}.severity must be info, warn or error.` });
8532
+ }
8533
+ for (const key of ["allowValues", "allowFiles"]) {
8534
+ if (value[key] === void 0) continue;
8535
+ if (isStringList(value[key])) rule[key] = value[key];
8536
+ else issues.push({ severity: "error", message: `${where}.${id}.${key} must be a list of strings.` });
8537
+ }
8538
+ if (value.primitives !== void 0) {
8539
+ if (isObject(value.primitives) && Object.values(value.primitives).every(isString)) rule.primitives = value.primitives;
8540
+ else issues.push({ severity: "error", message: `${where}.${id}.primitives must map element names to component names.` });
8541
+ }
8542
+ out[id] = rule;
8543
+ }
8544
+ return out;
8545
+ }
8546
+ function readContrast(raw, issues) {
8547
+ if (raw === void 0) return void 0;
8548
+ if (!Array.isArray(raw)) {
8549
+ issues.push({ severity: "error", message: "design.contrast must be a list of { fg, bg } pairs." });
8550
+ return void 0;
8551
+ }
8552
+ const out = [];
8553
+ raw.forEach((p3, i) => {
8554
+ if (!isObject(p3) || !isString(p3.fg) || !isString(p3.bg) || !p3.fg || !p3.bg) {
8555
+ issues.push({ severity: "error", message: `design.contrast[${i}] needs fg and bg token paths.` });
8556
+ return;
8557
+ }
8558
+ const pair = { fg: p3.fg, bg: p3.bg };
8559
+ if (typeof p3.min === "number" && p3.min >= 1 && p3.min <= 21) pair.min = p3.min;
8560
+ else if (p3.min !== void 0) issues.push({ severity: "error", message: `design.contrast[${i}].min must be a ratio from 1 to 21.` });
8561
+ if (typeof p3.large === "boolean") pair.large = p3.large;
8562
+ if (isString(p3.use)) pair.use = p3.use;
8563
+ out.push(pair);
8564
+ });
8565
+ return out;
8566
+ }
8567
+ function readScanManifest(manifest) {
8568
+ const issues = [];
8569
+ if (manifest.design === void 0) return { manifest: {}, issues };
8570
+ if (!isObject(manifest.design)) {
8571
+ issues.push({ severity: "error", message: '"design" must be an object.' });
8572
+ return { manifest: {}, issues };
8573
+ }
8574
+ const d = manifest.design;
8575
+ const design = {};
8576
+ if (d.tokens !== void 0) {
8577
+ const tokens = [];
8578
+ if (!Array.isArray(d.tokens)) issues.push({ severity: "error", message: "design.tokens must be a list." });
8579
+ else d.tokens.forEach((t, i) => {
8580
+ if (!isObject(t) || !isString(t.path) || !t.path) {
8581
+ issues.push({ severity: "error", message: `design.tokens[${i}] needs a path.` });
8582
+ return;
8583
+ }
8584
+ if (t.role !== "source" && t.role !== "export") {
8585
+ issues.push({ severity: "error", message: `design.tokens[${i}] (${t.path}) needs "role": "source" or "export".`, path: t.path });
8586
+ return;
8587
+ }
8588
+ tokens.push({ path: t.path, role: t.role, ...isString(t.generator) ? { generator: t.generator } : {} });
8589
+ });
8590
+ design.tokens = tokens;
8591
+ }
8592
+ if (d.directions !== void 0) {
8593
+ const dirs = [];
8594
+ if (!Array.isArray(d.directions)) issues.push({ severity: "error", message: "design.directions must be a list." });
8595
+ else d.directions.forEach((x, i) => {
8596
+ if (!isObject(x) || !isString(x.name) || x.status !== "active" && x.status !== "inactive" || !isStringList(x.tokens) || x.tokens.length === 0) {
8597
+ issues.push({ severity: "error", message: `design.directions[${i}] needs a name, "status": "active" or "inactive", and its token paths.` });
8598
+ return;
8599
+ }
8600
+ dirs.push({ name: x.name, status: x.status, tokens: x.tokens, ...isString(x.note) ? { note: x.note } : {} });
8601
+ });
8602
+ design.directions = dirs;
8603
+ }
8604
+ if (d.components !== void 0) {
8605
+ if (isObject(d.components) && isStringList(d.components.globs)) design.components = { globs: d.components.globs };
8606
+ else issues.push({ severity: "error", message: 'design.components needs "globs": a list of file globs.' });
8607
+ }
8608
+ if (d.literalScan !== void 0) {
8609
+ const ls = d.literalScan;
8610
+ if (!isObject(ls) || ls.globs !== void 0 && !isStringList(ls.globs) || ls.ignore !== void 0 && !isStringList(ls.ignore)) {
8611
+ issues.push({ severity: "error", message: "design.literalScan.globs and .ignore must be lists of file globs." });
8612
+ } else {
8613
+ design.literalScan = { ...ls.globs ? { globs: ls.globs } : {}, ...ls.ignore ? { ignore: ls.ignore } : {} };
8614
+ }
8615
+ }
8616
+ const contrast = readContrast(d.contrast, issues);
8617
+ if (contrast) design.contrast = contrast;
8618
+ const rules = readRules(d.rules, issues, "design.rules");
8619
+ if (rules) design.rules = rules;
8620
+ return { manifest: { design }, issues };
8621
+ }
8622
+
8623
+ // src/recipe/local.ts
8624
+ var MANIFEST = "mushi.recipe.json";
8625
+ var MAX_MANIFEST_BYTES = 64 * 1024;
8626
+ var PUSH_MAX_FILES = 60;
8627
+ var PUSH_MAX_FILE_BYTES = 512 * 1024;
8628
+ var PUSH_MAX_TOTAL_BYTES = 4 * 1024 * 1024;
8629
+ var SKIP = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", ".next", "out", "coverage", ".turbo", "Pods", ".gradle"]);
8630
+ function walk(root, limit = 2e4) {
8631
+ const out = [];
8632
+ const go = (dir) => {
8633
+ if (out.length >= limit) return;
8634
+ let entries;
8635
+ try {
8636
+ entries = readdirSync3(dir, { withFileTypes: true });
8637
+ } catch {
8638
+ return;
8639
+ }
8640
+ for (const e of entries) {
8641
+ if (out.length >= limit) return;
8642
+ if (e.isSymbolicLink()) continue;
8643
+ const full = join6(dir, e.name);
8644
+ if (e.isDirectory()) {
8645
+ if (!SKIP.has(e.name)) go(full);
8646
+ } else if (e.isFile()) out.push(relative3(root, full).split(sep2).join("/"));
8647
+ }
8648
+ };
8649
+ go(root);
8650
+ return out;
8651
+ }
8652
+ function listRepoFiles2(root) {
8653
+ let paths;
8654
+ try {
8655
+ const out = execFileSync2("git", ["ls-files", "-z", "--cached"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 });
8656
+ paths = out.split("\0").filter(Boolean);
8657
+ } catch {
8658
+ paths = walk(root);
8659
+ }
8660
+ const entries = [];
8661
+ for (const p3 of paths) {
8662
+ try {
8663
+ const st = statSync3(join6(root, p3));
8664
+ if (st.isFile()) entries.push({ path: p3, size: st.size });
8665
+ } catch {
8666
+ }
8667
+ }
8668
+ return entries;
8669
+ }
8670
+ function readJson(path) {
8671
+ try {
8672
+ return JSON.parse(readFileSync4(path, "utf8"));
8673
+ } catch {
8674
+ return null;
8675
+ }
8676
+ }
8677
+ var FIXTURE_DIR = /(^|\/)(__tests__|__fixtures__|__mocks__|fixtures|tests?|e2e|examples?)\//;
8678
+ function gitDefaultBranch(root) {
8679
+ try {
8680
+ const ref = execFileSync2("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
8681
+ const branch = ref.replace(/^origin\//, "");
8682
+ if (branch) return branch;
8683
+ } catch {
8684
+ }
8685
+ return "main";
8686
+ }
8687
+ var ENV_EXAMPLES = [".env.example", ".env.local.example", ".env.sample", ".env.template"];
8688
+ var ROOT_APP_DIRS = ["app", "src", "components", "features", "lib", "hooks", "pages"];
8689
+ var MAX_LITERAL_GLOBS = 30;
8690
+ var MAX_READ_BYTES = 512 * 1024;
8691
+ var MAX_SCRIPT_FILES = 400;
8692
+ var DEPLOY_STEPS = [
8693
+ /upload-google-play/i,
8694
+ /upload-testflight/i,
8695
+ /\baltool\b/i,
8696
+ /app-store-connect/i,
8697
+ /\baws\s+s3\s+sync\b/i,
8698
+ /\bwrangler\s+(pages\s+)?deploy\b/i,
8699
+ /cloudflare\/wrangler-action/i,
8700
+ /\bvercel\s+(deploy|--prod)\b/i,
8701
+ /\bsupabase\s+functions\s+deploy\b/i,
8702
+ /\bcapgo\b/i,
8703
+ /\beas\s+update\b/i,
8704
+ /\b(npm|pnpm|yarn)\s+publish\b/i,
8705
+ /\bchangeset\s+publish\b/i
8706
+ ];
8707
+ var WRITES_FILE = /\b(writeFileSync|writeFile|createWriteStream|outputFileSync|outputFile|outputJsonSync|outputJson|writeJsonSync|writeJson|writeTextFile|write_text)\b/;
8708
+ function readCapped(root, path) {
8709
+ const f = readTextFileCapped(join6(root, path), MAX_READ_BYTES);
8710
+ return f.kind === "file" ? f.text : null;
8711
+ }
8712
+ function workspacePatterns(root, pkg) {
8713
+ const out = [];
8714
+ const ws = pkg?.workspaces;
8715
+ const list = Array.isArray(ws) ? ws : isObject2(ws) && Array.isArray(ws.packages) ? ws.packages : [];
8716
+ for (const p3 of list) if (typeof p3 === "string") out.push(p3);
8717
+ const yaml = readCapped(root, "pnpm-workspace.yaml");
8718
+ if (yaml) {
8719
+ let inPackages = false;
8720
+ for (const line of yaml.split(/\r?\n/)) {
8721
+ if (/^packages\s*:/.test(line)) {
8722
+ inPackages = true;
8723
+ continue;
8724
+ }
8725
+ if (/^\S/.test(line)) inPackages = false;
8726
+ const item = inPackages ? /^\s+-\s*["']?([^"'#]+?)["']?\s*(#.*)?$/.exec(line) : null;
8727
+ if (item) out.push(item[1]);
8728
+ }
8729
+ }
8730
+ return out.map((p3) => p3.trim().replace(/^\.\//, "").replace(/\/+$/, "")).filter(Boolean);
8731
+ }
8732
+ function scriptRunner(files) {
8733
+ if (files.has("pnpm-lock.yaml")) return "pnpm";
8734
+ if (files.has("yarn.lock")) return "yarn";
8735
+ if (files.has("bun.lockb") || files.has("bun.lock")) return "bun run";
8736
+ return "npm run";
8737
+ }
8738
+ function tokenOrigin(tokenPath, scripts, scriptFiles, runner) {
8739
+ const isCheck = (name, cmd) => /(^|\s)--check(\s|=|$)/.test(cmd) || /(^|:)check$/.test(name);
8740
+ const namesToken = (file) => {
8741
+ if (file.text.includes(tokenPath)) return true;
8742
+ const pkgDir = file.path.includes("/scripts/") ? file.path.slice(0, file.path.indexOf("/scripts/")) : "";
8743
+ return pkgDir !== "" && tokenPath.startsWith(`${pkgDir}/`) && file.text.includes(tokenPath.slice(pkgDir.length + 1));
8744
+ };
8745
+ const mentioning = scriptFiles.filter(namesToken);
8746
+ const writers = mentioning.filter((f) => WRITES_FILE.test(f.text));
8747
+ const runs = (cmd, file) => cmd.includes(file);
8748
+ let generator;
8749
+ let generated = false;
8750
+ for (const [name, cmd] of scripts) {
8751
+ if (isCheck(name, cmd)) continue;
8752
+ if (cmd.includes(tokenPath) || writers.some((w) => runs(cmd, w.path))) {
8753
+ generated = true;
8754
+ generator ??= `${runner} ${name}`;
8755
+ }
8756
+ }
8757
+ if (writers.length) {
8758
+ generated = true;
8759
+ if (!generator) {
8760
+ const w = writers[0].path;
8761
+ generator = /\.(m?js|cjs)$/.test(w) ? `node ${w}` : w;
8762
+ }
8763
+ }
8764
+ const checked = scripts.some(([name, cmd]) => isCheck(name, cmd) && (cmd.includes(tokenPath) || mentioning.some((f) => runs(cmd, f.path))));
8765
+ if (!generated && !checked) return { role: "source" };
8766
+ return generator && generator.length <= 200 ? { role: "export", generator } : { role: "export" };
8767
+ }
8768
+ function starterManifest(root) {
8769
+ const tracked = listRepoFiles2(root).map((e) => e.path);
8770
+ const files = tracked.length ? tracked : walk(root);
8771
+ const fileSet = new Set(files);
8772
+ const pkg = readJson(join6(root, "package.json"));
8773
+ const deps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
8774
+ const rootScripts = Object.entries(pkg?.scripts ?? {}).filter((e) => typeof e[1] === "string");
8775
+ 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/"));
8776
+ const platforms = ["web", ...files.some((f) => f.startsWith("android/")) || "expo" in deps ? ["android"] : [], ...files.some((f) => f.startsWith("ios/")) || "expo" in deps ? ["ios"] : []];
8777
+ const archived = files.filter((f) => f.endsWith("/ARCHIVED.md")).map((f) => f.slice(0, -"/ARCHIVED.md".length));
8778
+ const isArchived = (f) => archived.some((d) => f.startsWith(`${d}/`));
8779
+ const live = (f) => !FIXTURE_DIR.test(f) && !isArchived(f);
8780
+ const tokenPaths = files.filter((f) => live(f) && (/\.tokens\.json$/.test(f) || /(^|\/)dtcg\/[^/]+\.json$/.test(f))).slice(0, 20);
8781
+ const workflows = files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$/.test(f));
8782
+ const hasSource = (dir, ext) => files.some((f) => f.startsWith(`${dir}/`) && live(f) && ext.test(f));
8783
+ 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");
8784
+ const rootDirs = rootIsApp ? ROOT_APP_DIRS.filter((d) => hasSource(d, /\.(ts|tsx|css)$/)) : [];
8785
+ const patterns = workspacePatterns(root, pkg);
8786
+ let packageRoots;
8787
+ if (patterns.length) {
8788
+ const include = patterns.filter((p3) => !p3.startsWith("!"));
8789
+ const exclude = patterns.filter((p3) => p3.startsWith("!")).map((p3) => p3.slice(1));
8790
+ const pkgDirs = [...new Set(files.filter((f) => f.endsWith("/package.json") && !f.includes("node_modules/")).map((f) => f.slice(0, -"/package.json".length)))];
8791
+ 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] : []);
8792
+ } else {
8793
+ packageRoots = [...new Set(files.filter((f) => live(f) && /^(apps|packages)\/[^/]+\/src\/.+\.(tsx|css)$/.test(f)).map((f) => f.split("/").slice(0, 3).join("/")))];
8794
+ }
8795
+ const scanRoots = [.../* @__PURE__ */ new Set([...rootDirs, ...packageRoots])].slice(0, MAX_LITERAL_GLOBS);
8796
+ const literalGlobs = scanRoots.length ? scanRoots.map((r) => `${r}/**/*.{ts,tsx,css}`) : ["src/**/*.{ts,tsx,css}", "app/**/*.{ts,tsx,css}"];
8797
+ const migrationsDir = files.find((f) => live(f) && /(^|\/)supabase\/migrations\/[^/]+\.sql$/.test(f))?.replace(/\/[^/]+$/, "");
8798
+ const tokens = tokenPaths.length ? (() => {
8799
+ const scriptFiles = files.filter((f) => live(f) && !f.includes("node_modules/") && /(^|\/)scripts\/.+\.(m?[jt]s|c[jt]s|py|sh)$/.test(f)).slice(0, MAX_SCRIPT_FILES).flatMap((path) => {
8800
+ const text2 = readCapped(root, path);
8801
+ return text2 === null ? [] : [{ path, text: text2 }];
8802
+ });
8803
+ const runner = scriptRunner(fileSet);
8804
+ return tokenPaths.map((path) => ({ path, ...tokenOrigin(path, rootScripts, scriptFiles, runner), format: "dtcg-2025.10" }));
8805
+ })() : [];
8806
+ const manifest = {
8807
+ version: 1,
8808
+ app: { name: pkg?.name ?? root.split(/[\\/]/).pop(), kind: native ? "app" : "site", platforms }
8809
+ };
8810
+ if (tokens.length) {
8811
+ manifest.design = { tokens, literalScan: { globs: literalGlobs, ignore: ["**/*.test.*"] } };
8812
+ }
8813
+ if (migrationsDir) manifest.data = { provider: "supabase", migrationsDir };
8814
+ if (workflows.length) {
8815
+ manifest.ci = {
8816
+ provider: "github-actions",
8817
+ defaultBranch: gitDefaultBranch(root),
8818
+ workflows: Object.fromEntries(workflows.map((w) => {
8819
+ const name = w.slice(".github/workflows/".length);
8820
+ const steps = (readCapped(root, w) ?? "").split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).join("\n");
8821
+ const deploys = /^(deploy|publish|release)/.test(name) || DEPLOY_STEPS.some((re) => re.test(steps));
8822
+ return [name, { role: deploys ? "deploy" : "ci" }];
8823
+ }))
8824
+ };
8825
+ }
8826
+ const envExample = ENV_EXAMPLES.find((p3) => existsSync3(join6(root, p3)));
8827
+ if (envExample) manifest.env = { environments: ["production"], required: [], example: envExample };
8828
+ const sourceDirs = tokens.filter((t) => t.role === "source").map((t) => `${t.path.split("/").slice(0, -1).join("/") || "."}/**`);
8829
+ manifest.change = { allowPaths: [MANIFEST, ...new Set(sourceDirs)] };
8830
+ return manifest;
8831
+ }
8832
+ function asStringList(v) {
8833
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
8834
+ }
8835
+ function isObject2(v) {
8836
+ return typeof v === "object" && v !== null && !Array.isArray(v);
8837
+ }
8838
+ function localReader(root) {
8839
+ return (path, maxBytes) => {
8840
+ const safe = normalizeRepoPath(path);
8841
+ if (!safe) return { kind: "absent" };
8842
+ return readTextFileCapped(join6(root, safe), maxBytes);
8843
+ };
8844
+ }
8845
+ function checkRecipe(root) {
8846
+ const issues = [];
8847
+ const files = {};
8848
+ const path = join6(root, MANIFEST);
8849
+ const fail = (message) => ({ ok: false, manifest: null, issues: [...issues, { severity: "error", message, path: MANIFEST }], tokenCount: 0, findings: [], design: null, files });
8850
+ const manifestRead = readTextFileCapped(path, MAX_MANIFEST_BYTES);
8851
+ if (manifestRead.kind === "absent") return fail(`No ${MANIFEST} at the repo root. Run \`mushi recipe init\` to write one.`);
8852
+ if (manifestRead.kind === "too_large") return fail(`${MANIFEST} is over 64 KB.`);
8853
+ const text2 = manifestRead.text;
8854
+ let parsed;
8855
+ try {
8856
+ parsed = JSON.parse(text2);
8857
+ } catch (err) {
8858
+ return fail(`${MANIFEST} is not valid JSON: ${err.message}`);
8859
+ }
8860
+ if (!isObject2(parsed) || parsed.version !== 1) return fail(`${MANIFEST} needs "version": 1.`);
8861
+ const manifest = parsed;
8862
+ files[MANIFEST] = text2;
8863
+ const change = isObject2(manifest.change) ? manifest.change : {};
8864
+ for (const p3 of asStringList(change.allowPaths)) {
8865
+ if (p3.startsWith(".github") || /(^|\/)(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|[^/]*\.lock)$/.test(p3)) {
8866
+ issues.push({ severity: "error", message: `change.allowPaths entry "${p3}" points at files Mushi never writes (workflows, lockfiles).`, path: MANIFEST });
8867
+ }
8868
+ if (/(^|\/)\.env(?!\.example)/.test(p3)) issues.push({ severity: "error", message: `change.allowPaths entry "${p3}" would let a PR touch env files.`, path: MANIFEST });
8869
+ }
8870
+ const shaped = readScanManifest(manifest);
8871
+ issues.push(...shaped.issues);
8872
+ const scanManifest = shaped.manifest;
8873
+ for (const t of scanManifest.design?.tokens ?? []) {
8874
+ if (!normalizeRepoPath(t.path)) issues.push({ severity: "error", message: `A design.tokens path is not a safe repo path: ${t.path}`, path: MANIFEST });
8875
+ }
8876
+ const tree = listRepoFiles2(root);
8877
+ const read = localReader(root);
8878
+ const { tokens, issues: tokenIssues } = readScanTokens(scanManifest, tree.map((e) => e.path), read);
8879
+ for (const i of tokenIssues) issues.push({ severity: i.severity, message: i.message, ...i.file ? { path: i.file } : {} });
8880
+ const judged = judgingSet(tokens);
8881
+ const tokenCount = judged?.tokens.length ?? 0;
8882
+ let pushedBytes = text2.length;
8883
+ const addPush = (p3) => {
8884
+ if (files[p3] !== void 0 || Object.keys(files).length >= PUSH_MAX_FILES) return;
8885
+ const f = read(p3, PUSH_MAX_FILE_BYTES);
8886
+ if (f.kind !== "file" || pushedBytes + f.text.length > PUSH_MAX_TOTAL_BYTES) return;
8887
+ files[p3] = f.text;
8888
+ pushedBytes += f.text.length;
8889
+ };
8890
+ for (const p3 of tokenFilePaths(tokens)) addPush(p3);
8891
+ const design = isObject2(manifest.design) ? manifest.design : {};
8892
+ for (const c of Array.isArray(design.css) ? design.css : []) {
8893
+ const rel = isObject2(c) && typeof c.path === "string" ? normalizeRepoPath(c.path) : null;
8894
+ if (rel) addPush(rel);
8895
+ }
8896
+ let findings = [];
8897
+ let designCheck = null;
8898
+ if (judged) {
8899
+ const pick = selectScanFiles(tree, scanManifest, [...tokenFilePaths(tokens), MANIFEST]);
8900
+ const texts = /* @__PURE__ */ new Map();
8901
+ for (const p3 of pick.files) {
8902
+ const f = read(p3, Number.POSITIVE_INFINITY);
8903
+ texts.set(p3, f.kind === "file" ? f.text : null);
8904
+ }
8905
+ const result = computeDeviance({ manifest: scanManifest, tokens }, texts);
8906
+ findings = result.findings;
8907
+ designCheck = {
8908
+ score: result.score,
8909
+ breakdown: result.breakdown,
8910
+ counts: result.counts,
8911
+ scannedFiles: result.scannedFiles,
8912
+ scannedLines: result.scannedLines,
8913
+ matchedFiles: pick.matched,
8914
+ truncated: pick.truncated,
8915
+ set: judged.name
8916
+ };
8917
+ } else if (scanManifest.design?.tokens?.length) {
8918
+ issues.push({ severity: "info", message: "The token files define no tokens, so there is nothing to judge the code against." });
8919
+ }
8920
+ return { ok: !issues.some((i) => i.severity === "error"), manifest, issues, tokenCount, findings, design: designCheck, files };
8921
+ }
8922
+ function pushPayload(check, maxFindings = 500) {
8923
+ if (!check.design) return null;
8924
+ const literal = check.findings.filter((f) => f.rule_id !== "contrast_below_aa" && f.file_path !== null && f.line !== null);
8925
+ const counts = { ...check.design.counts };
8926
+ delete counts.contrast_below_aa;
8927
+ return {
8928
+ engine: 1,
8929
+ scannedFiles: check.design.scannedFiles,
8930
+ scannedLines: check.design.scannedLines,
8931
+ matchedFiles: check.design.matchedFiles,
8932
+ truncated: check.design.truncated,
8933
+ counts,
8934
+ score: check.design.score,
8935
+ findings: literal.slice(0, maxFindings).map((f) => ({
8936
+ ruleId: f.rule_id,
8937
+ filePath: f.file_path,
8938
+ line: f.line,
8939
+ col: f.col,
8940
+ value: f.value.slice(0, 200),
8941
+ message: f.message.slice(0, 500),
8942
+ suggestion: f.suggestion
8943
+ }))
8944
+ };
8945
+ }
8946
+
8947
+ // src/recipe/report.ts
8948
+ var SHOWN_FINDINGS = 50;
8949
+ function describeCheck(result) {
8950
+ const d = result.design;
8951
+ const scope = d ? `${d.scannedFiles} files scanned${d.truncated ? " (capped)" : ""}` : "no scan";
8952
+ const score = d ? d.score === null ? ", not scored (no rule had anything to judge)" : `, deviance score ${d.score}/100` : "";
8953
+ const lines = [`${result.ok ? "OK" : "FAIL"} mushi.recipe.json: ${result.tokenCount} tokens${d?.set ? ` (set ${d.set})` : ""}, ${scope}${score}.`];
8954
+ for (const i of result.issues) lines.push(` ${i.severity.toUpperCase().padEnd(5)} ${i.message}`);
8955
+ for (const f of result.findings.slice(0, SHOWN_FINDINGS)) {
8956
+ const at = f.file_path ? `${f.file_path}${f.line ? `:${f.line}${f.col ? `:${f.col}` : ""}` : ""}` : "tokens";
8957
+ const use = f.suggestion ? ` \u2192 ${f.suggestion.cssVar ?? f.suggestion.ts ?? f.suggestion.token}` : "";
8958
+ lines.push(` ${f.severity.toUpperCase().padEnd(5)} ${at} ${f.message}${use} [${f.rule_id}]`);
8959
+ }
8960
+ if (result.findings.length > SHOWN_FINDINGS) lines.push(` \u2026 and ${result.findings.length - SHOWN_FINDINGS} more`);
8961
+ return lines;
8962
+ }
8963
+ function localLimitExceeded(score, maxScore) {
8964
+ return maxScore !== null && score !== null && score > maxScore;
8965
+ }
8966
+ function pushVerdict(answer, maxScore, localScore) {
8967
+ const lines = [`Sent to Mushi: ${answer.reason}`];
8968
+ const errors = [];
8969
+ let failed = localLimitExceeded(localScore, maxScore);
8970
+ const d = answer.deviance;
8971
+ if (!d) {
8972
+ if (localScore !== null) lines.push("Mushi stored the recipe but did not score the scan (it may be older than this CLI).");
8973
+ return { lines, errors, failed };
8974
+ }
8975
+ if (d.status === "skipped") lines.push(`Not scored: ${d.reason ?? "no design tokens to judge against."}`);
8976
+ else if (d.status === "error") {
8977
+ errors.push(`Mushi could not store the scan: ${d.reason ?? "unknown error"}`);
8978
+ if (d.gate?.enabled) {
8979
+ errors.push("Failing the step: this project's deviance gate is on, and an unstored scan cannot be checked against it.");
8980
+ failed = true;
8981
+ }
8982
+ } else {
8983
+ const refused = d.droppedFindings > 0 ? `, ${d.droppedFindings} refused` : "";
8984
+ lines.push(`Mushi scored ${d.score === null ? "nothing (no rule had anything to judge)" : `${d.score}/100`}: ${d.storedFindings} findings stored${refused}.`);
8985
+ }
8986
+ if (d.clientScore !== null && d.clientScore !== void 0) {
8987
+ errors.push(`This CLI scored ${d.clientScore}, Mushi scored ${d.score}. Update @mushi-mushi/cli so both run the same rules.`);
8988
+ }
8989
+ if (d.gate?.enabled) {
8990
+ if (d.gate.exceeded) {
8991
+ errors.push(`Deviance score ${d.score} is above this project's limit of ${d.gate.failAbove} (console: Design system \u2192 When the score is too high).`);
8992
+ failed = true;
8993
+ } else {
8994
+ lines.push(`Within the project's limit of ${d.gate.failAbove}.`);
8995
+ }
8996
+ }
8997
+ if (d.reason && d.status !== "skipped" && d.status !== "error") errors.push(d.reason);
8998
+ const a = d.action;
8999
+ if (a?.action === "dispatched") lines.push(`Mushi dispatched a fix for ${a.newFindings ?? "the"} new finding${a.newFindings === 1 ? "" : "s"} (report ${a.reportId}).`);
9000
+ else if (a?.action === "dispatch_refused") lines.push(`Design auto-fix did not dispatch: ${a.message ?? a.code ?? "refused"}.`);
9001
+ else if (a?.action === "autofix_disabled") lines.push("Design auto-fix is on, but Autofix is off for this project, so no fix was dispatched.");
9002
+ else if (a?.action === "key_not_trusted") lines.push("Design auto-fix skipped for this push: the key is treated as public (see above).");
9003
+ else if (a?.action === "settings_unavailable" || a?.action === "report_failed") errors.push(`Design auto-fix failed: ${a.error ?? a.action}.`);
9004
+ return { lines, errors, failed };
9005
+ }
9006
+
9007
+ // src/commands/recipe.ts
9008
+ var SOURCE_ELEMENTS = ["gates", "env", "routes"];
9009
+ function headSha2(dir) {
9010
+ const env = process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? null;
9011
+ if (env) return env;
9012
+ try {
9013
+ return execFileSync3("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
9014
+ } catch {
9015
+ return null;
9016
+ }
9017
+ }
9018
+ function headBranch(dir) {
9019
+ const env = process.env.GITHUB_REF_NAME ?? process.env.CI_COMMIT_BRANCH ?? null;
9020
+ if (env) return env;
9021
+ try {
9022
+ return execFileSync3("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
9023
+ } catch {
9024
+ return "main";
9025
+ }
9026
+ }
9027
+ function registerRecipeCommands(program2) {
9028
+ const recipe = program2.command("recipe").description("Your app's recipe: design tokens, routes, CI, deploy and env, so a fix uses your own system");
9029
+ recipe.command("init").description(`Write a starter ${MANIFEST} from what this repo shows`).option("--dir <path>", "Repo root", ".").option("--force", `Overwrite an existing ${MANIFEST}`).action((opts) => {
9030
+ const root = resolve5(opts.dir);
9031
+ const target = join7(root, MANIFEST);
9032
+ try {
9033
+ writeFileSync3(target, `${JSON.stringify(starterManifest(root), null, 2)}
9034
+ `, { flag: opts.force ? "w" : "wx" });
9035
+ } catch (err) {
9036
+ if (err.code !== "EEXIST") throw err;
9037
+ process.stderr.write(`${MANIFEST} already exists. Use --force to overwrite it.
9038
+ `);
9039
+ process.exitCode = 1;
9040
+ return;
9041
+ }
9042
+ console.log(`Wrote ${MANIFEST}. Check the guesses, then run \`mushi recipe check\`.`);
9043
+ });
9044
+ recipe.command("check").description(`Validate ${MANIFEST} and its token files, and score how far the code is from your design system (the same rules Mushi's scan uses)`).option("--dir <path>", "Repo root", ".").option("--push", "Send the recipe and the scan to Mushi (one extra step in your existing CI job); fails when the score is above the project's limit").option("--max-score <n>", "Also fail when the deviance score (0\u2013100) is above n, without asking Mushi").option("--json", "Machine-readable JSON output").action(async (opts) => {
9045
+ const root = resolve5(opts.dir);
9046
+ const maxScore = opts.maxScore === void 0 ? null : Number(opts.maxScore);
9047
+ if (maxScore !== null && (!Number.isInteger(maxScore) || maxScore < 0 || maxScore > 100)) {
9048
+ process.stderr.write("error: --max-score must be a whole number from 0 to 100.\n");
9049
+ process.exitCode = 1;
9050
+ return;
9051
+ }
9052
+ const result = checkRecipe(root);
9053
+ const json2 = outputIsJson(opts.json);
9054
+ const local = { ok: result.ok, issues: result.issues, tokenCount: result.tokenCount, design: result.design, findings: result.findings };
9055
+ if (json2 && !opts.push) console.log(JSON.stringify(local, null, 2));
9056
+ else if (!json2) for (const line of describeCheck(result)) console.log(line);
9057
+ if (!result.ok) {
9058
+ process.exitCode = 1;
9059
+ return;
9060
+ }
9061
+ const overLocal = localLimitExceeded(result.design?.score ?? null, maxScore);
9062
+ if (overLocal) process.stderr.write(`Deviance score ${result.design?.score} is above --max-score ${maxScore}.
9063
+ `);
9064
+ if (!opts.push) {
9065
+ if (overLocal) process.exitCode = 1;
9066
+ return;
9067
+ }
9068
+ const sha = headSha2(root);
9069
+ if (!sha || !/^[0-9a-f]{7,64}$/i.test(sha)) {
9070
+ process.stderr.write("error: could not read the commit SHA (set GITHUB_SHA or run inside a git checkout).\n");
9071
+ process.exitCode = 1;
9072
+ return;
9073
+ }
9074
+ const config = requireConfig();
9075
+ const deviance = pushPayload(result);
9076
+ const res = await apiCall("/v1/ingest/recipe", config, {
9077
+ method: "POST",
9078
+ body: JSON.stringify({ commitSha: sha, branch: headBranch(root), files: result.files, ...deviance ? { deviance } : {} })
9079
+ });
9080
+ if (!res.ok) die(res);
9081
+ const verdict = pushVerdict(res.data, maxScore, result.design?.score ?? null);
9082
+ if (json2) console.log(JSON.stringify({ ...res.data, local, failed: verdict.failed }, null, 2));
9083
+ else for (const line of verdict.lines) console.log(line);
9084
+ for (const line of verdict.errors) process.stderr.write(`${line}
9085
+ `);
9086
+ if (verdict.failed) process.exitCode = 1;
9087
+ });
9088
+ recipe.command("show").description("Show this project's recipe: each part and its state").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
9089
+ const config = requireConfig({ needsProject: !opts.projectId });
9090
+ const projectId = requireUuid(opts.projectId ?? config.projectId, "project id");
9091
+ const res = await apiCall(`/v1/admin/projects/${projectId}/recipe`, config);
9092
+ if (!res.ok) die(res);
9093
+ if (outputIsJson(opts.json)) {
9094
+ console.log(JSON.stringify(res.data, null, 2));
9095
+ return;
9096
+ }
9097
+ console.log(`Worst: ${res.data.worst}`);
9098
+ for (const e of Object.values(res.data.elements)) console.log(` ${e.state.padEnd(14)} ${e.label} \u2014 ${e.reason}`);
9099
+ });
9100
+ recipe.command("sources").description("The repo files a recipe change may edit for one element, with the sha to send back as baseSha").requiredOption("--element <element>", SOURCE_ELEMENTS.join(" | ")).option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output (includes each file's content)").action(async (opts) => {
9101
+ if (!SOURCE_ELEMENTS.includes(opts.element)) {
9102
+ throw new MushiCliError("E_INVALID_INPUT", `--element must be one of ${SOURCE_ELEMENTS.join(", ")}`);
9103
+ }
9104
+ const config = requireConfig({ needsProject: !opts.projectId });
9105
+ const projectId = requireUuid(opts.projectId ?? config.projectId, "project id");
9106
+ const res = await apiCall(`/v1/admin/projects/${projectId}/recipe/sources?element=${opts.element}`, config);
9107
+ if (!res.ok) die(res);
9108
+ if (outputIsJson(opts.json)) {
9109
+ console.log(JSON.stringify(res.data, null, 2));
9110
+ return;
9111
+ }
9112
+ if (!res.data.ok) {
9113
+ console.log(`Nothing editable: ${res.data.reason ?? "unknown reason"}`);
9114
+ return;
9115
+ }
9116
+ console.log(`${res.data.element} on ${res.data.branch ?? "?"} @ ${(res.data.headSha ?? "").slice(0, 7)}`);
9117
+ for (const f of res.data.files) {
9118
+ const state = !f.exists ? "new file" : f.content === null ? "not shown" : `${f.content.length} chars, sha ${(f.sha ?? "").slice(0, 7)}`;
9119
+ console.log(` ${f.writable ? "writable" : "locked "} ${f.path} (${state})${f.reason ? ` \u2014 ${f.reason}` : ""}`);
9120
+ }
9121
+ });
9122
+ recipe.command("change <jobId>").description("One recipe change job: queued, running, pr_opened, rejected or failed").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (jobId, opts) => {
9123
+ const id = requireUuid(jobId, "job id");
9124
+ const config = requireConfig({ needsProject: !opts.projectId });
9125
+ const projectId = requireUuid(opts.projectId ?? config.projectId, "project id");
9126
+ const res = await apiCall(`/v1/admin/projects/${projectId}/recipe/changes/${id}`, config);
9127
+ if (!res.ok) die(res);
9128
+ if (outputIsJson(opts.json)) {
9129
+ console.log(JSON.stringify(res.data, null, 2));
9130
+ return;
9131
+ }
9132
+ const j = res.data;
9133
+ console.log(`${j.element} change ${j.id}: ${j.status} (started ${fmtDate(j.created_at)}${j.finished_at ? `, finished ${fmtDate(j.finished_at)}` : ""})`);
9134
+ if (j.pr_url) console.log(` Draft PR: ${j.pr_url}`);
9135
+ if (j.error) console.log(` ${j.error}`);
9136
+ });
9137
+ }
9138
+
9139
+ // src/commands/store.ts
9140
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
9141
+ import { join as join9, resolve as resolve6 } from "path";
9142
+
9143
+ // src/store/pull.ts
9144
+ import { createSign, sign as cryptoSign } from "crypto";
9145
+ import { mkdirSync, writeFileSync as writeFileSync4 } from "fs";
9146
+ import { dirname as dirname2, join as join8 } from "path";
9147
+ function b64url(buf) {
9148
+ return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9149
+ }
9150
+ function ascJwt(keyId, issuerId, privateKeyPem, nowSec) {
9151
+ const head = b64url(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
9152
+ const body = b64url(JSON.stringify({ iss: issuerId, iat: nowSec, exp: nowSec + 1200, aud: "appstoreconnect-v1" }));
9153
+ const sig = cryptoSign("sha256", Buffer.from(`${head}.${body}`), { key: privateKeyPem, dsaEncoding: "ieee-p1363" });
9154
+ return `${head}.${body}.${b64url(sig)}`;
9155
+ }
9156
+ function googleAssertion(clientEmail, privateKeyPem, nowSec) {
9157
+ const head = b64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
9158
+ const body = b64url(JSON.stringify({ iss: clientEmail, scope: "https://www.googleapis.com/auth/androidpublisher", aud: "https://oauth2.googleapis.com/token", iat: nowSec, exp: nowSec + 3600 }));
9159
+ const signer = createSign("RSA-SHA256");
9160
+ signer.update(`${head}.${body}`);
9161
+ return `${head}.${body}.${b64url(signer.sign(privateKeyPem))}`;
9162
+ }
9163
+ async function json(fetchImpl, url, init) {
9164
+ const res = await fetchImpl(url, init);
9165
+ const body = await res.json().catch(() => null);
9166
+ if (!res.ok) {
9167
+ const detail = body?.errors?.[0]?.detail ?? body?.error?.message ?? "";
9168
+ throw new Error(`${new URL(url).host} answered ${res.status}${detail ? `: ${detail}` : ""}`);
9169
+ }
9170
+ return body;
9171
+ }
9172
+ var IOS_FILES = { name: "name.txt", subtitle: "subtitle.txt", privacyPolicyUrl: "privacy_url.txt", description: "description.txt", keywords: "keywords.txt", promotionalText: "promotional_text.txt", whatsNew: "release_notes.txt" };
9173
+ var ANDROID_FILES = { title: "title.txt", shortDescription: "short_description.txt", fullDescription: "full_description.txt", video: "video.txt" };
9174
+ async function pullAppStore(fetchImpl, appId, key, nowSec) {
9175
+ const auth = { headers: { Authorization: `Bearer ${ascJwt(key.keyId, key.issuerId, key.privateKey, nowSec)}` } };
9176
+ const base = "https://api.appstoreconnect.apple.com/v1";
9177
+ const out = {};
9178
+ const infos = await json(fetchImpl, `${base}/apps/${appId}/appInfos`, auth);
9179
+ const isLive = (a) => a?.state === "READY_FOR_DISTRIBUTION" || a?.appStoreState === "READY_FOR_SALE";
9180
+ const info = (infos.data ?? []).find((i) => isLive(i.attributes)) ?? infos.data?.[0];
9181
+ if (info) {
9182
+ const locs = await json(fetchImpl, `${base}/appInfos/${info.id}/appInfoLocalizations`, auth);
9183
+ for (const l of locs.data ?? []) {
9184
+ const a = l.attributes ?? {};
9185
+ for (const k of ["name", "subtitle", "privacyPolicyUrl"]) if (typeof a[k] === "string") out[`${a.locale}/${IOS_FILES[k]}`] = a[k];
9186
+ }
9187
+ }
9188
+ const versions = await json(fetchImpl, `${base}/apps/${appId}/appStoreVersions?filter[appVersionState]=READY_FOR_DISTRIBUTION&limit=1`, auth);
9189
+ const version = versions.data?.[0];
9190
+ if (version) {
9191
+ const locs = await json(fetchImpl, `${base}/appStoreVersions/${version.id}/appStoreVersionLocalizations`, auth);
9192
+ for (const l of locs.data ?? []) {
9193
+ const a = l.attributes ?? {};
9194
+ for (const k of ["description", "keywords", "promotionalText", "whatsNew"]) if (typeof a[k] === "string") out[`${a.locale}/${IOS_FILES[k]}`] = a[k];
9195
+ }
9196
+ }
9197
+ return out;
9198
+ }
9199
+ async function pullPlay(fetchImpl, pkg, sa, nowSec) {
9200
+ const tokenBody = `grant_type=${encodeURIComponent("urn:ietf:params:oauth:grant-type:jwt-bearer")}&assertion=${encodeURIComponent(googleAssertion(sa.client_email, sa.private_key, nowSec))}`;
9201
+ const token = await json(fetchImpl, "https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: tokenBody });
9202
+ const auth = { Authorization: `Bearer ${token.access_token}` };
9203
+ const base = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${pkg}`;
9204
+ const edit = await json(fetchImpl, `${base}/edits`, { method: "POST", headers: auth });
9205
+ const out = {};
9206
+ try {
9207
+ const listings = await json(fetchImpl, `${base}/edits/${edit.id}/listings`, { headers: auth });
9208
+ for (const l of listings.listings ?? []) {
9209
+ for (const k of Object.keys(ANDROID_FILES)) if (typeof l[k] === "string" && l[k]) out[`android/${l.language}/${ANDROID_FILES[k]}`] = l[k];
9210
+ }
9211
+ } finally {
9212
+ await fetchImpl(`${base}/edits/${edit.id}`, { method: "DELETE", headers: auth }).catch(() => void 0);
9213
+ }
9214
+ return out;
9215
+ }
9216
+ function writeListing(root, listingDir, files) {
9217
+ const written = [];
9218
+ for (const [rel, text2] of Object.entries(files)) {
9219
+ if (rel.includes("..")) continue;
9220
+ const path = join8(root, listingDir, rel);
9221
+ mkdirSync(dirname2(path), { recursive: true });
9222
+ writeFileSync4(path, text2.endsWith("\n") ? text2 : `${text2}
9223
+ `);
9224
+ written.push(`${listingDir}/${rel}`);
9225
+ }
9226
+ return written.sort();
9227
+ }
9228
+
9229
+ // src/commands/store.ts
9230
+ var PULL_TIMEOUT_MS = 9e4;
9231
+ function renderStoreReviews(d) {
9232
+ const s = d.settings;
9233
+ const lines = [`Store reviews as reports: ${s.enabled ? "on" : "off"} \xB7 files reviews of ${s.maxRating} star(s) or fewer`];
9234
+ lines.push(` Last pull: ${s.lastPulledAt ? `${fmtDate(s.lastPulledAt)} (${s.lastStatus ?? "?"})` : "never"}${s.lastError ? ` \u2014 ${oneLine(s.lastError, 100)}` : ""}`);
9235
+ if (d.sources.length === 0) lines.push(" No App Store or Google Play connector is bound to this app.");
9236
+ for (const src of d.sources) lines.push(` ${src.store.padEnd(10)} ${oneLine(src.appId, 50)} ${src.connected ? "key stored" : "key unreadable"}`);
9237
+ if (d.recent.length > 0) {
9238
+ lines.push("Recently seen:");
9239
+ for (const r of d.recent) lines.push(` ${r.store.padEnd(10)} ${String(r.rating ?? "?")}\u2605 ${r.reportId ? `report ${r.reportId}` : "not filed"} ${fmtDate(r.seenAt)}`);
9240
+ }
9241
+ if (!s.enabled && d.canManage) lines.push("Turn it on in the console (Recipe \u2192 Store reviews as reports).");
9242
+ return lines;
9243
+ }
9244
+ function readManifestStore(root) {
9245
+ const path = join9(root, "mushi.recipe.json");
9246
+ if (!existsSync4(path)) return null;
9247
+ try {
9248
+ const m = JSON.parse(readFileSync5(path, "utf8"));
9249
+ return {
9250
+ listingDir: (m.store?.listingDir ?? "fastlane/metadata").replace(/\/+$/, ""),
9251
+ appleId: m.store?.ios?.appleId ?? null,
9252
+ androidPackage: m.store?.android?.package ?? null
9253
+ };
9254
+ } catch {
9255
+ return null;
9256
+ }
9257
+ }
9258
+ function registerStoreCommands(program2) {
9259
+ const store = program2.command("store").description("Store listings as code");
9260
+ store.command("pull").description("Copy the live App Store and Google Play listings into the repo (fastlane metadata), using your own keys").option("--dir <path>", "Repo root", ".").option("--asc-key-id <id>", "App Store Connect key id (or MUSHI_ASC_KEY_ID)").option("--asc-issuer-id <id>", "App Store Connect issuer id (or MUSHI_ASC_ISSUER_ID)").option("--asc-key-path <path>", "Path to the .p8 key (or MUSHI_ASC_KEY_PATH)").option("--play-key-path <path>", "Path to the Google service account JSON (or GOOGLE_APPLICATION_CREDENTIALS)").addHelpText("after", `
9261
+ Runs on your machine with your own store keys. Nothing is sent to Mushi, and
9262
+ Mushi never holds a key that can publish. After this, change the listing in
9263
+ the repo; your own CI (fastlane deliver / supply, or EAS) publishes it.
9264
+ Reads store.listingDir, store.ios.appleId and store.android.package from
9265
+ mushi.recipe.json.`).action(async (opts) => {
9266
+ const root = resolve6(opts.dir);
9267
+ const cfg = readManifestStore(root);
9268
+ if (!cfg) {
9269
+ process.stderr.write("error: no readable mushi.recipe.json. Run `mushi recipe init` first.\n");
9270
+ process.exitCode = 1;
9271
+ return;
9272
+ }
9273
+ const now = Math.floor(Date.now() / 1e3);
9274
+ const files = {};
9275
+ const keyId = opts.ascKeyId ?? process.env.MUSHI_ASC_KEY_ID;
9276
+ const issuerId = opts.ascIssuerId ?? process.env.MUSHI_ASC_ISSUER_ID;
9277
+ const keyPath = opts.ascKeyPath ?? process.env.MUSHI_ASC_KEY_PATH;
9278
+ if (cfg.appleId && keyId && issuerId && keyPath) {
9279
+ try {
9280
+ const ios = await pullAppStore(fetch, cfg.appleId, { keyId, issuerId, privateKey: readFileSync5(keyPath, "utf8") }, now);
9281
+ if (!Object.keys(ios).some((k) => k.endsWith("/description.txt"))) console.log("App Store: no version is live yet, so only the app name, subtitle and privacy link were pulled.");
9282
+ Object.assign(files, ios);
9283
+ } catch (err) {
9284
+ process.stderr.write(`App Store: ${err.message}
9285
+ `);
9286
+ process.exitCode = 1;
9287
+ }
9288
+ } else if (cfg.appleId) {
9289
+ console.log("App Store skipped: set the key id, issuer id and .p8 path.");
9290
+ }
9291
+ const playPath = opts.playKeyPath ?? process.env.GOOGLE_APPLICATION_CREDENTIALS;
9292
+ if (cfg.androidPackage && playPath) {
9293
+ try {
9294
+ Object.assign(files, await pullPlay(fetch, cfg.androidPackage, JSON.parse(readFileSync5(playPath, "utf8")), now));
9295
+ } catch (err) {
9296
+ process.stderr.write(`Google Play: ${err.message}
9297
+ `);
9298
+ process.exitCode = 1;
9299
+ }
9300
+ } else if (cfg.androidPackage) {
9301
+ console.log("Google Play skipped: set the service account path.");
9302
+ }
9303
+ const written = writeListing(root, cfg.listingDir, files);
9304
+ console.log(written.length ? `Wrote ${written.length} files under ${cfg.listingDir}/. Review and commit them.` : "Nothing was pulled.");
9305
+ });
9306
+ store.command("reviews").description("Store reviews as reports: whether it is on, the bound stores, and the reviews seen lately").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
9307
+ const config = requireConfig();
9308
+ const pid = resolveProjectId(opts.projectId, config.projectId);
9309
+ const result = await apiCall(`/v1/admin/projects/${pid}/store/reviews`, config);
9310
+ if (!result.ok) die(result);
9311
+ if (outputIsJson(opts.json)) {
9312
+ console.log(JSON.stringify(result.data, null, 2));
9313
+ return;
9314
+ }
9315
+ for (const line of renderStoreReviews(result.data)) console.log(line);
9316
+ });
9317
+ store.command("reviews-pull").description("Pull the latest reviews now and file the low-star ones as reports (once per 10 minutes)").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
9318
+ const config = requireConfig();
9319
+ const pid = resolveProjectId(opts.projectId, config.projectId);
9320
+ const result = await apiCall(`/v1/admin/projects/${pid}/store/reviews/pull`, config, { method: "POST" }, { timeoutMs: PULL_TIMEOUT_MS });
9321
+ if (!result.ok) die(result);
9322
+ if (outputIsJson(opts.json)) {
9323
+ console.log(JSON.stringify(result.data, null, 2));
9324
+ return;
9325
+ }
9326
+ console.log(`Pull ${result.data.status}: filed ${result.data.filed} review(s) as reports.`);
9327
+ for (const line of result.data.stores) console.log(` ${line.store.padEnd(10)} ${line.status.padEnd(13)} fetched ${line.fetched}, new ${line.newReviews}, filed ${line.filed}${line.detail ? ` \u2014 ${oneLine(line.detail, 100)}` : ""}`);
9328
+ });
9329
+ store.command("reviews-set").description("Turn store review intake off, or change the star threshold while it is on (owners and admins)").option("--off", "Stop filing store reviews as reports").option("--max-rating <n>", "File reviews at or under this many stars (1-5)").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").addHelpText("after", `
9330
+ Turning intake on decides that public reviews become reports, so it needs a
9331
+ signed-in owner or admin in the console (Recipe \u2192 Store reviews as reports).`).action(async (opts) => {
9332
+ let maxRating;
9333
+ if (opts.maxRating !== void 0) {
9334
+ maxRating = Number(opts.maxRating);
9335
+ if (!Number.isInteger(maxRating) || maxRating < 1 || maxRating > 5) throw new MushiCliError("E_INVALID_INPUT", "--max-rating must be a whole number from 1 to 5");
9336
+ }
9337
+ if (!opts.off && maxRating === void 0) throw new MushiCliError("E_INVALID_INPUT", "Nothing to change: pass --off or --max-rating.");
9338
+ const config = requireConfig();
9339
+ const pid = resolveProjectId(opts.projectId, config.projectId);
9340
+ const body = { enabled: !opts.off, ...maxRating !== void 0 ? { maxRating } : {} };
9341
+ const result = await apiCall(`/v1/admin/projects/${pid}/store/reviews/settings`, config, { method: "PUT", body: JSON.stringify(body) });
9342
+ if (!result.ok) die(result);
9343
+ if (outputIsJson(opts.json)) {
9344
+ console.log(JSON.stringify(result.data, null, 2));
9345
+ return;
9346
+ }
9347
+ console.log(`Store reviews as reports: ${result.data.enabled ? "on" : "off"} \xB7 files reviews of ${result.data.maxRating} star(s) or fewer.`);
9348
+ });
9349
+ }
9350
+
9351
+ // src/commands/portfolio.ts
9352
+ var MAX_RESOURCE_CSV_BYTES = 256 * 1024;
9353
+ function renderPortfolioResources(data) {
9354
+ if (data.resources.length === 0) {
9355
+ return ["No shared resources recorded yet. They come from each app's recipe, or a CSV upload in the console."];
9356
+ }
9357
+ const lines = ["Shared resources (one account, domain or bucket used by several apps):"];
9358
+ for (const r of data.resources) {
9359
+ lines.push(` ${r.kind.padEnd(14)} ${oneLine(r.externalId, 50)} used by ${r.uses.length} app(s)`);
9360
+ for (const u of r.uses) lines.push(` ${u.projectId} ${oneLine(u.role, 30)} (${u.source})`);
9361
+ }
9362
+ if (data.findings.length > 0) {
9363
+ lines.push("Open findings on shared resources:");
9364
+ for (const f of data.findings) lines.push(` ${f.severity.toUpperCase().padEnd(5)} ${f.rule_id} \u2014 ${oneLine(f.message, 100)}`);
9365
+ }
9366
+ return lines;
9367
+ }
9368
+ var ORG_HELP = "--org <id>";
9369
+ var ORG_DESC = "Organization UUID (default: your only organization)";
9370
+ function sdkLabel(card) {
9371
+ const behind = card.sdk.filter((s) => s.status === "behind");
9372
+ if (behind.length > 0) return `SDK behind (${behind.map((s) => `${s.package} ${s.version}\u2192${s.latest}`).join(", ")})`;
9373
+ if (card.sdk.some((s) => s.status === "current")) return "SDK current";
9374
+ return "SDK unknown";
9375
+ }
9376
+ function renderPortfolio(data) {
9377
+ const lines = [];
9378
+ const pages = Math.max(1, Math.ceil(data.totalProjects / Math.max(1, data.pageSize)));
9379
+ lines.push(`${data.organizationName ?? data.organizationId} \u2014 ${data.totalProjects} app(s), page ${data.page}/${pages}`);
9380
+ if (data.cards.length === 0) lines.push(" No apps in this team yet.");
9381
+ for (const c of data.cards) {
9382
+ const radar = c.radar.status === "never_run" ? "holes not checked yet" : `holes ${c.radar.open.error} error / ${c.radar.open.warn} warn`;
9383
+ const release = c.latestRelease ? `last release ${c.latestRelease.version}` : "no release yet";
9384
+ lines.push(` ${c.worst.toUpperCase().padEnd(8)} ${oneLine(c.name, 40)} (${c.projectId})`);
9385
+ lines.push(` ${c.openReports} open report(s) \xB7 ${radar} \xB7 ${sdkLabel(c)} \xB7 ${release}`);
9386
+ if (c.error) lines.push(` recipe could not be read: ${oneLine(c.error)}`);
9387
+ }
9388
+ lines.push(`Repeated problems: ${data.repeatedGroups} \xB7 integration holes: ${data.holes} (mushi portfolio findings)`);
9389
+ return lines;
9390
+ }
9391
+ function renderPortfolioFindings(data) {
9392
+ const lines = [];
9393
+ if (data.groups.length === 0 && data.sdkSkew.length === 0 && data.holes.length === 0 && data.crossProject.length === 0) {
9394
+ return ["No repeated problems across your apps."];
9395
+ }
9396
+ if (data.groups.length > 0) {
9397
+ lines.push("Same problem in several apps:");
9398
+ for (const g of data.groups) {
9399
+ lines.push(` ${g.severity.toUpperCase().padEnd(5)} ${g.ruleId} (${g.gate}) \u2014 ${g.findingCount} finding(s) in ${g.projectIds.length} app(s)`);
9400
+ lines.push(` ${oneLine(g.sampleMessage, 120)}`);
9401
+ }
9402
+ }
9403
+ const behind = data.sdkSkew.filter((s) => s.status === "behind");
9404
+ if (behind.length > 0) {
9405
+ lines.push("SDK behind latest:");
9406
+ for (const s of behind) lines.push(` ${s.projectId} ${s.package} ${s.version} \u2192 ${s.latest}`);
9407
+ }
9408
+ if (data.holes.length > 0) {
9409
+ lines.push("Integration holes (siblings have it, this app does not):");
9410
+ for (const h of data.holes) lines.push(` ${h.projectId} ${h.integration} \u2014 ${oneLine(h.reason, 100)}`);
9411
+ }
9412
+ if (data.crossProject.length > 0) {
9413
+ lines.push("Cross-app findings:");
9414
+ for (const f of data.crossProject) lines.push(` ${(f.severity ?? "info").toUpperCase().padEnd(5)} ${f.ruleId} \u2014 ${oneLine(f.message, 100)}`);
9415
+ }
9416
+ return lines;
9417
+ }
9418
+ function registerPortfolioCommands(program2) {
9419
+ const portfolio = program2.command("portfolio").description("Every app in your team on one screen (needs an account-level key)");
9420
+ portfolio.command("show").description("Health, open reports, holes, SDK version and last release for each app").option(ORG_HELP, ORG_DESC).option("--page <n>", "Page of apps (25 per page)", "1").option("--json", "Machine-readable JSON output").action(async (opts) => {
9421
+ const config = requireConfig();
9422
+ const page = Math.max(1, Number.parseInt(opts.page, 10) || 1);
9423
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/portfolio?page=${page}`, config);
9424
+ if (!result.ok) dieOrgError(result);
9425
+ if (outputIsJson(opts.json)) {
9426
+ console.log(JSON.stringify(result.data, null, 2));
9427
+ return;
9428
+ }
9429
+ for (const line of renderPortfolio(result.data)) console.log(line);
9430
+ });
9431
+ portfolio.command("findings").description("Problems repeated across your apps, SDK skew and integration holes").option(ORG_HELP, ORG_DESC).option("--json", "Machine-readable JSON output").action(async (opts) => {
9432
+ const config = requireConfig();
9433
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/portfolio/findings`, config);
9434
+ if (!result.ok) dieOrgError(result);
9435
+ if (outputIsJson(opts.json)) {
9436
+ console.log(JSON.stringify(result.data, null, 2));
9437
+ return;
9438
+ }
9439
+ for (const line of renderPortfolioFindings(result.data)) console.log(line);
9440
+ });
9441
+ portfolio.command("resources").description("Accounts, domains and buckets shared between your apps, and their open findings").option(ORG_HELP, ORG_DESC).option("--json", "Machine-readable JSON output").action(async (opts) => {
9442
+ const config = requireConfig();
9443
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/portfolio/resources`, config);
9444
+ if (!result.ok) dieOrgError(result);
9445
+ if (outputIsJson(opts.json)) {
9446
+ console.log(JSON.stringify(result.data, null, 2));
9447
+ return;
9448
+ }
9449
+ for (const line of renderPortfolioResources(result.data)) console.log(line);
9450
+ });
9451
+ portfolio.command("import <file>").description("Record shared resources from a CSV: kind,external_id,project[,role] (team owners and admins)").option(ORG_HELP, ORG_DESC).option("--json", "Machine-readable JSON output").action(async (file, opts) => {
9452
+ const read = readTextFileCapped(file, MAX_RESOURCE_CSV_BYTES);
9453
+ if (read.kind === "absent") throw new MushiCliError("E_INVALID_INPUT", `Cannot read ${file}.`);
9454
+ if (read.kind === "too_large") throw new MushiCliError("E_INVALID_INPUT", `${file} is over 256 KB.`, "split it into several files");
9455
+ const csv = read.text;
9456
+ const organizationId = opts.org && opts.org !== "current" ? requireUuid(opts.org, "organization id") : "current";
9457
+ const config = requireConfig();
9458
+ const result = await apiCall(
9459
+ "/v1/ingest/recipe/csv",
9460
+ config,
9461
+ { method: "POST", body: JSON.stringify({ organizationId, csv }) }
9462
+ );
9463
+ if (!result.ok) dieOrgError(result);
9464
+ const r = result.data;
9465
+ if (outputIsJson(opts.json)) {
9466
+ console.log(JSON.stringify(r, null, 2));
9467
+ return;
9468
+ }
9469
+ console.log(`Imported ${r.imported} row(s); ${r.errorCount} refused.`);
9470
+ for (const e of r.errors) console.log(` ${oneLine(e, 120)}`);
9471
+ if (r.errorCount > r.errors.length) console.log(` \u2026 ${r.errorCount - r.errors.length} more`);
9472
+ if (r.skippedOverLimit > 0) console.log(` ${r.skippedOverLimit} row(s) past the 500-row limit were not read; import them in a second file.`);
9473
+ });
9474
+ }
9475
+
9476
+ // src/commands/connectors.ts
9477
+ function renderConnectorList(data) {
9478
+ const lines = [];
9479
+ if (data.instances.length === 0 && data.legacy.length === 0) {
9480
+ lines.push("No connectors yet. Add one in the console: Portfolio \u2192 Connectors.");
9481
+ }
9482
+ for (const i of data.instances) {
9483
+ const status = (i.status ?? "unknown").toUpperCase();
9484
+ lines.push(` ${status.padEnd(10)} ${i.kind.padEnd(18)} ${oneLine(i.display_name ?? "", 30).padEnd(30)} ${i.id}`);
9485
+ if (i.status_reason || i.last_error) lines.push(` ${oneLine(i.status_reason ?? i.last_error, 110)}`);
9486
+ }
9487
+ if (data.legacy.length > 0) {
9488
+ lines.push("Project integrations read as connectors:");
9489
+ for (const l of data.legacy) {
9490
+ lines.push(` ${(l.ok ? "OK" : "ERROR").padEnd(10)} ${l.kind.padEnd(18)} project ${l.project_id} (seen ${fmtDate(l.observed_at)})`);
9491
+ if (!l.ok && l.error) lines.push(` ${oneLine(l.error, 110)}`);
9492
+ }
9493
+ }
9494
+ const unused = data.available.filter((a) => !data.instances.some((i) => i.kind === a.kind));
9495
+ if (unused.length > 0) lines.push(`Available to add: ${unused.map((a) => a.kind).join(", ")}`);
9496
+ return lines;
9497
+ }
9498
+ function renderConnectorStatus(i) {
9499
+ return [
9500
+ `${i.kind} \u2014 ${oneLine(i.display_name ?? "(no name)", 60)}`,
9501
+ ` id: ${i.id}`,
9502
+ ` status: ${i.status ?? "unknown"}${i.status_reason ? ` (${oneLine(i.status_reason, 100)})` : ""}`,
9503
+ ` last probe: ${fmtDate(i.last_probe_at)}`,
9504
+ ` last OK: ${fmtDate(i.last_ok_at)}`,
9505
+ ` last error: ${i.last_error ? oneLine(i.last_error, 100) : "\u2014"}`,
9506
+ ` capabilities: ${(i.enabled_capabilities ?? []).join(", ") || "\u2014"}`,
9507
+ ` scopes: ${(i.granted_scopes ?? []).join(", ") || "\u2014"}`,
9508
+ ` bound apps: ${i.bindings.length ? i.bindings.map((b) => `${b.projectId} (${b.role} ${b.externalId})`).join(", ") : "\u2014"}`
9509
+ ];
9510
+ }
9511
+ function renderConnectorActions(actions) {
9512
+ if (actions.length === 0) return ["No connector actions requested."];
9513
+ const lines = [];
9514
+ for (const a of actions) {
9515
+ lines.push(` ${a.status.toUpperCase().padEnd(17)} ${a.action.padEnd(24)} requested ${fmtDate(a.requested_at)} ${a.id}`);
9516
+ if (a.reason) lines.push(` why: ${oneLine(a.reason, 100)}`);
9517
+ if (a.error) lines.push(` error: ${oneLine(a.error, 100)}`);
9518
+ }
9519
+ if (actions.some((a) => a.status === "pending_approval")) lines.push("A team owner or admin approves pending actions in the console.");
9520
+ return lines;
9521
+ }
9522
+ async function loadConnectors(org) {
9523
+ const config = requireConfig();
9524
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(org)}/connectors`, config);
9525
+ if (!result.ok) dieOrgError(result);
9526
+ return result.data;
9527
+ }
9528
+ function registerConnectorsCommands(program2) {
9529
+ const connectors = program2.command("connectors").description("Your team's connectors (read-only; add or edit them in the console)");
9530
+ connectors.command("list").description("Every connector with its status and last error").option("--org <id>", "Organization UUID (default: your only organization)").option("--json", "Machine-readable JSON output").action(async (opts) => {
9531
+ const data = await loadConnectors(opts.org);
9532
+ if (outputIsJson(opts.json)) {
9533
+ console.log(JSON.stringify(data, null, 2));
9534
+ return;
9535
+ }
9536
+ for (const line of renderConnectorList(data)) console.log(line);
9537
+ });
9538
+ connectors.command("status <connectorId>").description("One connector: status, last probe, last error, bound apps").option("--org <id>", "Organization UUID (default: your only organization)").option("--json", "Machine-readable JSON output").action(async (connectorId, opts) => {
9539
+ const data = await loadConnectors(opts.org);
9540
+ const exact = data.instances.find((i) => i.id === connectorId);
9541
+ const matches = exact ? [exact] : data.instances.filter((i) => i.id.startsWith(connectorId));
9542
+ if (matches.length !== 1) {
9543
+ throw new MushiCliError(
9544
+ "E_INVALID_INPUT",
9545
+ matches.length === 0 ? `No connector ${connectorId} in this team.` : `${connectorId} matches ${matches.length} connectors.`,
9546
+ "copy the full id from `mushi connectors list`"
9547
+ );
9548
+ }
9549
+ const instance = matches[0];
9550
+ if (outputIsJson(opts.json)) {
9551
+ console.log(JSON.stringify(instance, null, 2));
9552
+ return;
9553
+ }
9554
+ for (const line of renderConnectorStatus(instance)) console.log(line);
9555
+ });
9556
+ connectors.command("actions").description("Actions requested on a connector and whether they were approved and run (latest 100)").option("--org <id>", "Organization UUID (default: your only organization)").option("--json", "Machine-readable JSON output").action(async (opts) => {
9557
+ const config = requireConfig();
9558
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/connector-actions`, config);
9559
+ if (!result.ok) dieOrgError(result);
9560
+ if (outputIsJson(opts.json)) {
9561
+ console.log(JSON.stringify(result.data, null, 2));
9562
+ return;
9563
+ }
9564
+ for (const line of renderConnectorActions(result.data.actions)) console.log(line);
9565
+ });
9566
+ }
9567
+
9568
+ // src/commands/funnel.ts
9569
+ var EVENT_RE = /^[a-z][a-z0-9_]{1,63}$/;
9570
+ var WINDOWS = ["1d", "7d", "30d"];
9571
+ function parseFunnelSteps(raw) {
9572
+ const steps = raw.split(",").map((s) => s.trim()).filter(Boolean);
9573
+ if (steps.length < 2 || steps.length > 8) {
9574
+ throw new MushiCliError("E_INVALID_INPUT", "A funnel needs 2 to 8 steps.", "e.g. --steps signup,first_report,fix_merged");
9575
+ }
9576
+ const bad = steps.filter((s) => !EVENT_RE.test(s));
9577
+ if (bad.length > 0) {
9578
+ throw new MushiCliError("E_INVALID_INPUT", `Not an event name: ${bad.join(", ")}`, "event names are lowercase letters, digits and _");
9579
+ }
9580
+ if (new Set(steps).size !== steps.length) {
9581
+ throw new MushiCliError("E_INVALID_INPUT", "Each step must be a different event.");
9582
+ }
9583
+ return steps;
9584
+ }
9585
+ function renderOrgFunnel(data) {
9586
+ if (data.state === "not_set_up" || !data.definition) {
9587
+ return ["No team funnel yet. Set one with: mushi funnel set --steps signup,first_report,fix_merged"];
9588
+ }
9589
+ const d = data.definition;
9590
+ const lines = [`Funnel: ${d.steps.join(" \u2192 ")} (window ${d.window}, last ${d.lookbackDays} days)`];
9591
+ for (const r of data.rows) {
9592
+ const label = oneLine(r.name, 30).padEnd(30);
9593
+ if (r.state === "off") lines.push(` ${label} product events are off`);
9594
+ else if (r.state === "error") lines.push(` ${label} could not be read`);
9595
+ else if (r.state === "no_events") lines.push(` ${label} nobody entered the first step yet`);
9596
+ else lines.push(` ${label} ${String(r.overallPct ?? 0).padStart(5)}% ${r.steps.map((s) => `${s.name} ${s.converted}`).join(" \u2192 ")}`);
9597
+ }
9598
+ return lines;
9599
+ }
9600
+ function renderGrowthFunnel(data) {
9601
+ const lines = [`Growth funnel (${data.source}) ${data.window_start ?? "\u2014"} \u2192 ${data.window_end ?? "\u2014"}`];
9602
+ if (!data.self_project_configured) lines.push(" Note: the self-analytics project is not configured, so product steps may read zero.");
9603
+ if (data.weeks.length === 0) lines.push(" No weeks in this window.");
9604
+ for (const w of data.weeks) {
9605
+ const week = String(w["week"] ?? w["week_start"] ?? "");
9606
+ const rest = Object.entries(w).filter(([k]) => k !== "week" && k !== "week_start").map(([k, v]) => `${k} ${String(v)}`).join(" \xB7 ");
9607
+ lines.push(` ${week.padEnd(12)} ${rest}`);
9608
+ }
9609
+ return lines;
9610
+ }
9611
+ function registerFunnelCommands(program2) {
9612
+ const funnel = program2.command("funnel").description("One funnel across every app in your team (needs an account-level key)");
9613
+ funnel.command("show").description("Conversion per app for the team funnel").option("--org <id>", "Organization UUID (default: your only organization)").option("--json", "Machine-readable JSON output").action(async (opts) => {
9614
+ const config = requireConfig();
9615
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/funnel`, config);
9616
+ if (!result.ok) dieOrgError(result);
9617
+ if (outputIsJson(opts.json)) {
9618
+ console.log(JSON.stringify(result.data, null, 2));
9619
+ return;
9620
+ }
9621
+ for (const line of renderOrgFunnel(result.data)) console.log(line);
9622
+ });
9623
+ funnel.command("set").description("Set the team funnel's steps (team owners and admins)").requiredOption("--steps <events>", "Comma-separated event names, 2 to 8, in order").option("--window <w>", `Conversion window: ${WINDOWS.join(" | ")}`, "7d").option("--lookback <days>", "Days of history to read (1-365)", "30").option("--org <id>", "Organization UUID (default: your only organization)").option("--json", "Machine-readable JSON output").action(async (opts) => {
9624
+ const steps = parseFunnelSteps(opts.steps);
9625
+ if (!WINDOWS.includes(opts.window)) {
9626
+ throw new MushiCliError("E_INVALID_INPUT", `--window must be one of ${WINDOWS.join(", ")}`);
9627
+ }
9628
+ const lookbackDays = Number(opts.lookback);
9629
+ if (!Number.isInteger(lookbackDays) || lookbackDays < 1 || lookbackDays > 365) {
9630
+ throw new MushiCliError("E_INVALID_INPUT", "--lookback must be a whole number of days from 1 to 365");
9631
+ }
9632
+ const config = requireConfig();
9633
+ const result = await apiCall(
9634
+ `/v1/admin/orgs/${orgSegment(opts.org)}/funnel`,
9635
+ config,
9636
+ { method: "PUT", body: JSON.stringify({ steps, window: opts.window, lookbackDays }) }
9637
+ );
9638
+ if (!result.ok) dieOrgError(result);
9639
+ if (outputIsJson(opts.json)) {
9640
+ console.log(JSON.stringify(result.data, null, 2));
9641
+ return;
9642
+ }
9643
+ console.log(`Saved: ${result.data.steps.join(" \u2192 ")} (window ${result.data.window}, last ${result.data.lookbackDays} days)`);
9644
+ });
9645
+ funnel.command("growth").description("Mushi's own weekly growth funnel (operators only)").option("--weeks <n>", "Weeks to show", "8").option("--source <source>", "Signup source, or all", "all").option("--json", "Machine-readable JSON output").action(async (opts) => {
9646
+ const config = requireConfig();
9647
+ const qs = new URLSearchParams({ weeks: opts.weeks, source: opts.source });
9648
+ const result = await apiCall(`/v1/admin/growth/funnel?${qs}`, config);
9649
+ if (!result.ok) {
9650
+ if (result.error.code.endsWith("_NEEDS_ACCOUNT_KEY")) dieOrgError(result);
9651
+ die(result);
9652
+ }
9653
+ if (outputIsJson(opts.json)) {
9654
+ console.log(JSON.stringify(result.data, null, 2));
9655
+ return;
9656
+ }
9657
+ for (const line of renderGrowthFunnel(result.data)) console.log(line);
9658
+ });
9659
+ }
9660
+
9661
+ // src/commands/code-health.ts
9662
+ function renderCodeHealth(data) {
9663
+ if (!data.latestRunAt) {
9664
+ return [
9665
+ "No code-health run yet. Post one from CI:",
9666
+ " POST /v1/ingest/metrics with metrics[] (bundle.*, code_health.*) and findings[]"
9667
+ ];
9668
+ }
9669
+ const s = data.summary;
9670
+ const lines = [
9671
+ `Latest run ${fmtDate(data.latestRunAt)} (${data.latestRunStatus ?? "unknown"}): ${s.error_count} error(s), ${s.warn_count} warning(s)${s.max_loc != null ? ` \xB7 largest file ${s.max_loc} lines` : ""}${s.latest_bundle_kb != null ? ` \xB7 bundle ${s.latest_bundle_kb} KB gzip` : ""}`
9672
+ ];
9673
+ for (const f of data.godFiles) {
9674
+ const where = f.file_path ? `${f.file_path}${f.line ? `:${f.line}` : ""}` : "(no file)";
9675
+ lines.push(` ${f.severity.toUpperCase().padEnd(5)} ${where}`);
9676
+ lines.push(` ${oneLine(f.message, 110)} [${f.id}]`);
9677
+ }
9678
+ if (data.godFiles.length > 0) lines.push("Explain one: mushi audit explain <finding id>");
9679
+ return lines;
9680
+ }
9681
+ function registerCodeHealthCommands(program2) {
9682
+ const codeHealth = program2.command("code-health").description("Oversized files and bundle size from your CI (Code Health page)");
9683
+ codeHealth.command("show").description("Findings from the latest code-health run and the size summary").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--days <n>", "Trend window in days (1-365)", "30").option("--json", "Machine-readable JSON output (includes the trend series)").action(async (opts) => {
9684
+ const config = requireConfig();
9685
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
9686
+ const qs = new URLSearchParams({ project_id: projectId, days: opts.days });
9687
+ const result = await apiCall(`/v1/admin/code-health?${qs}`, config);
9688
+ if (!result.ok) die(result);
9689
+ if (outputIsJson(opts.json)) {
9690
+ console.log(JSON.stringify(result.data, null, 2));
9691
+ return;
9692
+ }
9693
+ for (const line of renderCodeHealth(result.data)) console.log(line);
9694
+ });
9695
+ codeHealth.command("stats").description("Counts only: errors, warnings and oversized files").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
9696
+ const config = requireConfig();
9697
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
9698
+ const result = await apiCall(
9699
+ `/v1/admin/code-health/stats?${new URLSearchParams({ project_id: projectId })}`,
9700
+ config
9701
+ );
9702
+ if (!result.ok) die(result);
9703
+ const d = result.data;
9704
+ if (outputIsJson(opts.json)) {
9705
+ console.log(JSON.stringify(d, null, 2));
9706
+ return;
9707
+ }
9708
+ if (!d.hasRun) {
9709
+ console.log("No code-health run yet.");
9710
+ return;
9711
+ }
9712
+ console.log(`${d.projectName ?? projectId}: ${d.errorCount} error(s), ${d.warnCount} warning(s), ${d.godFileCount} oversized file(s) \xB7 last run ${fmtDate(d.latestRunAt)}`);
9713
+ if (d.topPriorityLabel) console.log(`Next: ${d.topPriorityLabel}`);
9714
+ });
9715
+ }
9716
+
9717
+ // src/commands/releases.ts
9718
+ import { readFileSync as readFileSync6 } from "fs";
9719
+ var DRAFT_TIMEOUT_MS = 12e4;
9720
+ var PUBLISH_TIMEOUT_MS = 6e4;
9721
+ function renderReleaseList(rows, total) {
9722
+ if (rows.length === 0) return ["No releases yet. Draft one with: mushi releases draft 1.2.0"];
9723
+ const lines = [`${pad("VERSION", 12)}${pad("STATUS", 11)}${pad("FIXES", 7)}${pad("CREATED", 18)}ID`];
9724
+ for (const r of rows) {
9725
+ lines.push(`${pad(r.version, 12)}${pad(r.status, 11)}${pad(String(r.fixed_report_ids?.length ?? 0), 7)}${pad(fmtDate(r.created_at), 18)}${r.id}`);
9726
+ }
9727
+ if (total > rows.length) lines.push(`\u2026 ${total - rows.length} more (use --limit / --offset)`);
9728
+ return lines;
9729
+ }
9730
+ function renderPublish(release, delivery, ticketsFulfilled) {
9731
+ const lines = [`Published ${release.version}.`];
9732
+ if (delivery) {
9733
+ lines.push(` Reports resolved: ${delivery.reports_resolved} of ${delivery.reports_listed}`);
9734
+ lines.push(` Reporters messaged: ${delivery.reporters_notified}, held for review: ${delivery.reporters_held}, failed: ${delivery.reporters_failed}`);
9735
+ if (delivery.reporters_held > 0) lines.push(" Held messages wait in the outbox: mushi outbox list");
9736
+ }
9737
+ if (ticketsFulfilled) lines.push(` Support tickets marked shipped: ${ticketsFulfilled}`);
9738
+ return lines;
9739
+ }
9740
+ function renderCalendar(data) {
9741
+ if (data.rows.length === 0) return ["No apps in this team yet."];
9742
+ const lines = [];
9743
+ for (const r of data.rows) {
9744
+ const live = r.live ? `live ${r.live.version}${r.live.rolloutPct != null && r.live.rolloutPct < 100 ? ` at ${r.live.rolloutPct}%` : ""}` : "not live yet";
9745
+ lines.push(` ${pad(r.stage, 18)} ${pad(oneLine(r.name, 28), 29)} ${live} \xB7 ${r.mergedNotBuilt} native change(s) to build \xB7 ${r.otaPending} OTA fix(es)`);
9746
+ }
9747
+ if (data.batchSuggestion) lines.push(data.batchSuggestion.note);
9748
+ if (data.note) lines.push(data.note);
9749
+ return lines;
9750
+ }
9751
+ function readBody(path) {
9752
+ if (!path) return void 0;
9753
+ try {
9754
+ return readFileSync6(path, "utf8");
9755
+ } catch (err) {
9756
+ throw new MushiCliError("E_FILE_NOT_FOUND", `Could not read ${path}`, "pass a Markdown file you can read", err);
9757
+ }
9758
+ }
9759
+ function registerReleasesCommands(program2) {
9760
+ const releases = program2.command("releases").description("Release notes that credit your reporters and tell them the fix shipped");
9761
+ releases.command("list").description("Drafts and published releases, newest first").option("--status <status>", "draft | published").option("--project-id <id>", "Only this project").option("--limit <n>", "How many (max 100)", "20").option("--offset <n>", "Skip this many", "0").option("--json", "Machine-readable JSON output").action(async (opts) => {
9762
+ if (opts.status && opts.status !== "draft" && opts.status !== "published") {
9763
+ throw new MushiCliError("E_INVALID_INPUT", "--status must be draft or published");
9764
+ }
9765
+ const config = requireConfig();
9766
+ const qs = new URLSearchParams({ limit: opts.limit, offset: opts.offset });
9767
+ if (opts.status) qs.set("status", opts.status);
9768
+ if (opts.projectId) qs.set("project_id", requireUuid(opts.projectId, "project id"));
9769
+ const result = await apiCall(`/v1/admin/releases?${qs}`, config);
9770
+ if (!result.ok) die(result);
9771
+ const total = Number(result.meta?.total ?? result.data.length);
9772
+ if (outputIsJson(opts.json)) {
9773
+ console.log(JSON.stringify({ releases: result.data, total }, null, 2));
9774
+ return;
9775
+ }
9776
+ for (const line of renderReleaseList(result.data, total)) console.log(line);
9777
+ });
9778
+ releases.command("stats").description("Drafts, published releases, credited reporters and what to do next").option("--json", "Machine-readable JSON output").action(async (opts) => {
9779
+ const config = requireConfig();
9780
+ const result = await apiCall("/v1/admin/releases/stats", config);
9781
+ if (!result.ok) die(result);
9782
+ const d = result.data;
9783
+ if (outputIsJson(opts.json)) {
9784
+ console.log(JSON.stringify(d, null, 2));
9785
+ return;
9786
+ }
9787
+ console.log(`Releases: ${String(d["draftCount"] ?? 0)} draft, ${String(d["publishedCount"] ?? 0)} published \xB7 fixes linked ${String(d["totalFixesLinked"] ?? 0)} \xB7 reporters credited ${String(d["totalCredits"] ?? 0)} (${String(d["creditsPending"] ?? 0)} not told yet)`);
9788
+ if (typeof d["topPriorityLabel"] === "string") console.log(`Next: ${d["topPriorityLabel"]}`);
9789
+ });
9790
+ releases.command("show <releaseId>").description("One release with its notes and credited reporters").option("--json", "Machine-readable JSON output").action(async (releaseId, opts) => {
9791
+ const id = requireUuid(releaseId, "release id");
9792
+ const config = requireConfig();
9793
+ const result = await apiCall(`/v1/admin/releases/${id}`, config);
9794
+ if (!result.ok) die(result);
9795
+ const r = result.data;
9796
+ if (outputIsJson(opts.json)) {
9797
+ console.log(JSON.stringify(r, null, 2));
9798
+ return;
9799
+ }
9800
+ console.log(`${r.version} \u2014 ${oneLine(r.title ?? "(untitled)", 80)} [${r.status}${r.published_at ? ` ${fmtDate(r.published_at)}` : ""}]`);
9801
+ console.log(`Fixed reports: ${r.fixed_report_ids?.length ?? 0} \xB7 credited reporters: ${r.credits?.length ?? 0}`);
9802
+ if (r.body_md) console.log(`
9803
+ ${r.body_md}`);
9804
+ });
9805
+ releases.command("draft <version>").description("Draft notes from the fixes merged in a window (nothing is sent)").option("--title <title>", "Release title").option("--since <iso>", "Window start (ISO date); default: since the last release").option("--until <iso>", "Window end (ISO date); default: now").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (version, opts) => {
9806
+ const config = requireConfig();
9807
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
9808
+ const body = { project_id: projectId, version };
9809
+ if (opts.title) body["title"] = opts.title;
9810
+ if (opts.since) body["window_start"] = opts.since;
9811
+ if (opts.until) body["window_end"] = opts.until;
9812
+ const result = await apiCall("/v1/admin/releases/draft", config, { method: "POST", body: JSON.stringify(body) }, { timeoutMs: DRAFT_TIMEOUT_MS });
9813
+ if (!result.ok) die(result);
9814
+ if (outputIsJson(opts.json)) {
9815
+ console.log(JSON.stringify(result.data, null, 2));
9816
+ return;
9817
+ }
9818
+ const r = result.data.release;
9819
+ console.log(`Drafted ${r.version} (${r.id}) from ${result.data.reportCount} fixed report(s), crediting ${result.data.creditCount} reporter(s).`);
9820
+ console.log(`Review it with \`mushi releases show ${r.id}\`, then: mushi releases publish ${r.id} --yes`);
9821
+ });
9822
+ releases.command("edit <releaseId>").description("Change a draft: title, version or notes").option("--title <title>", "New title").option("--set-version <version>", "New version").option("--body-file <path>", "Replace the notes with this Markdown file").option("--json", "Machine-readable JSON output").action(async (releaseId, opts) => {
9823
+ const id = requireUuid(releaseId, "release id");
9824
+ const patch = {};
9825
+ if (opts.title) patch["title"] = opts.title;
9826
+ if (opts.setVersion) patch["version"] = opts.setVersion;
9827
+ const bodyMd = readBody(opts.bodyFile);
9828
+ if (bodyMd !== void 0) patch["body_md"] = bodyMd;
9829
+ if (Object.keys(patch).length === 0) {
9830
+ throw new MushiCliError("E_INVALID_INPUT", "Nothing to change.", "pass --title, --set-version or --body-file");
9831
+ }
9832
+ const config = requireConfig();
9833
+ const result = await apiCall(`/v1/admin/releases/${id}`, config, { method: "PATCH", body: JSON.stringify(patch) });
9834
+ if (!result.ok) die(result);
9835
+ if (outputIsJson(opts.json)) {
9836
+ console.log(JSON.stringify(result.data, null, 2));
9837
+ return;
9838
+ }
9839
+ console.log(`Saved draft ${result.data.version}.`);
9840
+ });
9841
+ releases.command("delete <releaseId>").description("Delete a draft (published releases cannot be deleted)").option("--yes", "Confirm the delete").action(async (releaseId, opts) => {
9842
+ const id = requireUuid(releaseId, "release id");
9843
+ requireYes(opts.yes, `This deletes draft release ${id}.`);
9844
+ const config = requireConfig();
9845
+ const result = await apiCall(`/v1/admin/releases/${id}`, config, { method: "DELETE" });
9846
+ if (!result.ok) die(result);
9847
+ console.log(`Deleted draft ${id}.`);
9848
+ });
9849
+ releases.command("publish <releaseId>").description("Publish a draft: resolves its fixed reports and messages each credited reporter").option("--yes", "Confirm: reporters are messaged (or held for review)").option("--json", "Machine-readable JSON output").action(async (releaseId, opts) => {
9850
+ const id = requireUuid(releaseId, "release id");
9851
+ requireYes(opts.yes, `Publishing ${id} tells every credited reporter their fix shipped.`);
9852
+ const config = requireConfig();
9853
+ const result = await apiCall(`/v1/admin/releases/${id}/publish`, config, { method: "POST", body: "{}" }, { timeoutMs: PUBLISH_TIMEOUT_MS });
9854
+ if (!result.ok) die(result);
9855
+ const extra = result;
9856
+ if (outputIsJson(opts.json)) {
9857
+ console.log(JSON.stringify({ release: extra.data, notified: extra.notified, tickets_fulfilled: extra.tickets_fulfilled, delivery: extra.delivery }, null, 2));
9858
+ return;
9859
+ }
9860
+ for (const line of renderPublish(extra.data, extra.delivery, extra.tickets_fulfilled)) console.log(line);
9861
+ });
9862
+ releases.command("auto-release").description("The automatic draft that blocks auto-release for a project, if any").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
9863
+ const config = requireConfig();
9864
+ const pid = resolveProjectId(opts.projectId, config.projectId);
9865
+ const result = await apiCall(`/v1/admin/releases/auto-release?project_id=${pid}`, config);
9866
+ if (!result.ok) die(result);
9867
+ if (outputIsJson(opts.json)) {
9868
+ console.log(JSON.stringify(result.data, null, 2));
9869
+ return;
9870
+ }
9871
+ const d = result.data.blockingDraft;
9872
+ if (!d) {
9873
+ console.log("Nothing blocks auto-release: no automatic draft is waiting.");
9874
+ return;
9875
+ }
9876
+ console.log(`Auto-release is blocked by draft ${d.version} (${d.id}), created ${fmtDate(d.createdAt)}${d.stale ? ", stale" : ""}.`);
9877
+ console.log(`Publish it (mushi releases publish ${d.id} --yes) or delete it (mushi releases delete ${d.id} --yes) to let the next build release.`);
9878
+ });
9879
+ releases.command("calendar").description("What is waiting to ship in each app, and one suggested batch (needs an account-level key)").option("--org <id>", "Organization UUID (default: your only organization)").option("--json", "Machine-readable JSON output").action(async (opts) => {
9880
+ const config = requireConfig();
9881
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/releases`, config);
9882
+ if (!result.ok) dieOrgError(result);
9883
+ if (outputIsJson(opts.json)) {
9884
+ console.log(JSON.stringify(result.data, null, 2));
9885
+ return;
9886
+ }
9887
+ for (const line of renderCalendar(result.data)) console.log(line);
9888
+ });
9889
+ }
9890
+
9891
+ // src/commands/sentry.ts
9892
+ var IMPORT_TIMEOUT_MS = 9e4;
9893
+ function buildSentryImportBody(issueIds, flags) {
9894
+ const body = {};
9895
+ if (issueIds.length > 0) {
9896
+ if (issueIds.length > 10) throw new MushiCliError("E_INVALID_INPUT", "Import at most 10 issue ids at a time.");
9897
+ if (flags.query || flags.cursor || flags.sinceDays || flags.sentryProject) {
9898
+ throw new MushiCliError("E_INVALID_INPUT", "Pass issue ids or a search (--query / --since-days / --cursor / --sentry-project), not both.");
9899
+ }
9900
+ body["issueIds"] = issueIds;
9901
+ }
9902
+ if (flags.query) body["query"] = flags.query;
9903
+ if (flags.limit !== void 0) {
9904
+ const n = Number(flags.limit);
9905
+ if (!Number.isInteger(n) || n < 1 || n > 10) throw new MushiCliError("E_INVALID_INPUT", "--limit must be 1 to 10");
9906
+ body["limit"] = n;
9907
+ }
9908
+ if (flags.sinceDays !== void 0) {
9909
+ const n = Number(flags.sinceDays);
9910
+ if (!Number.isInteger(n) || n < 1 || n > 90) throw new MushiCliError("E_INVALID_INPUT", "--since-days must be 1 to 90");
9911
+ body["sinceDays"] = n;
9912
+ }
9913
+ if (flags.cursor) body["cursor"] = flags.cursor;
9914
+ if (flags.sentryProject) body["sentryProject"] = flags.sentryProject;
9915
+ return body;
9916
+ }
9917
+ function nextPageCommand(flags, cursor) {
9918
+ const parts = ["mushi sentry import"];
9919
+ if (flags.query) parts.push(`--query ${JSON.stringify(flags.query)}`);
9920
+ if (flags.sinceDays) parts.push(`--since-days ${flags.sinceDays}`);
9921
+ if (flags.limit) parts.push(`--limit ${flags.limit}`);
9922
+ if (flags.sentryProject) parts.push(`--sentry-project ${flags.sentryProject}`);
9923
+ parts.push(`--cursor ${cursor}`);
9924
+ return parts.join(" ");
9925
+ }
9926
+ function renderSentryImport(data, flags = {}) {
9927
+ const lines = [`Sentry project: ${data.sentryProject ?? data.sentryProjects.join(", ")}`];
9928
+ if (data.items.length === 0) lines.push(" No matching issues.");
9929
+ for (const i of data.items) {
9930
+ const label = i.shortId ?? i.issueId ?? i.input;
9931
+ const tail = i.reportId ? ` \u2192 report ${i.reportId}` : i.error ? ` \u2014 ${i.error}` : "";
9932
+ lines.push(` ${i.outcome.toUpperCase().padEnd(10)} ${label}${tail}`);
9933
+ }
9934
+ lines.push(`New reports: ${data.created.length} \xB7 linked to existing: ${data.linked.length} \xB7 failed: ${data.failed}`);
9935
+ if (data.indexing.queued && data.indexing.paths > 0) lines.push(`Indexing ${data.indexing.paths} file(s) from the stack traces so the fix context can reach them.`);
9936
+ if (data.nextCursor) lines.push(`More issues: ${nextPageCommand(flags, data.nextCursor)}`);
9937
+ return lines;
9938
+ }
9939
+ function registerSentryCommands(program2) {
9940
+ const sentry = program2.command("sentry").description("Sentry issues in your Mushi queue");
9941
+ sentry.command("import [issueIds...]").description("Import open Sentry issues as reports (by id, by search, or the newest unresolved)").option("--query <q>", 'Sentry search, e.g. "is:unresolved level:error"').option("--limit <n>", "Issues per page, 1-10").option("--since-days <n>", "Only issues seen in the last N days (1-90)").option("--cursor <cursor>", "Next page, from the previous import").option("--sentry-project <slug>", "Which connected Sentry project (default: the primary)").option("--project-id <id>", "Mushi project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").addHelpText("after", `
9942
+ Needs the Sentry org slug, project slug and an auth token with event:read and
9943
+ project:read (console: Integrations \u2192 Sentry). With no ids and no --query it
9944
+ imports the newest unresolved issues.
9945
+
9946
+ Examples:
9947
+ mushi sentry import
9948
+ mushi sentry import PROJ-1A2 PROJ-1A3
9949
+ mushi sentry import --query "is:unresolved level:error" --since-days 7`).action(async (issueIds, opts) => {
9950
+ const body = buildSentryImportBody(issueIds, opts);
9951
+ const config = requireConfig();
9952
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
9953
+ const result = await apiCall(
9954
+ `/v1/admin/projects/${projectId}/sentry/import`,
9955
+ config,
9956
+ { method: "POST", body: JSON.stringify(body) },
9957
+ { timeoutMs: IMPORT_TIMEOUT_MS }
9958
+ );
9959
+ if (!result.ok) die(result);
9960
+ if (outputIsJson(opts.json)) {
9961
+ console.log(JSON.stringify(result.data, null, 2));
9962
+ return;
9963
+ }
9964
+ for (const line of renderSentryImport(result.data, opts)) console.log(line);
9965
+ if (result.data.failed > 0) process.exitCode = 1;
9966
+ });
9967
+ }
9968
+
9969
+ // src/commands/outbox.ts
9970
+ var OUTBOX_TEXT_MAX = 1e3;
9971
+ function validateOutboxText(text2) {
9972
+ const trimmed = text2.trim();
9973
+ if (!trimmed || trimmed.length > OUTBOX_TEXT_MAX) {
9974
+ throw new MushiCliError("E_INVALID_INPUT", `The message must be 1 to ${OUTBOX_TEXT_MAX} characters.`);
9975
+ }
9976
+ return trimmed;
9977
+ }
9978
+ function renderOutbox(messages) {
9979
+ if (messages.length === 0) return ["Nothing held. Every reporter update has gone out."];
9980
+ const lines = [`${messages.length} update(s) waiting for review:`];
9981
+ for (const m of messages) {
9982
+ lines.push(` ${m.id} ${m.notification_type} ${fmtDate(m.created_at)}`);
9983
+ if (m.report_title) lines.push(` report: ${oneLine(m.report_title, 90)}`);
9984
+ lines.push(` sends: ${oneLine(m.body_override ?? m.text, 200)}${m.body_override ? " (edited)" : ""}`);
9985
+ }
9986
+ lines.push("Send one: mushi outbox release <id> --yes \xB7 drop one: mushi outbox discard <id> --yes");
9987
+ return lines;
9988
+ }
9989
+ function renderHeld(verb, d) {
9990
+ return `${verb}: delivered ${d.delivered}, skipped ${d.skipped}, duplicate ${d.duplicate}, failed ${d.failed}.`;
9991
+ }
9992
+ function registerOutboxCommands(program2) {
9993
+ const outbox = program2.command("outbox").description("Reporter updates held for your review before they are sent");
9994
+ outbox.command("list").description("Updates waiting for review, oldest first").option("--json", "Machine-readable JSON output").action(async (opts) => {
9995
+ const config = requireConfig();
9996
+ const result = await apiCall("/v1/admin/reporter-outbox", config);
9997
+ if (!result.ok) die(result);
9998
+ if (outputIsJson(opts.json)) {
9999
+ console.log(JSON.stringify(result.data, null, 2));
10000
+ return;
10001
+ }
10002
+ for (const line of renderOutbox(result.data.messages)) console.log(line);
10003
+ });
10004
+ outbox.command("edit <messageId> <text>").description("Reword a held update (it stays held)").action(async (messageId, text2) => {
10005
+ const id = requireUuid(messageId, "message id");
10006
+ const body = validateOutboxText(text2);
10007
+ const config = requireConfig();
10008
+ const result = await apiCall(
10009
+ `/v1/admin/reporter-outbox/${id}`,
10010
+ config,
10011
+ { method: "PATCH", body: JSON.stringify({ body_override: body }) }
10012
+ );
10013
+ if (!result.ok) die(result);
10014
+ console.log(`Saved. Still held; send it with: mushi outbox release ${id} --yes`);
10015
+ });
10016
+ outbox.command("release <messageId>").description("Send a held update to the reporter now").option("--text <text>", "Send this wording instead").option("--yes", "Confirm: the reporter receives it").option("--json", "Machine-readable JSON output").action(async (messageId, opts) => {
10017
+ const id = requireUuid(messageId, "message id");
10018
+ const override = opts.text !== void 0 ? validateOutboxText(opts.text) : void 0;
10019
+ requireYes(opts.yes, `Releasing ${id} sends it to the reporter.`);
10020
+ const config = requireConfig();
10021
+ const result = await apiCall(
10022
+ `/v1/admin/reporter-outbox/${id}/release`,
10023
+ config,
10024
+ { method: "POST", body: JSON.stringify(override ? { body_override: override } : {}) }
10025
+ );
10026
+ if (!result.ok) die(result);
10027
+ if (outputIsJson(opts.json)) {
10028
+ console.log(JSON.stringify(result.data, null, 2));
10029
+ return;
10030
+ }
10031
+ console.log(renderHeld("Released", result.data));
10032
+ if (result.data.failed > 0) process.exitCode = 1;
10033
+ });
10034
+ outbox.command("discard <messageId>").description("Drop a held update; the reporter never sees it").option("--yes", "Confirm the discard").option("--json", "Machine-readable JSON output").action(async (messageId, opts) => {
10035
+ const id = requireUuid(messageId, "message id");
10036
+ requireYes(opts.yes, `Discarding ${id} means the reporter never gets this update.`);
10037
+ const config = requireConfig();
10038
+ const result = await apiCall(`/v1/admin/reporter-outbox/${id}/discard`, config, { method: "POST", body: "{}" });
10039
+ if (!result.ok) die(result);
10040
+ if (outputIsJson(opts.json)) {
10041
+ console.log(JSON.stringify(result.data, null, 2));
10042
+ return;
10043
+ }
10044
+ console.log(`Discarded ${id}.`);
10045
+ });
10046
+ }
10047
+
10048
+ // src/commands/budgets.ts
10049
+ var SPEND_LIMIT_FIELDS = [
10050
+ "monthly_llm_budget_usd",
10051
+ "autofix_max_spend_usd",
10052
+ "autofix_max_dispatches_per_day",
10053
+ "autofix_approval_cost_threshold_usd"
10054
+ ];
10055
+ var LABELS = {
10056
+ monthly_llm_budget_usd: { label: "Monthly AI budget", unit: "usd", none: "no budget" },
10057
+ autofix_max_spend_usd: { label: "Auto-fix spend limit, last 30 days", unit: "usd", none: "no limit" },
10058
+ autofix_max_dispatches_per_day: { label: "Automatic fixes per day", unit: "count", none: "no limit" },
10059
+ autofix_approval_cost_threshold_usd: { label: "Ask before fixes costing more than", unit: "usd", none: "never ask" }
10060
+ };
10061
+ function numberOrNull(v) {
10062
+ if (v === null || v === void 0 || v === "") return null;
10063
+ const n = Number(v);
10064
+ return Number.isFinite(n) ? n : null;
10065
+ }
10066
+ function pickLimits(settings) {
10067
+ return Object.fromEntries(SPEND_LIMIT_FIELDS.map((f) => [f, numberOrNull(settings[f])]));
10068
+ }
10069
+ function renderBudgets(view) {
10070
+ const lines = [`Spend limits for project ${view.projectId}`];
10071
+ for (const f of SPEND_LIMIT_FIELDS) {
10072
+ const meta = LABELS[f];
10073
+ const v = view.limits[f];
10074
+ const shown = v === null ? meta.none : meta.unit === "usd" ? `$${v}` : String(v);
10075
+ lines.push(` ${meta.label.padEnd(36)} ${shown}`);
10076
+ }
10077
+ lines.push(` ${"Plan spend cap (per month)".padEnd(36)} ${view.monthlySpendCapUsd == null ? "no cap" : `$${view.monthlySpendCapUsd}`}`);
10078
+ lines.push(` ${"Auto-fix".padEnd(36)} ${view.autofixEnabled ? "on" : "off"}`);
10079
+ if (view.autofixEnabled && view.limits.autofix_max_spend_usd === null && view.limits.autofix_max_dispatches_per_day === null) {
10080
+ lines.push(" Auto-fix is on with no cap. Set one in the console: Settings \u2192 General \u2192 Spend limits.");
10081
+ }
10082
+ lines.push("Change the plan cap: mushi billing cap <usd>. Change the other limits: console Settings \u2192 General \u2192 Spend limits.");
10083
+ return lines;
10084
+ }
10085
+ function registerBudgetsCommands(program2) {
10086
+ const budgets = program2.command("budgets").description("What Mushi may spend for a project: AI budget, auto-fix caps, plan spend cap");
10087
+ budgets.command("show").description("Every spend limit and whether auto-fix is on").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
10088
+ const config = requireConfig();
10089
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
10090
+ const qs = new URLSearchParams({ project_id: projectId });
10091
+ const [settings, billing, autofix] = await Promise.all([
10092
+ apiCall(`/v1/admin/settings?${qs}`, config),
10093
+ apiCall(`/v1/admin/billing/stats?${qs}`, config),
10094
+ apiCall(`/v1/admin/projects/${projectId}/autofix`, config)
10095
+ ]);
10096
+ if (!settings.ok) die(settings);
10097
+ if (!billing.ok) die(billing);
10098
+ if (!autofix.ok) die(autofix);
10099
+ const view = {
10100
+ projectId,
10101
+ limits: pickLimits(settings.data),
10102
+ monthlySpendCapUsd: billing.data.monthlySpendCapUsd ?? null,
10103
+ autofixEnabled: autofix.data.autofix_enabled
10104
+ };
10105
+ if (outputIsJson(opts.json)) {
10106
+ console.log(JSON.stringify(view, null, 2));
10107
+ return;
10108
+ }
10109
+ for (const line of renderBudgets(view)) console.log(line);
10110
+ });
10111
+ budgets.command("autofix <state>").description("Turn auto-fix on or off (on | off). Fixes you start yourself always run.").option("--project-id <id>", "Project ID (defaults to the configured project)").action(async (state, opts) => {
10112
+ if (state !== "on" && state !== "off") {
10113
+ throw new MushiCliError("E_INVALID_INPUT", "Pass on or off.", "e.g. mushi budgets autofix off");
10114
+ }
10115
+ const config = requireConfig();
10116
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
10117
+ const result = await apiCall(
10118
+ `/v1/admin/projects/${projectId}/autofix/toggle`,
10119
+ config,
10120
+ { method: "POST", body: JSON.stringify({ enabled: state === "on" }) }
10121
+ );
10122
+ if (!result.ok) die(result);
10123
+ console.log(`Auto-fix is ${result.data.autofix_enabled ? "on" : "off"} for project ${projectId}.`);
10124
+ });
10125
+ }
10126
+
10127
+ // src/commands/repo.ts
10128
+ var DIGEST_TIMEOUT_MS = 9e4;
10129
+ var MAX_DIGEST_FILE_BYTES = 16 * 1024 * 1024;
10130
+ var DIAGRAM_TIMEOUT_MS = 12e4;
10131
+ var PUBLISH_TIMEOUT_MS2 = 6e4;
10132
+ function digestQuery(opts) {
10133
+ const qs = new URLSearchParams();
10134
+ if (opts.report) qs.set("report_id", requireUuid(opts.report, "report id"));
10135
+ if (opts.path) qs.set("path", opts.path);
10136
+ if (opts.ref) qs.set("ref", opts.ref);
10137
+ if (opts.budget) qs.set("budget", opts.budget);
10138
+ if (opts.include) qs.set("include", opts.include);
10139
+ if (opts.exclude) qs.set("exclude", opts.exclude);
10140
+ return qs;
10141
+ }
10142
+ function digestSummary(d) {
10143
+ const dropped = Object.entries(d.dropped_counts).filter(([, n]) => n > 0).map(([why, n]) => `${n} ${why}`).join(", ");
10144
+ const lines = [
10145
+ `${d.owner}/${d.repo}@${d.sha.slice(0, 7)} (${d.ref}) \u2014 ${d.scope.label}`,
10146
+ ` ${d.files.length} of ${d.eligible_files} files, ~${d.total_tokens} of ${d.budget_tokens} tokens${d.cached ? " (cached)" : ""}`
10147
+ ];
10148
+ if (dropped) lines.push(` left out: ${dropped}`);
10149
+ if (d.redacted.length > 0) lines.push(` secrets removed from ${d.redacted.length} file(s)`);
10150
+ if (d.tree_truncated) lines.push(" GitHub returned a partial tree (very large repo): some paths are missing.");
10151
+ return lines;
10152
+ }
10153
+ function renderDiagram(data) {
10154
+ const d = data.diagram;
10155
+ if (!d) return ["No diagram yet. Make one with: mushi repo diagram generate"];
10156
+ const lines = [`${d.repo_owner}/${d.repo_name}@${d.commit_sha.slice(0, 7)} \u2014 updated ${fmtDate(d.updated_at)}${d.model ? ` by ${d.model}` : ""}`];
10157
+ for (const g of d.graph.groups) {
10158
+ const parts = d.graph.nodes.filter((n) => n.group === g.id);
10159
+ lines.push(` ${oneLine(g.label, 40)}`);
10160
+ for (const n of parts) lines.push(` - ${oneLine(n.label, 40)}${n.path ? ` (${n.path})` : ""}`);
10161
+ }
10162
+ lines.push(` ${d.graph.edges.length} connection(s)`);
10163
+ lines.push(data.publication.published ? `Public page: ${data.publication.url}` : "Not published. Publish it with: mushi repo diagram publish --yes");
10164
+ return lines;
10165
+ }
10166
+ function registerRepoCommands(program2) {
10167
+ const repo = program2.command("repo").description("Your connected GitHub repo: a digest for an LLM and an architecture diagram");
10168
+ repo.command("digest").description("One token-budgeted text of the repo (tree + the files that matter), secrets removed").option("--report <id>", "Start from the files linked to this report's stack trace and fixes").option("--path <folder>", "Only this folder").option("--ref <ref>", "Branch, tag or commit (default: the default branch)").option("--budget <tokens>", "Token budget").option("--include <globs>", "Comma-separated globs a file must match").option("--exclude <globs>", "Comma-separated globs to leave out").option("--out <file>", "Write the digest text to this file instead of stdout").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output (metadata and text)").action(async (opts) => {
10169
+ const qs = digestQuery(opts);
10170
+ const config = requireConfig();
10171
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
10172
+ const suffix = qs.toString() ? `?${qs}` : "";
10173
+ const result = await apiCall(`/v1/admin/projects/${projectId}/codebase/digest${suffix}`, config, {}, { timeoutMs: DIGEST_TIMEOUT_MS });
10174
+ if (!result.ok) die(result);
10175
+ if (outputIsJson(opts.json)) {
10176
+ console.log(JSON.stringify(result.data, null, 2));
10177
+ return;
10178
+ }
10179
+ if (opts.out) {
10180
+ writeUserOutputFile(opts.out, result.data.text, { maxBytes: MAX_DIGEST_FILE_BYTES });
10181
+ for (const line of digestSummary(result.data)) console.log(line);
10182
+ console.log(`Wrote ${opts.out}.`);
10183
+ return;
10184
+ }
10185
+ for (const line of digestSummary(result.data)) process.stderr.write(`${line}
10186
+ `);
10187
+ process.stdout.write(result.data.text.endsWith("\n") ? result.data.text : `${result.data.text}
10188
+ `);
10189
+ });
10190
+ const diagram = repo.command("diagram").description("The architecture diagram of your repo");
10191
+ diagram.command("show").description("The latest diagram as an outline, and its public page if published").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
10192
+ const config = requireConfig();
10193
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
10194
+ const result = await apiCall(`/v1/admin/projects/${projectId}/codebase/diagram`, config);
10195
+ if (!result.ok) die(result);
10196
+ if (outputIsJson(opts.json)) {
10197
+ console.log(JSON.stringify(result.data, null, 2));
10198
+ return;
10199
+ }
10200
+ for (const line of renderDiagram(result.data)) console.log(line);
10201
+ });
10202
+ diagram.command("generate").description("Draw the diagram for the latest commit (reuses the one for that commit unless --force)").option("--force", "Draw it again even if this commit already has one").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
10203
+ const config = requireConfig();
10204
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
10205
+ const result = await apiCall(
10206
+ `/v1/admin/projects/${projectId}/codebase/diagram`,
10207
+ config,
10208
+ { method: "POST", body: JSON.stringify({ force: opts.force === true }) },
10209
+ { timeoutMs: DIAGRAM_TIMEOUT_MS }
10210
+ );
10211
+ if (!result.ok) die(result);
10212
+ if (outputIsJson(opts.json)) {
10213
+ console.log(JSON.stringify(result.data, null, 2));
10214
+ return;
10215
+ }
10216
+ if (result.data.reused) console.log("This commit already has a diagram (pass --force to draw it again).");
10217
+ for (const line of renderDiagram(result.data)) console.log(line);
10218
+ });
10219
+ diagram.command("publish").description("Publish the latest diagram as a public page (project owners and admins)").option("--yes", "Confirm: the diagram becomes public").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
10220
+ const config = requireConfig();
10221
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
10222
+ const preview = await apiCall(`/v1/admin/projects/${projectId}/codebase/diagram/publish-preview`, config);
10223
+ if (!preview.ok) die(preview);
10224
+ const p3 = preview.data;
10225
+ if (!p3.can_publish) {
10226
+ throw new MushiCliError("E_INVALID_INPUT", p3.publish_blocked_reason ?? "This diagram cannot be published.", "publish it from the console instead");
10227
+ }
10228
+ console.log(`Will publish to ${p3.url}${p3.repo_private ? " (the repo is private on GitHub)" : ""}.`);
10229
+ requireYes(opts.yes, "Publishing makes the diagram public.");
10230
+ const result = await apiCall(
10231
+ `/v1/admin/projects/${projectId}/codebase/diagram/publish`,
10232
+ config,
10233
+ { method: "POST", body: JSON.stringify({ diagram_id: p3.diagram_id, payload_hash: p3.payload_hash }) },
10234
+ { timeoutMs: PUBLISH_TIMEOUT_MS2 }
10235
+ );
10236
+ if (!result.ok) die(result);
10237
+ if (outputIsJson(opts.json)) {
10238
+ console.log(JSON.stringify(result.data, null, 2));
10239
+ return;
10240
+ }
10241
+ console.log(`Published: ${result.data.url}`);
10242
+ console.log(`README badge: ${result.data.badge_markdown}`);
10243
+ });
10244
+ diagram.command("unpublish").description("Take the public diagram page down").option("--yes", "Confirm the unpublish").option("--project-id <id>", "Project ID (defaults to the configured project)").action(async (opts) => {
10245
+ requireYes(opts.yes, "This removes the public diagram page.");
10246
+ const config = requireConfig();
10247
+ const projectId = resolveProjectId(opts.projectId, config.projectId);
10248
+ const result = await apiCall(`/v1/admin/projects/${projectId}/codebase/diagram/publish`, config, { method: "DELETE" });
10249
+ if (!result.ok) die(result);
10250
+ console.log("The public diagram page is down.");
10251
+ });
10252
+ }
10253
+
10254
+ // src/commands/accounts.ts
10255
+ var MAX_EXPORT_FILE_BYTES = 8 * 1024 * 1024;
10256
+ var PROVIDERS = ["apple", "google_play", "aws", "supabase", "vercel", "registrar", "stripe", "github", "cloudflare", "other"];
10257
+ var ORG_FLAG = "--org <id>";
10258
+ var ORG_DESC2 = "Organization UUID (default: your only organization)";
10259
+ var tri = (v) => v === null ? "unknown" : v ? "yes" : "no";
10260
+ function parseTri(raw, flag) {
10261
+ const v = raw.trim().toLowerCase();
10262
+ if (v === "yes" || v === "true" || v === "on") return true;
10263
+ if (v === "no" || v === "false" || v === "off") return false;
10264
+ if (v === "unknown" || v === "null") return null;
10265
+ throw new MushiCliError("E_INVALID_INPUT", `${flag} must be yes, no or unknown`);
10266
+ }
10267
+ function accountBody(opts) {
10268
+ const body = {};
10269
+ if (opts.provider !== void 0) {
10270
+ if (!PROVIDERS.includes(opts.provider)) {
10271
+ throw new MushiCliError("E_INVALID_INPUT", `--provider must be one of ${PROVIDERS.join(", ")}`);
10272
+ }
10273
+ body.provider = opts.provider;
10274
+ }
10275
+ if (opts.name !== void 0) body.displayName = opts.name;
10276
+ if (opts.ownerEmail !== void 0) body.ownerEmail = opts.ownerEmail || null;
10277
+ if (opts.twoFactor !== void 0) body.twoFactorDeclared = parseTri(opts.twoFactor, "--two-factor");
10278
+ if (opts.recoveryContact !== void 0) body.recoveryContact = opts.recoveryContact || null;
10279
+ if (opts.admins !== void 0) {
10280
+ const n = Number(opts.admins);
10281
+ if (!Number.isInteger(n) || n < 1 || n > 100) throw new MushiCliError("E_INVALID_INPUT", "--admins must be a whole number from 1 to 100");
10282
+ body.adminCount = n;
10283
+ }
10284
+ if (opts.autoRenew !== void 0) body.autoRenew = parseTri(opts.autoRenew, "--auto-renew");
10285
+ return body;
10286
+ }
10287
+ function renderRegister(data) {
10288
+ const lines = [];
10289
+ if (data.accounts.length === 0) lines.push('No accounts recorded yet. Add one with: mushi accounts add --provider apple --name "Your team"');
10290
+ else {
10291
+ lines.push("Accounts:");
10292
+ for (const a of data.accounts) {
10293
+ lines.push(` ${a.provider.padEnd(12)} ${oneLine(a.name, 36).padEnd(36)} admins ${a.adminCount} \xB7 2FA ${tri(a.twoFactorDeclared)} \xB7 recovery ${a.recoveryContact ? "set" : "none"} (${a.id})`);
10294
+ }
10295
+ }
10296
+ if (data.domains.length > 0) {
10297
+ lines.push("Domains your apps use:");
10298
+ for (const d of data.domains) lines.push(` ${oneLine(d.domain, 40).padEnd(40)} auto-renew ${tri(d.autoRenew)} (${d.id})`);
10299
+ }
10300
+ if (data.findings.length > 0) {
10301
+ lines.push("To fix:");
10302
+ for (const f of data.findings) lines.push(` ${f.severity.toUpperCase().padEnd(5)} ${f.ruleId} \u2014 ${oneLine(f.message, 110)}`);
10303
+ }
10304
+ if (!data.canEdit) lines.push("Read-only: only team owners and admins change the register.");
10305
+ return lines;
10306
+ }
10307
+ function registerAccountsCommands(program2) {
10308
+ const accounts = program2.command("accounts").description("The accounts and resilience register: who can get into each store, cloud and registrar account (needs an account-level key)");
10309
+ accounts.command("list").description("Accounts, the domains your apps use, and what to fix").option(ORG_FLAG, ORG_DESC2).option("--json", "Machine-readable JSON output").action(async (opts) => {
10310
+ const config = requireConfig();
10311
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/accounts`, config);
10312
+ if (!result.ok) dieOrgError(result);
10313
+ if (outputIsJson(opts.json)) {
10314
+ console.log(JSON.stringify(result.data, null, 2));
10315
+ return;
10316
+ }
10317
+ for (const line of renderRegister(result.data)) console.log(line);
10318
+ });
10319
+ accounts.command("export").description("The register as Markdown, to keep with your recovery notes").option(ORG_FLAG, ORG_DESC2).option("--out <file>", "Write to this file instead of stdout").action(async (opts) => {
10320
+ const config = requireConfig();
10321
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/accounts/export`, config, {}, { text: true });
10322
+ if (!result.ok) dieOrgError(result);
10323
+ if (opts.out) {
10324
+ writeUserOutputFile(opts.out, result.data, { maxBytes: MAX_EXPORT_FILE_BYTES, mode: 384 });
10325
+ console.log(`Wrote ${opts.out}.`);
10326
+ return;
10327
+ }
10328
+ process.stdout.write(result.data.endsWith("\n") ? result.data : `${result.data}
10329
+ `);
10330
+ });
10331
+ const withAccountFlags = (cmd) => cmd.option("--provider <provider>", PROVIDERS.join(" | ")).option("--name <name>", "The account name, as the provider shows it").option("--owner-email <email>", "Who owns it (empty to clear)").option("--two-factor <state>", "yes | no | unknown").option("--recovery-contact <who>", "A person who can recover it, never a recovery code (empty to clear)").option("--admins <n>", "How many people can sign in as an admin").option("--auto-renew <state>", "yes | no | unknown").option(ORG_FLAG, ORG_DESC2).option("--json", "Machine-readable JSON output");
10332
+ withAccountFlags(accounts.command("add").description("Record an account (team owners and admins)")).action(async (opts) => {
10333
+ if (!opts.provider || !opts.name) throw new MushiCliError("E_INVALID_INPUT", "A new account needs --provider and --name.");
10334
+ const body = accountBody(opts);
10335
+ const config = requireConfig();
10336
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/accounts`, config, { method: "POST", body: JSON.stringify(body) });
10337
+ if (!result.ok) dieOrgError(result);
10338
+ if (outputIsJson(opts.json)) {
10339
+ console.log(JSON.stringify(result.data, null, 2));
10340
+ return;
10341
+ }
10342
+ console.log(`Recorded ${opts.provider} account "${oneLine(opts.name, 60)}" (${result.data.id}).`);
10343
+ });
10344
+ withAccountFlags(accounts.command("edit <accountId>").description("Change a recorded account (team owners and admins)")).action(async (accountId, opts) => {
10345
+ const id = requireUuid(accountId, "account id");
10346
+ const body = accountBody(opts);
10347
+ if (Object.keys(body).length === 0) throw new MushiCliError("E_INVALID_INPUT", "Nothing to change: pass at least one flag, such as --recovery-contact.");
10348
+ const config = requireConfig();
10349
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/accounts/${id}`, config, { method: "PATCH", body: JSON.stringify(body) });
10350
+ if (!result.ok) dieOrgError(result);
10351
+ if (outputIsJson(opts.json)) {
10352
+ console.log(JSON.stringify(result.data, null, 2));
10353
+ return;
10354
+ }
10355
+ console.log(`Saved ${id}.`);
10356
+ });
10357
+ accounts.command("remove <accountId>").description("Take an account off the register (nothing changes at the provider)").option("--yes", "Confirm the removal").option(ORG_FLAG, ORG_DESC2).action(async (accountId, opts) => {
10358
+ const id = requireUuid(accountId, "account id");
10359
+ requireYes(opts.yes, "This removes the account from the register.");
10360
+ const config = requireConfig();
10361
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/accounts/${id}`, config, { method: "DELETE" });
10362
+ if (!result.ok) dieOrgError(result);
10363
+ console.log(`Removed ${id} from the register.`);
10364
+ });
10365
+ accounts.command("domain <domainId>").description("Declare whether a domain renews automatically (what you declare; nothing is checked at the registrar)").requiredOption("--auto-renew <state>", "yes | no | unknown").option(ORG_FLAG, ORG_DESC2).action(async (domainId, opts) => {
10366
+ const id = requireUuid(domainId, "domain id");
10367
+ const autoRenew = parseTri(opts.autoRenew, "--auto-renew");
10368
+ const config = requireConfig();
10369
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/domains/${id}`, config, { method: "PATCH", body: JSON.stringify({ autoRenew }) });
10370
+ if (!result.ok) dieOrgError(result);
10371
+ console.log(`Auto-renew for ${id}: ${tri(autoRenew)}.`);
10372
+ });
10373
+ }
10374
+
10375
+ // src/commands/spend.ts
10376
+ import { basename as basename2 } from "path";
10377
+ var VENDORS = ["vercel", "aws", "supabase", "other"];
10378
+ var MAX_BILL_CSV_BYTES = 5 * 1024 * 1024;
10379
+ var IMPORT_TIMEOUT_MS2 = 6e4;
10380
+ var usd = (n) => `$${(n ?? 0).toFixed(2)}`;
10381
+ var ORG_FLAG2 = "--org <id>";
10382
+ var ORG_DESC3 = "Organization UUID (default: your only organization)";
10383
+ function renderLedger(d) {
10384
+ const lines = [`Spend ${d.from.slice(0, 10)} \u2192 ${d.to.slice(0, 10)} (${d.days} days): ${usd(d.totals.totalUsd)}`];
10385
+ lines.push(` Mushi AI ${usd(d.totals.mushiLlmUsd)} \xB7 provider AI ${usd(d.totals.providerLlmUsd)} \xB7 CI ${usd(d.totals.ciUsd)} \xB7 Supabase ${usd(d.totals.supabaseUsd)} \xB7 bills ${usd(d.totals.billsUsd)}`);
10386
+ for (const a of [...d.apps].sort((x, y) => y.totalUsd - x.totalUsd)) lines.push(` ${oneLine(a.name, 36).padEnd(36)} ${usd(a.totalUsd).padStart(10)}`);
10387
+ if (d.unattributedProviderUsd) lines.push(` Provider AI not bound to an app: ${usd(d.unattributedProviderUsd)}`);
10388
+ if (!d.complete) lines.push(" Some sources could not be read; the totals are a floor.");
10389
+ if (d.imports.length > 0) {
10390
+ lines.push("Recent bill imports:");
10391
+ for (const i of d.imports) lines.push(` ${i.vendor.padEnd(9)} ${usd(i.totalUsd).padStart(10)} ${i.periodStart ?? "\u2014"} \u2192 ${i.periodEnd ?? "\u2014"} ${oneLine(i.filename ?? "", 30)} ${fmtDate(i.createdAt)} (${i.id})`);
10392
+ }
10393
+ return lines;
10394
+ }
10395
+ function registerSpendCommands(program2) {
10396
+ const spend = program2.command("spend").description("What each app costs: Mushi AI, provider AI, CI, Supabase and imported bills (needs an account-level key)");
10397
+ spend.command("show").description("The 30-day ledger per app, and the latest bill imports").option(ORG_FLAG2, ORG_DESC3).option("--json", "Machine-readable JSON output").action(async (opts) => {
10398
+ const config = requireConfig();
10399
+ const result = await apiCall(`/v1/admin/orgs/${orgSegment(opts.org)}/spend`, config);
10400
+ if (!result.ok) dieOrgError(result);
10401
+ if (outputIsJson(opts.json)) {
10402
+ console.log(JSON.stringify(result.data, null, 2));
10403
+ return;
10404
+ }
10405
+ for (const line of renderLedger(result.data)) console.log(line);
10406
+ });
10407
+ spend.command("import <file>").description("Import a bill CSV (FOCUS, AWS CUR, or date,service,cost); a re-import of the same days replaces them").requiredOption("--vendor <vendor>", VENDORS.join(" | ")).option("--project-id <id>", "Put every row on this app (default: match an app column by name or slug)").option(ORG_FLAG2, ORG_DESC3).option("--json", "Machine-readable JSON output").action(async (file, opts) => {
10408
+ if (!VENDORS.includes(opts.vendor)) throw new MushiCliError("E_INVALID_INPUT", `--vendor must be one of ${VENDORS.join(", ")}`);
10409
+ const projectId = opts.projectId ? requireUuid(opts.projectId, "project id") : void 0;
10410
+ const read = readTextFileCapped(file, MAX_BILL_CSV_BYTES);
10411
+ if (read.kind === "absent") throw new MushiCliError("E_INVALID_INPUT", `Cannot read ${file}.`);
10412
+ if (read.kind === "too_large") throw new MushiCliError("E_INVALID_INPUT", `${file} is over 5 MB.`, "export daily rather than hourly rows, or split it by month");
10413
+ const csv = read.text;
10414
+ const config = requireConfig();
10415
+ const result = await apiCall(
10416
+ `/v1/admin/orgs/${orgSegment(opts.org)}/spend/imports`,
10417
+ config,
10418
+ { method: "POST", body: JSON.stringify({ vendor: opts.vendor, csv, filename: basename2(file), ...projectId ? { projectId } : {} }) },
10419
+ { timeoutMs: IMPORT_TIMEOUT_MS2 }
10420
+ );
10421
+ if (!result.ok) dieOrgError(result);
10422
+ const r = result.data;
10423
+ if (outputIsJson(opts.json)) {
10424
+ console.log(JSON.stringify(r, null, 2));
10425
+ return;
10426
+ }
10427
+ console.log(`Imported ${r.rowsImported} of ${r.rowsRead} row(s) (${r.format}), ${usd(r.totalUsd)} for ${r.periodStart ?? "\u2014"} \u2192 ${r.periodEnd ?? "\u2014"}. Import ${r.importId}.`);
10428
+ if (r.unmatchedApps.length > 0) console.log(` No app matched: ${r.unmatchedApps.map((a) => oneLine(a, 40)).join(", ")}`);
10429
+ for (const reason of r.skipReasons.slice(0, 5)) console.log(` Skipped: ${oneLine(reason, 120)}`);
10430
+ });
10431
+ spend.command("remove <importId>").description("Remove a bill import; its days go back to the next newest import that has them").option("--yes", "Confirm the removal").option(ORG_FLAG2, ORG_DESC3).action(async (importId, opts) => {
10432
+ const id = requireUuid(importId, "import id");
10433
+ requireYes(opts.yes, "This removes the import and its rows from the ledger.");
10434
+ const config = requireConfig();
10435
+ const result = await apiCall(
10436
+ `/v1/admin/orgs/${orgSegment(opts.org)}/spend/imports/${id}`,
10437
+ config,
10438
+ { method: "DELETE" }
10439
+ );
10440
+ if (!result.ok) dieOrgError(result);
10441
+ console.log(`Removed import ${id}: ${result.data.rowsRemoved} row(s) removed, ${result.data.rowsRestored} restored from ${result.data.restoredFrom} older import(s).`);
10442
+ });
10443
+ }
10444
+
10445
+ // src/commands/design.ts
10446
+ function parseOnOff(raw, flag) {
10447
+ const v = raw.trim().toLowerCase();
10448
+ if (v === "on" || v === "true" || v === "yes") return true;
10449
+ if (v === "off" || v === "false" || v === "no") return false;
10450
+ throw new MushiCliError("E_INVALID_INPUT", `${flag} must be on or off`);
10451
+ }
10452
+ function renderSettings(s) {
10453
+ const lines = [
10454
+ `Threshold: ${s.threshold} (the actions below fire when the design score is above it)`,
10455
+ ` Fail CI: ${s.failCi ? "on" : "off"} (mushi recipe check --push exits non-zero above it)`,
10456
+ ` Auto-fix: ${s.autofix ? "on" : "off"}${s.autofix && !s.autofixEnabled ? " (does nothing: the project auto-fix switch is off)" : ""}`
10457
+ ];
10458
+ if (!s.canEdit) lines.push("Read-only: only project owners and admins change these.");
10459
+ return lines;
10460
+ }
10461
+ function registerDesignCommands(program2) {
10462
+ const design = program2.command("design").description("What the design deviance score may do on its own: fail CI, dispatch a fix");
10463
+ design.command("settings").description("Show the threshold and the actions above it").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
10464
+ const config = requireConfig();
10465
+ const pid = resolveProjectId(opts.projectId, config.projectId);
10466
+ const result = await apiCall(`/v1/admin/projects/${pid}/design/settings`, config);
10467
+ if (!result.ok) die(result);
10468
+ if (outputIsJson(opts.json)) {
10469
+ console.log(JSON.stringify(result.data, null, 2));
10470
+ return;
10471
+ }
10472
+ for (const line of renderSettings(result.data)) console.log(line);
10473
+ });
10474
+ design.command("set").description("Change the threshold, fail CI, or turn the design auto-fix off (project owners and admins)").option("--threshold <n>", "Score 0-100 above which the actions fire").option("--fail-ci <state>", "on | off").option("--autofix <state>", "off (turning it on needs the console)").option("--project-id <id>", "Project ID (defaults to the configured project)").option("--json", "Machine-readable JSON output").action(async (opts) => {
10475
+ const body = {};
10476
+ if (opts.threshold !== void 0) {
10477
+ const n = Number(opts.threshold);
10478
+ if (!Number.isInteger(n) || n < 0 || n > 100) throw new MushiCliError("E_INVALID_INPUT", "--threshold must be a whole number from 0 to 100");
10479
+ body.threshold = n;
10480
+ }
10481
+ if (opts.failCi !== void 0) body.failCi = parseOnOff(opts.failCi, "--fail-ci");
10482
+ if (opts.autofix !== void 0) body.autofix = parseOnOff(opts.autofix, "--autofix");
10483
+ if (Object.keys(body).length === 0) throw new MushiCliError("E_INVALID_INPUT", "Nothing to change: pass --threshold, --fail-ci or --autofix.");
10484
+ const config = requireConfig();
10485
+ const pid = resolveProjectId(opts.projectId, config.projectId);
10486
+ const result = await apiCall(`/v1/admin/projects/${pid}/design/settings`, config, { method: "PUT", body: JSON.stringify(body) });
10487
+ if (!result.ok) die(result);
10488
+ if (outputIsJson(opts.json)) {
10489
+ console.log(JSON.stringify(result.data, null, 2));
10490
+ return;
10491
+ }
10492
+ console.log("Saved.");
10493
+ for (const line of renderSettings(result.data)) console.log(line);
10494
+ });
10495
+ }
10496
+
6679
10497
  // src/index.ts
6680
10498
  installSignalHandlers();
6681
10499
  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) => {
@@ -6724,6 +10542,21 @@ registerSkillsCommands(program);
6724
10542
  registerBillingCommands(program);
6725
10543
  registerSelfhostCommands(program);
6726
10544
  registerProfileCommands(program);
10545
+ registerRadarCommands(program);
10546
+ registerRecipeCommands(program);
10547
+ registerStoreCommands(program);
10548
+ registerPortfolioCommands(program);
10549
+ registerConnectorsCommands(program);
10550
+ registerFunnelCommands(program);
10551
+ registerCodeHealthCommands(program);
10552
+ registerReleasesCommands(program);
10553
+ registerSentryCommands(program);
10554
+ registerOutboxCommands(program);
10555
+ registerBudgetsCommands(program);
10556
+ registerRepoCommands(program);
10557
+ registerAccountsCommands(program);
10558
+ registerSpendCommands(program);
10559
+ registerDesignCommands(program);
6727
10560
  registerCompletionCommand(program);
6728
10561
  program.parseAsync().catch((err) => {
6729
10562
  printAndExit(err);