@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/README.md +1 -1
- package/dist/chunk-HHOPCVNI.js +10 -0
- package/dist/chunk-JE3STWB2.js +8 -0
- package/dist/{chunk-AV3RKAEE.js → chunk-KQ6POXZH.js} +35 -4
- package/dist/{chunk-FJETUM25.js → chunk-MDYYLRWB.js} +36 -4
- package/dist/{connect-C673EKS3.js → connect-35YLLMGD.js} +2 -2
- package/dist/{connect-63GYZLFM.js → connect-RIPZ4B5Y.js} +2 -2
- package/dist/index.js +3999 -166
- package/dist/init.js +3 -3
- package/dist/{version-VR37VEE4.js → version-FYBMIU3T.js} +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-U2VGB63C.js +0 -8
- package/dist/chunk-UVO4A2G6.js +0 -10
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-
|
|
56
|
+
} from "./chunk-MDYYLRWB.js";
|
|
56
57
|
import {
|
|
57
58
|
MUSHI_CLI_VERSION,
|
|
58
59
|
MUSHI_MCP_PIN_SPEC
|
|
59
|
-
} from "./chunk-
|
|
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
|
|
112
|
-
if (!res.ok || !
|
|
112
|
+
const json2 = await res.json().catch(() => null);
|
|
113
|
+
if (!res.ok || !json2?.ok || !json2.data) {
|
|
113
114
|
throw new Error(
|
|
114
|
-
|
|
115
|
+
json2?.error?.message ?? `Could not start browser sign-in (HTTP ${res.status}).`
|
|
115
116
|
);
|
|
116
117
|
}
|
|
117
|
-
return
|
|
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
|
|
128
|
-
if (res.ok &&
|
|
129
|
-
return { status: "approved", cliToken:
|
|
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 (
|
|
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:
|
|
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
|
|
253
|
-
if (res.ok &&
|
|
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
|
-
|
|
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
|
|
269
|
-
if (!res.ok || !
|
|
270
|
-
throw new Error(
|
|
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 } =
|
|
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
|
|
294
|
-
if (res.ok &&
|
|
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
|
-
|
|
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
|
|
321
|
-
if (res.ok &&
|
|
321
|
+
const json2 = await res.json().catch(() => null);
|
|
322
|
+
if (res.ok && json2?.ok) {
|
|
322
323
|
return {
|
|
323
|
-
revoked:
|
|
324
|
-
revokedAt:
|
|
324
|
+
revoked: json2.revoked ?? 0,
|
|
325
|
+
revokedAt: json2.revokedAt
|
|
325
326
|
};
|
|
326
327
|
}
|
|
327
328
|
throw new DeviceAuthRequestError(
|
|
328
329
|
"revoke_key",
|
|
329
|
-
|
|
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
|
|
442
|
-
if (
|
|
443
|
-
const picked = projectsList[
|
|
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((
|
|
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
|
|
871
|
-
const ok = res.ok &&
|
|
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 "${
|
|
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:
|
|
898
|
-
const root =
|
|
899
|
-
const pkgPath =
|
|
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:
|
|
1110
|
-
const root =
|
|
1111
|
-
const pkgPath =
|
|
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(
|
|
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",
|
|
1135
|
+
for (const rel of [".mcp.json", join10(".cursor", "mcp.json")]) {
|
|
1135
1136
|
try {
|
|
1136
|
-
await access(
|
|
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:
|
|
1179
|
+
const { join: join10 } = await import("path");
|
|
1179
1180
|
let count = 0;
|
|
1180
|
-
async function
|
|
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 =
|
|
1191
|
+
const full = join10(d, e.name);
|
|
1191
1192
|
if (e.isDirectory()) {
|
|
1192
|
-
const found = await
|
|
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:
|
|
1206
|
+
const { resolve: resolve7 } = await import("path");
|
|
1206
1207
|
for (const sub of ["src", "app", "pages", "lib", "components"]) {
|
|
1207
|
-
const found = await
|
|
1208
|
+
const found = await walk2(join10(resolve7(dir), sub), 0);
|
|
1208
1209
|
if (found) return found;
|
|
1209
1210
|
}
|
|
1210
|
-
return
|
|
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:
|
|
1244
|
+
const { join: join10 } = await import("path");
|
|
1244
1245
|
const sources = [];
|
|
1245
1246
|
const invalid = [];
|
|
1246
1247
|
const plainFiles = [
|
|
1247
|
-
{ path:
|
|
1248
|
-
{ path:
|
|
1249
|
-
{ path:
|
|
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 =
|
|
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:
|
|
1318
|
+
const { resolve: resolve7 } = await import("path");
|
|
1318
1319
|
const { homedir } = await import("os");
|
|
1319
|
-
const root =
|
|
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:
|
|
1444
|
-
const root =
|
|
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(
|
|
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-
|
|
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:
|
|
1545
|
-
const pkg = JSON.parse(await readFile3(
|
|
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((
|
|
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)
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
3357
|
+
await walk2(dir);
|
|
3357
3358
|
return results;
|
|
3358
3359
|
}
|
|
3359
3360
|
function fileHash(path) {
|
|
3360
|
-
return new Promise((
|
|
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", () =>
|
|
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
|
|
3401
|
-
if (
|
|
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
|
|
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*
|
|
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 (
|
|
3572
|
-
if (e.isDirectory()) yield*
|
|
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
|
|
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
|
|
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(` ${
|
|
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:
|
|
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 ${
|
|
3605
|
+
process.stderr.write(` FAIL ${relative4}: ${result.error.message}
|
|
3605
3606
|
`);
|
|
3606
3607
|
} else if (!opts.json) {
|
|
3607
|
-
process.stdout.write(` ok ${
|
|
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((
|
|
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
|
|
3751
|
-
if (
|
|
3752
|
-
chosen = projectsList[
|
|
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
|
-
(
|
|
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
|
|
4234
|
+
const sep3 = existing.endsWith("\n") || existing === "" ? "" : "\n";
|
|
4234
4235
|
await writeFile3(
|
|
4235
4236
|
gitignorePath,
|
|
4236
|
-
`${existing}${
|
|
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", {
|
|
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 (!
|
|
4322
|
-
console.error("No
|
|
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/${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
5756
|
-
|
|
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
|
-
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
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
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
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
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
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
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
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
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
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
|
-
|
|
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);
|