uilint 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2155 -253
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/skills/ui-consistency-enforcer/SKILL.md +445 -0
- package/skills/ui-consistency-enforcer/references/REGISTRY-ENTRY.md +163 -0
- package/skills/ui-consistency-enforcer/references/RULE-TEMPLATE.ts +253 -0
- package/skills/ui-consistency-enforcer/references/TEST-TEMPLATE.ts +496 -0
package/dist/index.js
CHANGED
|
@@ -237,8 +237,8 @@ async function initializeLangfuseIfEnabled() {
|
|
|
237
237
|
},
|
|
238
238
|
{ asType: "generation" }
|
|
239
239
|
);
|
|
240
|
-
await new Promise((
|
|
241
|
-
resolveTrace =
|
|
240
|
+
await new Promise((resolve8) => {
|
|
241
|
+
resolveTrace = resolve8;
|
|
242
242
|
});
|
|
243
243
|
if (endData && generationRef) {
|
|
244
244
|
const usageDetails = endData.usage ? Object.fromEntries(
|
|
@@ -329,6 +329,26 @@ function registerShutdownHandler() {
|
|
|
329
329
|
}
|
|
330
330
|
registerShutdownHandler();
|
|
331
331
|
|
|
332
|
+
// src/utils/timing.ts
|
|
333
|
+
function nsNow() {
|
|
334
|
+
return process.hrtime.bigint();
|
|
335
|
+
}
|
|
336
|
+
function nsToMs(ns) {
|
|
337
|
+
return Number(ns) / 1e6;
|
|
338
|
+
}
|
|
339
|
+
function formatMs(ms) {
|
|
340
|
+
if (!Number.isFinite(ms)) return "n/a";
|
|
341
|
+
if (ms < 1e3) return `${ms.toFixed(ms < 10 ? 2 : ms < 100 ? 1 : 0)}ms`;
|
|
342
|
+
const s = ms / 1e3;
|
|
343
|
+
if (s < 60) return `${s.toFixed(s < 10 ? 2 : 1)}s`;
|
|
344
|
+
const m = Math.floor(s / 60);
|
|
345
|
+
const rem = s - m * 60;
|
|
346
|
+
return `${m}m ${rem.toFixed(rem < 10 ? 1 : 0)}s`;
|
|
347
|
+
}
|
|
348
|
+
function maybeMs(ms) {
|
|
349
|
+
return ms == null ? "n/a" : formatMs(ms);
|
|
350
|
+
}
|
|
351
|
+
|
|
332
352
|
// src/utils/prompts.ts
|
|
333
353
|
import * as p from "@clack/prompts";
|
|
334
354
|
import pc from "picocolors";
|
|
@@ -337,8 +357,8 @@ import { dirname, join as join2 } from "path";
|
|
|
337
357
|
import { fileURLToPath } from "url";
|
|
338
358
|
function getCLIVersion() {
|
|
339
359
|
try {
|
|
340
|
-
const
|
|
341
|
-
const pkgPath = join2(
|
|
360
|
+
const __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
361
|
+
const pkgPath = join2(__dirname2, "..", "..", "package.json");
|
|
342
362
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
343
363
|
return pkg.version || "0.0.0";
|
|
344
364
|
} catch {
|
|
@@ -369,7 +389,7 @@ async function withSpinner(message, fn) {
|
|
|
369
389
|
const s = p.spinner();
|
|
370
390
|
s.start(message);
|
|
371
391
|
try {
|
|
372
|
-
const result = await fn();
|
|
392
|
+
const result = fn.length >= 1 ? await fn(s) : await fn();
|
|
373
393
|
s.stop(pc.green("\u2713 ") + message);
|
|
374
394
|
return result;
|
|
375
395
|
} catch (error) {
|
|
@@ -642,6 +662,7 @@ async function scan(options) {
|
|
|
642
662
|
} else if (dbg && styleSummary) {
|
|
643
663
|
debugLog(dbg, "Style summary (preview)", preview(styleSummary, 800));
|
|
644
664
|
}
|
|
665
|
+
const prepStartNs = nsNow();
|
|
645
666
|
if (!isJsonOutput) {
|
|
646
667
|
await withSpinner("Preparing Ollama", async () => {
|
|
647
668
|
await ensureOllamaReady();
|
|
@@ -649,6 +670,7 @@ async function scan(options) {
|
|
|
649
670
|
} else {
|
|
650
671
|
await ensureOllamaReady();
|
|
651
672
|
}
|
|
673
|
+
const prepEndNs = nsNow();
|
|
652
674
|
const client = await createLLMClient({});
|
|
653
675
|
let result;
|
|
654
676
|
const prompt = snapshot.kind === "dom" ? buildAnalysisPrompt(styleSummary ?? "", styleGuide) : buildSourceAnalysisPrompt(snapshot.source, styleGuide, {
|
|
@@ -764,7 +786,33 @@ async function scan(options) {
|
|
|
764
786
|
} else {
|
|
765
787
|
const s = createSpinner();
|
|
766
788
|
s.start("Analyzing with LLM");
|
|
767
|
-
|
|
789
|
+
let thinkingStarted = false;
|
|
790
|
+
const analysisStartNs = nsNow();
|
|
791
|
+
let firstTokenNs = null;
|
|
792
|
+
let firstThinkingNs = null;
|
|
793
|
+
let lastThinkingNs = null;
|
|
794
|
+
let firstAnswerNs = null;
|
|
795
|
+
let lastAnswerNs = null;
|
|
796
|
+
const onProgress = (latestLine, _fullResponse, delta, thinkingDelta) => {
|
|
797
|
+
const nowNs = nsNow();
|
|
798
|
+
if (!firstTokenNs && (thinkingDelta || delta)) firstTokenNs = nowNs;
|
|
799
|
+
if (thinkingDelta) {
|
|
800
|
+
if (!firstThinkingNs) firstThinkingNs = nowNs;
|
|
801
|
+
lastThinkingNs = nowNs;
|
|
802
|
+
}
|
|
803
|
+
if (delta) {
|
|
804
|
+
if (!firstAnswerNs) firstAnswerNs = nowNs;
|
|
805
|
+
lastAnswerNs = nowNs;
|
|
806
|
+
}
|
|
807
|
+
if (thinkingDelta) {
|
|
808
|
+
if (!thinkingStarted) {
|
|
809
|
+
thinkingStarted = true;
|
|
810
|
+
s.stop(pc.dim("[scan] streaming thinking:"));
|
|
811
|
+
process.stderr.write(pc.dim("Thinking:\n"));
|
|
812
|
+
}
|
|
813
|
+
process.stderr.write(thinkingDelta);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
768
816
|
const maxLen = 60;
|
|
769
817
|
const displayLine = latestLine.length > maxLen ? latestLine.slice(0, maxLen) + "\u2026" : latestLine;
|
|
770
818
|
s.message(`Analyzing: ${pc.dim(displayLine || "...")}`);
|
|
@@ -790,6 +838,29 @@ async function scan(options) {
|
|
|
790
838
|
}
|
|
791
839
|
);
|
|
792
840
|
s.stop(pc.green("\u2713 ") + "Analyzing with LLM");
|
|
841
|
+
if (process.stdout.isTTY) {
|
|
842
|
+
const analysisEndNs = nsNow();
|
|
843
|
+
const prepMs = nsToMs(prepEndNs - prepStartNs);
|
|
844
|
+
const totalMs = nsToMs(analysisEndNs - analysisStartNs);
|
|
845
|
+
const endToEndMs = nsToMs(analysisEndNs - prepStartNs);
|
|
846
|
+
const ttftMs = firstTokenNs ? nsToMs(firstTokenNs - analysisStartNs) : null;
|
|
847
|
+
const thinkingMs = firstThinkingNs && (firstAnswerNs || lastThinkingNs) ? nsToMs(
|
|
848
|
+
(firstAnswerNs ?? lastThinkingNs ?? analysisEndNs) - firstThinkingNs
|
|
849
|
+
) : null;
|
|
850
|
+
const outputMs = firstAnswerNs && (lastAnswerNs || analysisEndNs) ? nsToMs((lastAnswerNs ?? analysisEndNs) - firstAnswerNs) : null;
|
|
851
|
+
note2(
|
|
852
|
+
[
|
|
853
|
+
`Prepare Ollama: ${formatMs(prepMs)}`,
|
|
854
|
+
`Time to first token: ${maybeMs(ttftMs)}`,
|
|
855
|
+
`Thinking: ${maybeMs(thinkingMs)}`,
|
|
856
|
+
`Outputting: ${maybeMs(outputMs)}`,
|
|
857
|
+
`LLM total: ${formatMs(totalMs)}`,
|
|
858
|
+
`End-to-end: ${formatMs(endToEndMs)}`,
|
|
859
|
+
result?.analysisTime ? pc.dim(`(core analysisTime: ${formatMs(result.analysisTime)})`) : pc.dim("(core analysisTime: n/a)")
|
|
860
|
+
].join("\n"),
|
|
861
|
+
"Timings"
|
|
862
|
+
);
|
|
863
|
+
}
|
|
793
864
|
} catch (error) {
|
|
794
865
|
s.stop(pc.red("\u2717 ") + "Analyzing with LLM");
|
|
795
866
|
throw error;
|
|
@@ -970,6 +1041,7 @@ async function analyze(options) {
|
|
|
970
1041
|
} else if (resolvedStyle.path && !isJsonOutput) {
|
|
971
1042
|
logSuccess(`Using styleguide: ${pc.dim(resolvedStyle.path)}`);
|
|
972
1043
|
}
|
|
1044
|
+
const prepStartNs = nsNow();
|
|
973
1045
|
if (!isJsonOutput) {
|
|
974
1046
|
await withSpinner("Preparing Ollama", async () => {
|
|
975
1047
|
await ensureOllamaReady2();
|
|
@@ -977,6 +1049,7 @@ async function analyze(options) {
|
|
|
977
1049
|
} else {
|
|
978
1050
|
await ensureOllamaReady2();
|
|
979
1051
|
}
|
|
1052
|
+
const prepEndNs = nsNow();
|
|
980
1053
|
const client = await createLLMClient({});
|
|
981
1054
|
const promptContext = {
|
|
982
1055
|
filePath: options.filePath || (options.inputFile ? options.inputFile : void 0) || "component.tsx",
|
|
@@ -1065,17 +1138,65 @@ async function analyze(options) {
|
|
|
1065
1138
|
if (!isJsonOutput) {
|
|
1066
1139
|
const s = createSpinner();
|
|
1067
1140
|
s.start("Analyzing with LLM");
|
|
1141
|
+
let thinkingStarted = false;
|
|
1142
|
+
const analysisStartNs = nsNow();
|
|
1143
|
+
let firstTokenNs = null;
|
|
1144
|
+
let firstThinkingNs = null;
|
|
1145
|
+
let lastThinkingNs = null;
|
|
1146
|
+
let firstAnswerNs = null;
|
|
1147
|
+
let lastAnswerNs = null;
|
|
1068
1148
|
try {
|
|
1069
1149
|
responseText = await client.complete(prompt, {
|
|
1070
1150
|
json: true,
|
|
1071
1151
|
stream: true,
|
|
1072
|
-
onProgress: (latestLine) => {
|
|
1152
|
+
onProgress: (latestLine, _fullResponse, delta, thinkingDelta) => {
|
|
1153
|
+
const nowNs = nsNow();
|
|
1154
|
+
if (!firstTokenNs && (thinkingDelta || delta)) firstTokenNs = nowNs;
|
|
1155
|
+
if (thinkingDelta) {
|
|
1156
|
+
if (!firstThinkingNs) firstThinkingNs = nowNs;
|
|
1157
|
+
lastThinkingNs = nowNs;
|
|
1158
|
+
}
|
|
1159
|
+
if (delta) {
|
|
1160
|
+
if (!firstAnswerNs) firstAnswerNs = nowNs;
|
|
1161
|
+
lastAnswerNs = nowNs;
|
|
1162
|
+
}
|
|
1163
|
+
if (thinkingDelta) {
|
|
1164
|
+
if (!thinkingStarted) {
|
|
1165
|
+
thinkingStarted = true;
|
|
1166
|
+
s.stop(pc.dim("[analyze] streaming thinking:"));
|
|
1167
|
+
process.stderr.write(pc.dim("Thinking:\n"));
|
|
1168
|
+
}
|
|
1169
|
+
process.stderr.write(thinkingDelta);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1073
1172
|
const maxLen = 60;
|
|
1074
1173
|
const line = latestLine.length > maxLen ? latestLine.slice(0, maxLen) + "\u2026" : latestLine;
|
|
1075
1174
|
s.message(`Analyzing: ${pc.dim(line || "...")}`);
|
|
1076
1175
|
}
|
|
1077
1176
|
});
|
|
1078
1177
|
s.stop(pc.green("\u2713 ") + "Analyzing with LLM");
|
|
1178
|
+
if (process.stdout.isTTY) {
|
|
1179
|
+
const analysisEndNs = nsNow();
|
|
1180
|
+
const prepMs = nsToMs(prepEndNs - prepStartNs);
|
|
1181
|
+
const totalMs = nsToMs(analysisEndNs - analysisStartNs);
|
|
1182
|
+
const endToEndMs = nsToMs(analysisEndNs - prepStartNs);
|
|
1183
|
+
const ttftMs = firstTokenNs ? nsToMs(firstTokenNs - analysisStartNs) : null;
|
|
1184
|
+
const thinkingMs = firstThinkingNs && (firstAnswerNs || lastThinkingNs) ? nsToMs(
|
|
1185
|
+
(firstAnswerNs ?? lastThinkingNs ?? analysisEndNs) - firstThinkingNs
|
|
1186
|
+
) : null;
|
|
1187
|
+
const outputMs = firstAnswerNs && (lastAnswerNs || analysisEndNs) ? nsToMs((lastAnswerNs ?? analysisEndNs) - firstAnswerNs) : null;
|
|
1188
|
+
note2(
|
|
1189
|
+
[
|
|
1190
|
+
`Prepare Ollama: ${formatMs(prepMs)}`,
|
|
1191
|
+
`Time to first token: ${maybeMs(ttftMs)}`,
|
|
1192
|
+
`Thinking: ${maybeMs(thinkingMs)}`,
|
|
1193
|
+
`Outputting: ${maybeMs(outputMs)}`,
|
|
1194
|
+
`LLM total: ${formatMs(totalMs)}`,
|
|
1195
|
+
`End-to-end: ${formatMs(endToEndMs)}`
|
|
1196
|
+
].join("\n"),
|
|
1197
|
+
"Timings"
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1079
1200
|
} catch (e) {
|
|
1080
1201
|
s.stop(pc.red("\u2717 ") + "Analyzing with LLM");
|
|
1081
1202
|
throw e;
|
|
@@ -1128,14 +1249,14 @@ import {
|
|
|
1128
1249
|
} from "uilint-core";
|
|
1129
1250
|
import { ensureOllamaReady as ensureOllamaReady3 } from "uilint-core/node";
|
|
1130
1251
|
async function readStdin2() {
|
|
1131
|
-
return new Promise((
|
|
1252
|
+
return new Promise((resolve8) => {
|
|
1132
1253
|
let data = "";
|
|
1133
1254
|
const rl = createInterface({ input: process.stdin });
|
|
1134
1255
|
rl.on("line", (line) => {
|
|
1135
1256
|
data += line;
|
|
1136
1257
|
});
|
|
1137
1258
|
rl.on("close", () => {
|
|
1138
|
-
|
|
1259
|
+
resolve8(data);
|
|
1139
1260
|
});
|
|
1140
1261
|
});
|
|
1141
1262
|
}
|
|
@@ -1354,12 +1475,12 @@ async function update(options) {
|
|
|
1354
1475
|
}
|
|
1355
1476
|
|
|
1356
1477
|
// src/commands/install.ts
|
|
1357
|
-
import { join as
|
|
1478
|
+
import { join as join15 } from "path";
|
|
1358
1479
|
import { ruleRegistry as ruleRegistry2 } from "uilint-eslint";
|
|
1359
1480
|
|
|
1360
1481
|
// src/commands/install/analyze.ts
|
|
1361
|
-
import { existsSync as
|
|
1362
|
-
import { join as
|
|
1482
|
+
import { existsSync as existsSync9, readFileSync as readFileSync5 } from "fs";
|
|
1483
|
+
import { join as join8 } from "path";
|
|
1363
1484
|
import { findWorkspaceRoot as findWorkspaceRoot4 } from "uilint-core/node";
|
|
1364
1485
|
|
|
1365
1486
|
// src/utils/next-detect.ts
|
|
@@ -1444,10 +1565,113 @@ function findNextAppRouterProjects(rootDir, options) {
|
|
|
1444
1565
|
return results;
|
|
1445
1566
|
}
|
|
1446
1567
|
|
|
1447
|
-
// src/utils/
|
|
1568
|
+
// src/utils/vite-detect.ts
|
|
1448
1569
|
import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
|
|
1449
|
-
import { join as join4
|
|
1570
|
+
import { join as join4 } from "path";
|
|
1571
|
+
var VITE_CONFIG_EXTS = [".ts", ".mjs", ".js", ".cjs"];
|
|
1572
|
+
function findViteConfigFile(projectPath) {
|
|
1573
|
+
for (const ext of VITE_CONFIG_EXTS) {
|
|
1574
|
+
const rel = `vite.config${ext}`;
|
|
1575
|
+
if (existsSync5(join4(projectPath, rel))) return rel;
|
|
1576
|
+
}
|
|
1577
|
+
return null;
|
|
1578
|
+
}
|
|
1579
|
+
function looksLikeReactPackage(projectPath) {
|
|
1580
|
+
try {
|
|
1581
|
+
const pkgPath = join4(projectPath, "package.json");
|
|
1582
|
+
if (!existsSync5(pkgPath)) return false;
|
|
1583
|
+
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
1584
|
+
const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
1585
|
+
return "react" in deps || "react-dom" in deps;
|
|
1586
|
+
} catch {
|
|
1587
|
+
return false;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function fileExists2(projectPath, relPath) {
|
|
1591
|
+
return existsSync5(join4(projectPath, relPath));
|
|
1592
|
+
}
|
|
1593
|
+
function detectViteReact(projectPath) {
|
|
1594
|
+
const configFile = findViteConfigFile(projectPath);
|
|
1595
|
+
if (!configFile) return null;
|
|
1596
|
+
if (!looksLikeReactPackage(projectPath)) return null;
|
|
1597
|
+
const entryRoot = "src";
|
|
1598
|
+
const candidates = [];
|
|
1599
|
+
const entryCandidates = [
|
|
1600
|
+
join4(entryRoot, "main.tsx"),
|
|
1601
|
+
join4(entryRoot, "main.jsx"),
|
|
1602
|
+
join4(entryRoot, "main.ts"),
|
|
1603
|
+
join4(entryRoot, "main.js")
|
|
1604
|
+
];
|
|
1605
|
+
for (const rel of entryCandidates) {
|
|
1606
|
+
if (fileExists2(projectPath, rel)) candidates.push(rel);
|
|
1607
|
+
}
|
|
1608
|
+
const fallbackCandidates = [
|
|
1609
|
+
join4(entryRoot, "App.tsx"),
|
|
1610
|
+
join4(entryRoot, "App.jsx")
|
|
1611
|
+
];
|
|
1612
|
+
for (const rel of fallbackCandidates) {
|
|
1613
|
+
if (!candidates.includes(rel) && fileExists2(projectPath, rel)) {
|
|
1614
|
+
candidates.push(rel);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
return {
|
|
1618
|
+
configFile,
|
|
1619
|
+
configFileAbs: join4(projectPath, configFile),
|
|
1620
|
+
entryRoot,
|
|
1621
|
+
candidates
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1450
1624
|
var DEFAULT_IGNORE_DIRS2 = /* @__PURE__ */ new Set([
|
|
1625
|
+
"node_modules",
|
|
1626
|
+
".git",
|
|
1627
|
+
".next",
|
|
1628
|
+
"dist",
|
|
1629
|
+
"build",
|
|
1630
|
+
"out",
|
|
1631
|
+
".turbo",
|
|
1632
|
+
".vercel",
|
|
1633
|
+
".cursor",
|
|
1634
|
+
"coverage",
|
|
1635
|
+
".uilint"
|
|
1636
|
+
]);
|
|
1637
|
+
function findViteReactProjects(rootDir, options) {
|
|
1638
|
+
const maxDepth = options?.maxDepth ?? 4;
|
|
1639
|
+
const ignoreDirs = options?.ignoreDirs ?? DEFAULT_IGNORE_DIRS2;
|
|
1640
|
+
const results = [];
|
|
1641
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1642
|
+
function walk(dir, depth) {
|
|
1643
|
+
if (depth > maxDepth) return;
|
|
1644
|
+
if (visited.has(dir)) return;
|
|
1645
|
+
visited.add(dir);
|
|
1646
|
+
const detection = detectViteReact(dir);
|
|
1647
|
+
if (detection) {
|
|
1648
|
+
results.push({ projectPath: dir, detection });
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
let entries = [];
|
|
1652
|
+
try {
|
|
1653
|
+
entries = readdirSync2(dir, { withFileTypes: true }).map((d) => ({
|
|
1654
|
+
name: d.name,
|
|
1655
|
+
isDirectory: d.isDirectory()
|
|
1656
|
+
}));
|
|
1657
|
+
} catch {
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
for (const ent of entries) {
|
|
1661
|
+
if (!ent.isDirectory) continue;
|
|
1662
|
+
if (ignoreDirs.has(ent.name)) continue;
|
|
1663
|
+
if (ent.name.startsWith(".") && ent.name !== ".") continue;
|
|
1664
|
+
walk(join4(dir, ent.name), depth + 1);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
walk(rootDir, 0);
|
|
1668
|
+
return results;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// src/utils/package-detect.ts
|
|
1672
|
+
import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync3 } from "fs";
|
|
1673
|
+
import { join as join5, relative } from "path";
|
|
1674
|
+
var DEFAULT_IGNORE_DIRS3 = /* @__PURE__ */ new Set([
|
|
1451
1675
|
"node_modules",
|
|
1452
1676
|
".git",
|
|
1453
1677
|
".next",
|
|
@@ -1463,6 +1687,7 @@ var DEFAULT_IGNORE_DIRS2 = /* @__PURE__ */ new Set([
|
|
|
1463
1687
|
]);
|
|
1464
1688
|
var ESLINT_CONFIG_FILES = [
|
|
1465
1689
|
"eslint.config.js",
|
|
1690
|
+
"eslint.config.ts",
|
|
1466
1691
|
"eslint.config.mjs",
|
|
1467
1692
|
"eslint.config.cjs",
|
|
1468
1693
|
".eslintrc.js",
|
|
@@ -1491,14 +1716,14 @@ function isFrontendPackage(pkgJson) {
|
|
|
1491
1716
|
}
|
|
1492
1717
|
function hasEslintConfig(dir) {
|
|
1493
1718
|
for (const file of ESLINT_CONFIG_FILES) {
|
|
1494
|
-
if (
|
|
1719
|
+
if (existsSync6(join5(dir, file))) {
|
|
1495
1720
|
return true;
|
|
1496
1721
|
}
|
|
1497
1722
|
}
|
|
1498
1723
|
try {
|
|
1499
|
-
const pkgPath =
|
|
1500
|
-
if (
|
|
1501
|
-
const pkg = JSON.parse(
|
|
1724
|
+
const pkgPath = join5(dir, "package.json");
|
|
1725
|
+
if (existsSync6(pkgPath)) {
|
|
1726
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
|
|
1502
1727
|
if (pkg.eslintConfig) return true;
|
|
1503
1728
|
}
|
|
1504
1729
|
} catch {
|
|
@@ -1507,14 +1732,14 @@ function hasEslintConfig(dir) {
|
|
|
1507
1732
|
}
|
|
1508
1733
|
function findPackages(rootDir, options) {
|
|
1509
1734
|
const maxDepth = options?.maxDepth ?? 5;
|
|
1510
|
-
const ignoreDirs = options?.ignoreDirs ??
|
|
1735
|
+
const ignoreDirs = options?.ignoreDirs ?? DEFAULT_IGNORE_DIRS3;
|
|
1511
1736
|
const results = [];
|
|
1512
1737
|
const visited = /* @__PURE__ */ new Set();
|
|
1513
1738
|
function processPackage(dir, isRoot) {
|
|
1514
|
-
const pkgPath =
|
|
1515
|
-
if (!
|
|
1739
|
+
const pkgPath = join5(dir, "package.json");
|
|
1740
|
+
if (!existsSync6(pkgPath)) return null;
|
|
1516
1741
|
try {
|
|
1517
|
-
const pkg = JSON.parse(
|
|
1742
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf-8"));
|
|
1518
1743
|
const name = pkg.name || relative(rootDir, dir) || ".";
|
|
1519
1744
|
return {
|
|
1520
1745
|
path: dir,
|
|
@@ -1538,7 +1763,7 @@ function findPackages(rootDir, options) {
|
|
|
1538
1763
|
}
|
|
1539
1764
|
let entries = [];
|
|
1540
1765
|
try {
|
|
1541
|
-
entries =
|
|
1766
|
+
entries = readdirSync3(dir, { withFileTypes: true }).map((d) => ({
|
|
1542
1767
|
name: d.name,
|
|
1543
1768
|
isDirectory: d.isDirectory()
|
|
1544
1769
|
}));
|
|
@@ -1549,7 +1774,7 @@ function findPackages(rootDir, options) {
|
|
|
1549
1774
|
if (!ent.isDirectory) continue;
|
|
1550
1775
|
if (ignoreDirs.has(ent.name)) continue;
|
|
1551
1776
|
if (ent.name.startsWith(".")) continue;
|
|
1552
|
-
walk(
|
|
1777
|
+
walk(join5(dir, ent.name), depth + 1);
|
|
1553
1778
|
}
|
|
1554
1779
|
}
|
|
1555
1780
|
walk(rootDir, 0);
|
|
@@ -1574,18 +1799,18 @@ function formatPackageOption(pkg) {
|
|
|
1574
1799
|
}
|
|
1575
1800
|
|
|
1576
1801
|
// src/utils/package-manager.ts
|
|
1577
|
-
import { existsSync as
|
|
1802
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1578
1803
|
import { spawn } from "child_process";
|
|
1579
|
-
import { dirname as dirname5, join as
|
|
1804
|
+
import { dirname as dirname5, join as join6 } from "path";
|
|
1580
1805
|
function detectPackageManager(projectPath) {
|
|
1581
1806
|
let dir = projectPath;
|
|
1582
1807
|
for (; ; ) {
|
|
1583
|
-
if (
|
|
1584
|
-
if (
|
|
1585
|
-
if (
|
|
1586
|
-
if (
|
|
1587
|
-
if (
|
|
1588
|
-
if (
|
|
1808
|
+
if (existsSync7(join6(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
1809
|
+
if (existsSync7(join6(dir, "pnpm-workspace.yaml"))) return "pnpm";
|
|
1810
|
+
if (existsSync7(join6(dir, "yarn.lock"))) return "yarn";
|
|
1811
|
+
if (existsSync7(join6(dir, "bun.lockb"))) return "bun";
|
|
1812
|
+
if (existsSync7(join6(dir, "bun.lock"))) return "bun";
|
|
1813
|
+
if (existsSync7(join6(dir, "package-lock.json"))) return "npm";
|
|
1589
1814
|
const parent = dirname5(dir);
|
|
1590
1815
|
if (parent === dir) break;
|
|
1591
1816
|
dir = parent;
|
|
@@ -1593,7 +1818,7 @@ function detectPackageManager(projectPath) {
|
|
|
1593
1818
|
return "npm";
|
|
1594
1819
|
}
|
|
1595
1820
|
function spawnAsync(command, args, cwd) {
|
|
1596
|
-
return new Promise((
|
|
1821
|
+
return new Promise((resolve8, reject) => {
|
|
1597
1822
|
const child = spawn(command, args, {
|
|
1598
1823
|
cwd,
|
|
1599
1824
|
stdio: "inherit",
|
|
@@ -1601,7 +1826,7 @@ function spawnAsync(command, args, cwd) {
|
|
|
1601
1826
|
});
|
|
1602
1827
|
child.on("error", reject);
|
|
1603
1828
|
child.on("close", (code) => {
|
|
1604
|
-
if (code === 0)
|
|
1829
|
+
if (code === 0) resolve8();
|
|
1605
1830
|
else
|
|
1606
1831
|
reject(new Error(`${command} ${args.join(" ")} exited with ${code}`));
|
|
1607
1832
|
});
|
|
@@ -1627,14 +1852,14 @@ async function installDependencies(pm, projectPath, packages) {
|
|
|
1627
1852
|
}
|
|
1628
1853
|
|
|
1629
1854
|
// src/utils/eslint-config-inject.ts
|
|
1630
|
-
import { existsSync as
|
|
1631
|
-
import { join as
|
|
1855
|
+
import { existsSync as existsSync8, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
1856
|
+
import { join as join7 } from "path";
|
|
1632
1857
|
import { parseExpression, parseModule, generateCode } from "magicast";
|
|
1633
|
-
var CONFIG_EXTENSIONS = [".mjs", ".js", ".cjs"];
|
|
1858
|
+
var CONFIG_EXTENSIONS = [".ts", ".mjs", ".js", ".cjs"];
|
|
1634
1859
|
function findEslintConfigFile(projectPath) {
|
|
1635
1860
|
for (const ext of CONFIG_EXTENSIONS) {
|
|
1636
|
-
const configPath =
|
|
1637
|
-
if (
|
|
1861
|
+
const configPath = join7(projectPath, `eslint.config${ext}`);
|
|
1862
|
+
if (existsSync8(configPath)) {
|
|
1638
1863
|
return configPath;
|
|
1639
1864
|
}
|
|
1640
1865
|
}
|
|
@@ -1732,7 +1957,7 @@ function collectUilintRuleIdsFromRulesObject(rulesObj) {
|
|
|
1732
1957
|
return ids;
|
|
1733
1958
|
}
|
|
1734
1959
|
function findExportedConfigArrayExpression(mod) {
|
|
1735
|
-
function
|
|
1960
|
+
function unwrapExpression2(expr) {
|
|
1736
1961
|
let e = expr;
|
|
1737
1962
|
while (e) {
|
|
1738
1963
|
if (e.type === "TSAsExpression" || e.type === "TSNonNullExpression") {
|
|
@@ -1758,11 +1983,11 @@ function findExportedConfigArrayExpression(mod) {
|
|
|
1758
1983
|
for (const decl of stmt.declarations ?? []) {
|
|
1759
1984
|
const id = decl?.id;
|
|
1760
1985
|
if (!isIdentifier(id, name)) continue;
|
|
1761
|
-
const init =
|
|
1986
|
+
const init = unwrapExpression2(decl?.init);
|
|
1762
1987
|
if (!init) return null;
|
|
1763
1988
|
if (init.type === "ArrayExpression") return init;
|
|
1764
|
-
if (init.type === "CallExpression" && isIdentifier(init.callee, "defineConfig") &&
|
|
1765
|
-
return
|
|
1989
|
+
if (init.type === "CallExpression" && isIdentifier(init.callee, "defineConfig") && unwrapExpression2(init.arguments?.[0])?.type === "ArrayExpression") {
|
|
1990
|
+
return unwrapExpression2(init.arguments?.[0]);
|
|
1766
1991
|
}
|
|
1767
1992
|
return null;
|
|
1768
1993
|
}
|
|
@@ -1773,15 +1998,15 @@ function findExportedConfigArrayExpression(mod) {
|
|
|
1773
1998
|
if (program2 && program2.type === "Program") {
|
|
1774
1999
|
for (const stmt of program2.body ?? []) {
|
|
1775
2000
|
if (!stmt || stmt.type !== "ExportDefaultDeclaration") continue;
|
|
1776
|
-
const decl =
|
|
2001
|
+
const decl = unwrapExpression2(stmt.declaration);
|
|
1777
2002
|
if (!decl) break;
|
|
1778
2003
|
if (decl.type === "ArrayExpression") {
|
|
1779
2004
|
return { kind: "esm", arrayExpr: decl, program: program2 };
|
|
1780
2005
|
}
|
|
1781
|
-
if (decl.type === "CallExpression" && isIdentifier(decl.callee, "defineConfig") &&
|
|
2006
|
+
if (decl.type === "CallExpression" && isIdentifier(decl.callee, "defineConfig") && unwrapExpression2(decl.arguments?.[0])?.type === "ArrayExpression") {
|
|
1782
2007
|
return {
|
|
1783
2008
|
kind: "esm",
|
|
1784
|
-
arrayExpr:
|
|
2009
|
+
arrayExpr: unwrapExpression2(decl.arguments?.[0]),
|
|
1785
2010
|
program: program2
|
|
1786
2011
|
};
|
|
1787
2012
|
}
|
|
@@ -2041,7 +2266,7 @@ async function installEslintPlugin(opts) {
|
|
|
2041
2266
|
};
|
|
2042
2267
|
}
|
|
2043
2268
|
const configFilename = getEslintConfigFilename(configPath);
|
|
2044
|
-
const original =
|
|
2269
|
+
const original = readFileSync4(configPath, "utf-8");
|
|
2045
2270
|
const isCommonJS = configPath.endsWith(".cjs");
|
|
2046
2271
|
const ast = getUilintEslintConfigInfoFromSourceAst(original);
|
|
2047
2272
|
if ("error" in ast) {
|
|
@@ -2143,7 +2368,7 @@ async function installEslintPlugin(opts) {
|
|
|
2143
2368
|
var LEGACY_HOOK_FILES = ["uilint-validate.sh", "uilint-validate.js"];
|
|
2144
2369
|
function safeParseJson(filePath) {
|
|
2145
2370
|
try {
|
|
2146
|
-
const content =
|
|
2371
|
+
const content = readFileSync5(filePath, "utf-8");
|
|
2147
2372
|
return JSON.parse(content);
|
|
2148
2373
|
} catch {
|
|
2149
2374
|
return void 0;
|
|
@@ -2152,27 +2377,27 @@ function safeParseJson(filePath) {
|
|
|
2152
2377
|
async function analyze2(projectPath = process.cwd()) {
|
|
2153
2378
|
const workspaceRoot = findWorkspaceRoot4(projectPath);
|
|
2154
2379
|
const packageManager = detectPackageManager(projectPath);
|
|
2155
|
-
const cursorDir =
|
|
2156
|
-
const cursorDirExists =
|
|
2157
|
-
const mcpPath =
|
|
2158
|
-
const mcpExists =
|
|
2380
|
+
const cursorDir = join8(projectPath, ".cursor");
|
|
2381
|
+
const cursorDirExists = existsSync9(cursorDir);
|
|
2382
|
+
const mcpPath = join8(cursorDir, "mcp.json");
|
|
2383
|
+
const mcpExists = existsSync9(mcpPath);
|
|
2159
2384
|
const mcpConfig = mcpExists ? safeParseJson(mcpPath) : void 0;
|
|
2160
|
-
const hooksPath =
|
|
2161
|
-
const hooksExists =
|
|
2385
|
+
const hooksPath = join8(cursorDir, "hooks.json");
|
|
2386
|
+
const hooksExists = existsSync9(hooksPath);
|
|
2162
2387
|
const hooksConfig = hooksExists ? safeParseJson(hooksPath) : void 0;
|
|
2163
|
-
const hooksDir =
|
|
2388
|
+
const hooksDir = join8(cursorDir, "hooks");
|
|
2164
2389
|
const legacyPaths = [];
|
|
2165
2390
|
for (const legacyFile of LEGACY_HOOK_FILES) {
|
|
2166
|
-
const legacyPath =
|
|
2167
|
-
if (
|
|
2391
|
+
const legacyPath = join8(hooksDir, legacyFile);
|
|
2392
|
+
if (existsSync9(legacyPath)) {
|
|
2168
2393
|
legacyPaths.push(legacyPath);
|
|
2169
2394
|
}
|
|
2170
2395
|
}
|
|
2171
|
-
const styleguidePath =
|
|
2172
|
-
const styleguideExists =
|
|
2173
|
-
const commandsDir =
|
|
2174
|
-
const genstyleguideExists =
|
|
2175
|
-
const genrulesExists =
|
|
2396
|
+
const styleguidePath = join8(projectPath, ".uilint", "styleguide.md");
|
|
2397
|
+
const styleguideExists = existsSync9(styleguidePath);
|
|
2398
|
+
const commandsDir = join8(cursorDir, "commands");
|
|
2399
|
+
const genstyleguideExists = existsSync9(join8(commandsDir, "genstyleguide.md"));
|
|
2400
|
+
const genrulesExists = existsSync9(join8(commandsDir, "genrules.md"));
|
|
2176
2401
|
const nextApps = [];
|
|
2177
2402
|
const directDetection = detectNextAppRouter(projectPath);
|
|
2178
2403
|
if (directDetection) {
|
|
@@ -2186,6 +2411,19 @@ async function analyze2(projectPath = process.cwd()) {
|
|
|
2186
2411
|
});
|
|
2187
2412
|
}
|
|
2188
2413
|
}
|
|
2414
|
+
const viteApps = [];
|
|
2415
|
+
const directVite = detectViteReact(projectPath);
|
|
2416
|
+
if (directVite) {
|
|
2417
|
+
viteApps.push({ projectPath, detection: directVite });
|
|
2418
|
+
} else {
|
|
2419
|
+
const matches = findViteReactProjects(workspaceRoot, { maxDepth: 5 });
|
|
2420
|
+
for (const match of matches) {
|
|
2421
|
+
viteApps.push({
|
|
2422
|
+
projectPath: match.projectPath,
|
|
2423
|
+
detection: match.detection
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2189
2427
|
const rawPackages = findPackages(workspaceRoot);
|
|
2190
2428
|
const packages = rawPackages.map((pkg) => {
|
|
2191
2429
|
const eslintConfigPath = findEslintConfigFile(pkg.path);
|
|
@@ -2195,7 +2433,7 @@ async function analyze2(projectPath = process.cwd()) {
|
|
|
2195
2433
|
if (eslintConfigPath) {
|
|
2196
2434
|
eslintConfigFilename = getEslintConfigFilename(eslintConfigPath);
|
|
2197
2435
|
try {
|
|
2198
|
-
const source =
|
|
2436
|
+
const source = readFileSync5(eslintConfigPath, "utf-8");
|
|
2199
2437
|
const info = getUilintEslintConfigInfoFromSource(source);
|
|
2200
2438
|
hasRules = info.configuredRuleIds.size > 0 || info.usesUilintConfigs;
|
|
2201
2439
|
configuredRuleIds = Array.from(info.configuredRuleIds);
|
|
@@ -2239,12 +2477,13 @@ async function analyze2(projectPath = process.cwd()) {
|
|
|
2239
2477
|
genrules: genrulesExists
|
|
2240
2478
|
},
|
|
2241
2479
|
nextApps,
|
|
2480
|
+
viteApps,
|
|
2242
2481
|
packages
|
|
2243
2482
|
};
|
|
2244
2483
|
}
|
|
2245
2484
|
|
|
2246
2485
|
// src/commands/install/plan.ts
|
|
2247
|
-
import { join as
|
|
2486
|
+
import { join as join10 } from "path";
|
|
2248
2487
|
import { createRequire } from "module";
|
|
2249
2488
|
|
|
2250
2489
|
// src/commands/install/constants.ts
|
|
@@ -2633,6 +2872,55 @@ Generate in \`.uilint/rules/\`:
|
|
|
2633
2872
|
- **Minimal rules** - generate 3-5 high-impact rules, not dozens
|
|
2634
2873
|
`;
|
|
2635
2874
|
|
|
2875
|
+
// src/utils/skill-loader.ts
|
|
2876
|
+
import { readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync2, existsSync as existsSync10 } from "fs";
|
|
2877
|
+
import { join as join9, dirname as dirname6, relative as relative2 } from "path";
|
|
2878
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2879
|
+
var __filename = fileURLToPath2(import.meta.url);
|
|
2880
|
+
var __dirname = dirname6(__filename);
|
|
2881
|
+
function getSkillsDir() {
|
|
2882
|
+
const devPath = join9(__dirname, "..", "..", "skills");
|
|
2883
|
+
const prodPath = join9(__dirname, "..", "skills");
|
|
2884
|
+
if (existsSync10(devPath)) {
|
|
2885
|
+
return devPath;
|
|
2886
|
+
}
|
|
2887
|
+
if (existsSync10(prodPath)) {
|
|
2888
|
+
return prodPath;
|
|
2889
|
+
}
|
|
2890
|
+
throw new Error(
|
|
2891
|
+
"Could not find skills directory. This is a bug in uilint installation."
|
|
2892
|
+
);
|
|
2893
|
+
}
|
|
2894
|
+
function collectFiles(dir, baseDir) {
|
|
2895
|
+
const files = [];
|
|
2896
|
+
const entries = readdirSync4(dir);
|
|
2897
|
+
for (const entry of entries) {
|
|
2898
|
+
const fullPath = join9(dir, entry);
|
|
2899
|
+
const stat = statSync2(fullPath);
|
|
2900
|
+
if (stat.isDirectory()) {
|
|
2901
|
+
files.push(...collectFiles(fullPath, baseDir));
|
|
2902
|
+
} else if (stat.isFile()) {
|
|
2903
|
+
const relativePath = relative2(baseDir, fullPath);
|
|
2904
|
+
const content = readFileSync6(fullPath, "utf-8");
|
|
2905
|
+
files.push({ relativePath, content });
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
return files;
|
|
2909
|
+
}
|
|
2910
|
+
function loadSkill(name) {
|
|
2911
|
+
const skillsDir = getSkillsDir();
|
|
2912
|
+
const skillDir = join9(skillsDir, name);
|
|
2913
|
+
if (!existsSync10(skillDir)) {
|
|
2914
|
+
throw new Error(`Skill "${name}" not found in ${skillsDir}`);
|
|
2915
|
+
}
|
|
2916
|
+
const skillMdPath = join9(skillDir, "SKILL.md");
|
|
2917
|
+
if (!existsSync10(skillMdPath)) {
|
|
2918
|
+
throw new Error(`Skill "${name}" is missing SKILL.md`);
|
|
2919
|
+
}
|
|
2920
|
+
const files = collectFiles(skillDir, skillDir);
|
|
2921
|
+
return { name, files };
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2636
2924
|
// src/commands/install/plan.ts
|
|
2637
2925
|
var require2 = createRequire(import.meta.url);
|
|
2638
2926
|
function getSelfDependencyVersionRange(pkgName) {
|
|
@@ -2683,7 +2971,7 @@ function createPlan(state, choices, options = {}) {
|
|
|
2683
2971
|
const dependencies = [];
|
|
2684
2972
|
const { force = false } = options;
|
|
2685
2973
|
const { items } = choices;
|
|
2686
|
-
const needsCursorDir = items.includes("mcp") || items.includes("hooks") || items.includes("genstyleguide") || items.includes("genrules");
|
|
2974
|
+
const needsCursorDir = items.includes("mcp") || items.includes("hooks") || items.includes("genstyleguide") || items.includes("genrules") || items.includes("skill");
|
|
2687
2975
|
if (needsCursorDir && !state.cursorDir.exists) {
|
|
2688
2976
|
actions.push({
|
|
2689
2977
|
type: "create_directory",
|
|
@@ -2712,7 +3000,7 @@ function createPlan(state, choices, options = {}) {
|
|
|
2712
3000
|
}
|
|
2713
3001
|
}
|
|
2714
3002
|
if (items.includes("hooks")) {
|
|
2715
|
-
const hooksDir =
|
|
3003
|
+
const hooksDir = join10(state.cursorDir.path, "hooks");
|
|
2716
3004
|
actions.push({
|
|
2717
3005
|
type: "create_directory",
|
|
2718
3006
|
path: hooksDir
|
|
@@ -2738,47 +3026,78 @@ function createPlan(state, choices, options = {}) {
|
|
|
2738
3026
|
});
|
|
2739
3027
|
actions.push({
|
|
2740
3028
|
type: "create_file",
|
|
2741
|
-
path:
|
|
3029
|
+
path: join10(hooksDir, "uilint-session-start.sh"),
|
|
2742
3030
|
content: SESSION_START_SCRIPT,
|
|
2743
3031
|
permissions: 493
|
|
2744
3032
|
});
|
|
2745
3033
|
actions.push({
|
|
2746
3034
|
type: "create_file",
|
|
2747
|
-
path:
|
|
3035
|
+
path: join10(hooksDir, "uilint-track.sh"),
|
|
2748
3036
|
content: TRACK_SCRIPT,
|
|
2749
3037
|
permissions: 493
|
|
2750
3038
|
});
|
|
2751
3039
|
actions.push({
|
|
2752
3040
|
type: "create_file",
|
|
2753
|
-
path:
|
|
3041
|
+
path: join10(hooksDir, "uilint-session-end.sh"),
|
|
2754
3042
|
content: SESSION_END_SCRIPT,
|
|
2755
3043
|
permissions: 493
|
|
2756
3044
|
});
|
|
2757
3045
|
}
|
|
2758
3046
|
if (items.includes("genstyleguide")) {
|
|
2759
|
-
const commandsDir =
|
|
3047
|
+
const commandsDir = join10(state.cursorDir.path, "commands");
|
|
2760
3048
|
actions.push({
|
|
2761
3049
|
type: "create_directory",
|
|
2762
3050
|
path: commandsDir
|
|
2763
3051
|
});
|
|
2764
3052
|
actions.push({
|
|
2765
3053
|
type: "create_file",
|
|
2766
|
-
path:
|
|
3054
|
+
path: join10(commandsDir, "genstyleguide.md"),
|
|
2767
3055
|
content: GENSTYLEGUIDE_COMMAND_MD
|
|
2768
3056
|
});
|
|
2769
3057
|
}
|
|
2770
3058
|
if (items.includes("genrules")) {
|
|
2771
|
-
const commandsDir =
|
|
3059
|
+
const commandsDir = join10(state.cursorDir.path, "commands");
|
|
2772
3060
|
actions.push({
|
|
2773
3061
|
type: "create_directory",
|
|
2774
3062
|
path: commandsDir
|
|
2775
3063
|
});
|
|
2776
3064
|
actions.push({
|
|
2777
3065
|
type: "create_file",
|
|
2778
|
-
path:
|
|
3066
|
+
path: join10(commandsDir, "genrules.md"),
|
|
2779
3067
|
content: GENRULES_COMMAND_MD
|
|
2780
3068
|
});
|
|
2781
3069
|
}
|
|
3070
|
+
if (items.includes("skill")) {
|
|
3071
|
+
const skillsDir = join10(state.cursorDir.path, "skills");
|
|
3072
|
+
actions.push({
|
|
3073
|
+
type: "create_directory",
|
|
3074
|
+
path: skillsDir
|
|
3075
|
+
});
|
|
3076
|
+
try {
|
|
3077
|
+
const skill = loadSkill("ui-consistency-enforcer");
|
|
3078
|
+
const skillDir = join10(skillsDir, skill.name);
|
|
3079
|
+
actions.push({
|
|
3080
|
+
type: "create_directory",
|
|
3081
|
+
path: skillDir
|
|
3082
|
+
});
|
|
3083
|
+
for (const file of skill.files) {
|
|
3084
|
+
const filePath = join10(skillDir, file.relativePath);
|
|
3085
|
+
const fileDir = join10(skillDir, file.relativePath.split("/").slice(0, -1).join("/"));
|
|
3086
|
+
if (fileDir !== skillDir && file.relativePath.includes("/")) {
|
|
3087
|
+
actions.push({
|
|
3088
|
+
type: "create_directory",
|
|
3089
|
+
path: fileDir
|
|
3090
|
+
});
|
|
3091
|
+
}
|
|
3092
|
+
actions.push({
|
|
3093
|
+
type: "create_file",
|
|
3094
|
+
path: filePath,
|
|
3095
|
+
content: file.content
|
|
3096
|
+
});
|
|
3097
|
+
}
|
|
3098
|
+
} catch {
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
2782
3101
|
if (items.includes("next") && choices.next) {
|
|
2783
3102
|
const { projectPath, detection } = choices.next;
|
|
2784
3103
|
actions.push({
|
|
@@ -2801,6 +3120,24 @@ function createPlan(state, choices, options = {}) {
|
|
|
2801
3120
|
projectPath
|
|
2802
3121
|
});
|
|
2803
3122
|
}
|
|
3123
|
+
if (items.includes("vite") && choices.vite) {
|
|
3124
|
+
const { projectPath, detection } = choices.vite;
|
|
3125
|
+
dependencies.push({
|
|
3126
|
+
packagePath: projectPath,
|
|
3127
|
+
packageManager: state.packageManager,
|
|
3128
|
+
packages: ["uilint-react", "uilint-core", "jsx-loc-plugin"]
|
|
3129
|
+
});
|
|
3130
|
+
actions.push({
|
|
3131
|
+
type: "inject_react",
|
|
3132
|
+
projectPath,
|
|
3133
|
+
appRoot: detection.entryRoot,
|
|
3134
|
+
mode: "vite"
|
|
3135
|
+
});
|
|
3136
|
+
actions.push({
|
|
3137
|
+
type: "inject_vite_config",
|
|
3138
|
+
projectPath
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
2804
3141
|
if (items.includes("eslint") && choices.eslint) {
|
|
2805
3142
|
const { packagePaths, selectedRules } = choices.eslint;
|
|
2806
3143
|
for (const pkgPath of packagePaths) {
|
|
@@ -2820,7 +3157,7 @@ function createPlan(state, choices, options = {}) {
|
|
|
2820
3157
|
});
|
|
2821
3158
|
}
|
|
2822
3159
|
}
|
|
2823
|
-
const gitignorePath =
|
|
3160
|
+
const gitignorePath = join10(state.workspaceRoot, ".gitignore");
|
|
2824
3161
|
actions.push({
|
|
2825
3162
|
type: "append_to_file",
|
|
2826
3163
|
path: gitignorePath,
|
|
@@ -2833,34 +3170,49 @@ function createPlan(state, choices, options = {}) {
|
|
|
2833
3170
|
|
|
2834
3171
|
// src/commands/install/execute.ts
|
|
2835
3172
|
import {
|
|
2836
|
-
existsSync as
|
|
3173
|
+
existsSync as existsSync15,
|
|
2837
3174
|
mkdirSync as mkdirSync3,
|
|
2838
|
-
writeFileSync as
|
|
2839
|
-
readFileSync as
|
|
3175
|
+
writeFileSync as writeFileSync7,
|
|
3176
|
+
readFileSync as readFileSync10,
|
|
2840
3177
|
unlinkSync,
|
|
2841
3178
|
chmodSync
|
|
2842
3179
|
} from "fs";
|
|
2843
|
-
import { dirname as
|
|
3180
|
+
import { dirname as dirname7 } from "path";
|
|
2844
3181
|
|
|
2845
3182
|
// src/utils/react-inject.ts
|
|
2846
|
-
import { existsSync as
|
|
2847
|
-
import { join as
|
|
3183
|
+
import { existsSync as existsSync11, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
|
|
3184
|
+
import { join as join11 } from "path";
|
|
2848
3185
|
import { parseModule as parseModule2, generateCode as generateCode2 } from "magicast";
|
|
2849
3186
|
function getDefaultCandidates(projectPath, appRoot) {
|
|
3187
|
+
const viteMainCandidates = [
|
|
3188
|
+
join11(appRoot, "main.tsx"),
|
|
3189
|
+
join11(appRoot, "main.jsx"),
|
|
3190
|
+
join11(appRoot, "main.ts"),
|
|
3191
|
+
join11(appRoot, "main.js")
|
|
3192
|
+
];
|
|
3193
|
+
const existingViteMain = viteMainCandidates.filter(
|
|
3194
|
+
(rel) => existsSync11(join11(projectPath, rel))
|
|
3195
|
+
);
|
|
3196
|
+
if (existingViteMain.length > 0) return existingViteMain;
|
|
3197
|
+
const viteAppCandidates = [join11(appRoot, "App.tsx"), join11(appRoot, "App.jsx")];
|
|
3198
|
+
const existingViteApp = viteAppCandidates.filter(
|
|
3199
|
+
(rel) => existsSync11(join11(projectPath, rel))
|
|
3200
|
+
);
|
|
3201
|
+
if (existingViteApp.length > 0) return existingViteApp;
|
|
2850
3202
|
const layoutCandidates = [
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
3203
|
+
join11(appRoot, "layout.tsx"),
|
|
3204
|
+
join11(appRoot, "layout.jsx"),
|
|
3205
|
+
join11(appRoot, "layout.ts"),
|
|
3206
|
+
join11(appRoot, "layout.js")
|
|
2855
3207
|
];
|
|
2856
3208
|
const existingLayouts = layoutCandidates.filter(
|
|
2857
|
-
(rel) =>
|
|
3209
|
+
(rel) => existsSync11(join11(projectPath, rel))
|
|
2858
3210
|
);
|
|
2859
3211
|
if (existingLayouts.length > 0) {
|
|
2860
3212
|
return existingLayouts;
|
|
2861
3213
|
}
|
|
2862
|
-
const pageCandidates = [
|
|
2863
|
-
return pageCandidates.filter((rel) =>
|
|
3214
|
+
const pageCandidates = [join11(appRoot, "page.tsx"), join11(appRoot, "page.jsx")];
|
|
3215
|
+
return pageCandidates.filter((rel) => existsSync11(join11(projectPath, rel)));
|
|
2864
3216
|
}
|
|
2865
3217
|
function isUseClientDirective(stmt) {
|
|
2866
3218
|
return stmt?.type === "ExpressionStatement" && stmt.expression?.type === "StringLiteral" && stmt.expression.value === "use client";
|
|
@@ -2947,11 +3299,44 @@ function wrapFirstChildrenExpressionWithProvider(program2) {
|
|
|
2947
3299
|
}
|
|
2948
3300
|
return { changed: true };
|
|
2949
3301
|
}
|
|
3302
|
+
function wrapFirstRenderCallArgumentWithProvider(program2) {
|
|
3303
|
+
if (!program2 || program2.type !== "Program") return { changed: false };
|
|
3304
|
+
if (hasUILintProviderJsx(program2)) return { changed: false };
|
|
3305
|
+
const providerMod = parseModule2(
|
|
3306
|
+
'const __uilint_provider = (<UILintProvider enabled={process.env.NODE_ENV !== "production"}></UILintProvider>);'
|
|
3307
|
+
);
|
|
3308
|
+
const providerJsx = providerMod.$ast.body?.[0]?.declarations?.[0]?.init ?? null;
|
|
3309
|
+
if (!providerJsx || providerJsx.type !== "JSXElement")
|
|
3310
|
+
return { changed: false };
|
|
3311
|
+
providerJsx.children = providerJsx.children ?? [];
|
|
3312
|
+
let wrapped = false;
|
|
3313
|
+
walkAst2(program2, (node) => {
|
|
3314
|
+
if (wrapped) return;
|
|
3315
|
+
if (node.type !== "CallExpression") return;
|
|
3316
|
+
const callee = node.callee;
|
|
3317
|
+
if (callee?.type !== "MemberExpression") return;
|
|
3318
|
+
const prop = callee.property;
|
|
3319
|
+
const isRender = prop?.type === "Identifier" && prop.name === "render" || prop?.type === "StringLiteral" && prop.value === "render" || prop?.type === "Literal" && prop.value === "render";
|
|
3320
|
+
if (!isRender) return;
|
|
3321
|
+
const arg0 = node.arguments?.[0];
|
|
3322
|
+
if (!arg0) return;
|
|
3323
|
+
if (arg0.type !== "JSXElement" && arg0.type !== "JSXFragment") return;
|
|
3324
|
+
providerJsx.children = [arg0];
|
|
3325
|
+
node.arguments[0] = providerJsx;
|
|
3326
|
+
wrapped = true;
|
|
3327
|
+
});
|
|
3328
|
+
if (!wrapped) {
|
|
3329
|
+
throw new Error(
|
|
3330
|
+
"Could not find a `.render(<...>)` call to wrap. Expected a React entry like `createRoot(...).render(<App />)`."
|
|
3331
|
+
);
|
|
3332
|
+
}
|
|
3333
|
+
return { changed: true };
|
|
3334
|
+
}
|
|
2950
3335
|
async function installReactUILintOverlay(opts) {
|
|
2951
3336
|
const candidates = getDefaultCandidates(opts.projectPath, opts.appRoot);
|
|
2952
3337
|
if (!candidates.length) {
|
|
2953
3338
|
throw new Error(
|
|
2954
|
-
`No suitable
|
|
3339
|
+
`No suitable entry files found under ${opts.appRoot} (expected Next.js layout/page or Vite main/App).`
|
|
2955
3340
|
);
|
|
2956
3341
|
}
|
|
2957
3342
|
let chosen;
|
|
@@ -2960,8 +3345,8 @@ async function installReactUILintOverlay(opts) {
|
|
|
2960
3345
|
} else {
|
|
2961
3346
|
chosen = candidates[0];
|
|
2962
3347
|
}
|
|
2963
|
-
const absTarget =
|
|
2964
|
-
const original =
|
|
3348
|
+
const absTarget = join11(opts.projectPath, chosen);
|
|
3349
|
+
const original = readFileSync7(absTarget, "utf-8");
|
|
2965
3350
|
let mod;
|
|
2966
3351
|
try {
|
|
2967
3352
|
mod = parseModule2(original);
|
|
@@ -2979,7 +3364,8 @@ async function installReactUILintOverlay(opts) {
|
|
|
2979
3364
|
"UILintProvider"
|
|
2980
3365
|
);
|
|
2981
3366
|
if (importRes.changed) changed = true;
|
|
2982
|
-
const
|
|
3367
|
+
const mode = opts.mode ?? "next";
|
|
3368
|
+
const wrapRes = mode === "vite" ? wrapFirstRenderCallArgumentWithProvider(program2) : wrapFirstChildrenExpressionWithProvider(program2);
|
|
2983
3369
|
if (wrapRes.changed) changed = true;
|
|
2984
3370
|
const updated = changed ? generateCode2(mod).code : original;
|
|
2985
3371
|
const modified = updated !== original;
|
|
@@ -2994,14 +3380,14 @@ async function installReactUILintOverlay(opts) {
|
|
|
2994
3380
|
}
|
|
2995
3381
|
|
|
2996
3382
|
// src/utils/next-config-inject.ts
|
|
2997
|
-
import { existsSync as
|
|
2998
|
-
import { join as
|
|
3383
|
+
import { existsSync as existsSync12, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
|
|
3384
|
+
import { join as join12 } from "path";
|
|
2999
3385
|
import { parseModule as parseModule3, generateCode as generateCode3 } from "magicast";
|
|
3000
3386
|
var CONFIG_EXTENSIONS2 = [".ts", ".mjs", ".js", ".cjs"];
|
|
3001
3387
|
function findNextConfigFile(projectPath) {
|
|
3002
3388
|
for (const ext of CONFIG_EXTENSIONS2) {
|
|
3003
|
-
const configPath =
|
|
3004
|
-
if (
|
|
3389
|
+
const configPath = join12(projectPath, `next.config${ext}`);
|
|
3390
|
+
if (existsSync12(configPath)) {
|
|
3005
3391
|
return configPath;
|
|
3006
3392
|
}
|
|
3007
3393
|
}
|
|
@@ -3114,7 +3500,7 @@ async function installJsxLocPlugin(opts) {
|
|
|
3114
3500
|
return { configFile: null, modified: false };
|
|
3115
3501
|
}
|
|
3116
3502
|
const configFilename = getNextConfigFilename(configPath);
|
|
3117
|
-
const original =
|
|
3503
|
+
const original = readFileSync8(configPath, "utf-8");
|
|
3118
3504
|
let mod;
|
|
3119
3505
|
try {
|
|
3120
3506
|
mod = parseModule3(original);
|
|
@@ -3143,98 +3529,323 @@ async function installJsxLocPlugin(opts) {
|
|
|
3143
3529
|
return { configFile: configFilename, modified: false };
|
|
3144
3530
|
}
|
|
3145
3531
|
|
|
3146
|
-
// src/utils/
|
|
3147
|
-
import { existsSync as
|
|
3148
|
-
import {
|
|
3149
|
-
import {
|
|
3150
|
-
var
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
*
|
|
3156
|
-
* Security:
|
|
3157
|
-
* - Only available in development mode
|
|
3158
|
-
* - Validates file path is within project root
|
|
3159
|
-
* - Only allows specific file extensions
|
|
3160
|
-
*/
|
|
3161
|
-
|
|
3162
|
-
import { NextRequest, NextResponse } from "next/server";
|
|
3163
|
-
import { readFileSync, existsSync } from "fs";
|
|
3164
|
-
import { resolve, relative, dirname, extname } from "path";
|
|
3165
|
-
|
|
3166
|
-
export const runtime = "nodejs";
|
|
3167
|
-
|
|
3168
|
-
// Allowed file extensions
|
|
3169
|
-
const ALLOWED_EXTENSIONS = new Set([".tsx", ".ts", ".jsx", ".js", ".css"]);
|
|
3170
|
-
|
|
3171
|
-
/**
|
|
3172
|
-
* Find the project root by looking for package.json or next.config
|
|
3173
|
-
*/
|
|
3174
|
-
function findProjectRoot(startDir: string): string {
|
|
3175
|
-
let dir = startDir;
|
|
3176
|
-
for (let i = 0; i < 10; i++) {
|
|
3177
|
-
if (
|
|
3178
|
-
existsSync(resolve(dir, "package.json")) ||
|
|
3179
|
-
existsSync(resolve(dir, "next.config.js")) ||
|
|
3180
|
-
existsSync(resolve(dir, "next.config.ts"))
|
|
3181
|
-
) {
|
|
3182
|
-
return dir;
|
|
3183
|
-
}
|
|
3184
|
-
const parent = dirname(dir);
|
|
3185
|
-
if (parent === dir) break;
|
|
3186
|
-
dir = parent;
|
|
3532
|
+
// src/utils/vite-config-inject.ts
|
|
3533
|
+
import { existsSync as existsSync13, readFileSync as readFileSync9, writeFileSync as writeFileSync6 } from "fs";
|
|
3534
|
+
import { join as join13 } from "path";
|
|
3535
|
+
import { parseModule as parseModule4, generateCode as generateCode4 } from "magicast";
|
|
3536
|
+
var CONFIG_EXTENSIONS3 = [".ts", ".mjs", ".js", ".cjs"];
|
|
3537
|
+
function findViteConfigFile2(projectPath) {
|
|
3538
|
+
for (const ext of CONFIG_EXTENSIONS3) {
|
|
3539
|
+
const configPath = join13(projectPath, `vite.config${ext}`);
|
|
3540
|
+
if (existsSync13(configPath)) return configPath;
|
|
3187
3541
|
}
|
|
3188
|
-
return
|
|
3542
|
+
return null;
|
|
3189
3543
|
}
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
*/
|
|
3194
|
-
function isPathWithinRoot(filePath: string, root: string): boolean {
|
|
3195
|
-
const resolved = resolve(filePath);
|
|
3196
|
-
const resolvedRoot = resolve(root);
|
|
3197
|
-
return resolved.startsWith(resolvedRoot + "/") || resolved === resolvedRoot;
|
|
3544
|
+
function getViteConfigFilename(configPath) {
|
|
3545
|
+
const parts = configPath.split("/");
|
|
3546
|
+
return parts[parts.length - 1] || "vite.config.ts";
|
|
3198
3547
|
}
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3548
|
+
function isIdentifier3(node, name) {
|
|
3549
|
+
return !!node && node.type === "Identifier" && (name ? node.name === name : typeof node.name === "string");
|
|
3550
|
+
}
|
|
3551
|
+
function isStringLiteral3(node) {
|
|
3552
|
+
return !!node && (node.type === "StringLiteral" || node.type === "Literal") && typeof node.value === "string";
|
|
3553
|
+
}
|
|
3554
|
+
function unwrapExpression(expr) {
|
|
3555
|
+
let e = expr;
|
|
3556
|
+
while (e) {
|
|
3557
|
+
if (e.type === "TSAsExpression" || e.type === "TSNonNullExpression") {
|
|
3558
|
+
e = e.expression;
|
|
3559
|
+
continue;
|
|
3211
3560
|
}
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3561
|
+
if (e.type === "TSSatisfiesExpression") {
|
|
3562
|
+
e = e.expression;
|
|
3563
|
+
continue;
|
|
3564
|
+
}
|
|
3565
|
+
if (e.type === "ParenthesizedExpression") {
|
|
3566
|
+
e = e.expression;
|
|
3567
|
+
continue;
|
|
3568
|
+
}
|
|
3569
|
+
break;
|
|
3215
3570
|
}
|
|
3216
|
-
return
|
|
3571
|
+
return e;
|
|
3217
3572
|
}
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
)
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3573
|
+
function findExportedConfigObjectExpression(mod) {
|
|
3574
|
+
const program2 = mod?.$ast;
|
|
3575
|
+
if (!program2 || program2.type !== "Program") return null;
|
|
3576
|
+
for (const stmt of program2.body ?? []) {
|
|
3577
|
+
if (!stmt || stmt.type !== "ExportDefaultDeclaration") continue;
|
|
3578
|
+
const decl = unwrapExpression(stmt.declaration);
|
|
3579
|
+
if (!decl) break;
|
|
3580
|
+
if (decl.type === "ObjectExpression") {
|
|
3581
|
+
return { kind: "esm", objExpr: decl, program: program2 };
|
|
3582
|
+
}
|
|
3583
|
+
if (decl.type === "CallExpression" && isIdentifier3(decl.callee, "defineConfig") && unwrapExpression(decl.arguments?.[0])?.type === "ObjectExpression") {
|
|
3584
|
+
return {
|
|
3585
|
+
kind: "esm",
|
|
3586
|
+
objExpr: unwrapExpression(decl.arguments?.[0]),
|
|
3587
|
+
program: program2
|
|
3588
|
+
};
|
|
3589
|
+
}
|
|
3590
|
+
break;
|
|
3236
3591
|
}
|
|
3237
|
-
|
|
3592
|
+
for (const stmt of program2.body ?? []) {
|
|
3593
|
+
if (!stmt || stmt.type !== "ExpressionStatement") continue;
|
|
3594
|
+
const expr = stmt.expression;
|
|
3595
|
+
if (!expr || expr.type !== "AssignmentExpression") continue;
|
|
3596
|
+
const left = expr.left;
|
|
3597
|
+
const right = unwrapExpression(expr.right);
|
|
3598
|
+
const isModuleExports = left?.type === "MemberExpression" && isIdentifier3(left.object, "module") && isIdentifier3(left.property, "exports");
|
|
3599
|
+
if (!isModuleExports) continue;
|
|
3600
|
+
if (right?.type === "ObjectExpression") {
|
|
3601
|
+
return { kind: "cjs", objExpr: right, program: program2 };
|
|
3602
|
+
}
|
|
3603
|
+
if (right?.type === "CallExpression" && isIdentifier3(right.callee, "defineConfig") && unwrapExpression(right.arguments?.[0])?.type === "ObjectExpression") {
|
|
3604
|
+
return {
|
|
3605
|
+
kind: "cjs",
|
|
3606
|
+
objExpr: unwrapExpression(right.arguments?.[0]),
|
|
3607
|
+
program: program2
|
|
3608
|
+
};
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
return null;
|
|
3612
|
+
}
|
|
3613
|
+
function getObjectProperty(obj, keyName) {
|
|
3614
|
+
if (!obj || obj.type !== "ObjectExpression") return null;
|
|
3615
|
+
for (const prop of obj.properties ?? []) {
|
|
3616
|
+
if (!prop) continue;
|
|
3617
|
+
if (prop.type !== "ObjectProperty" && prop.type !== "Property") continue;
|
|
3618
|
+
const key = prop.key;
|
|
3619
|
+
const keyMatch = key?.type === "Identifier" && key.name === keyName || isStringLiteral3(key) && key.value === keyName;
|
|
3620
|
+
if (keyMatch) return prop;
|
|
3621
|
+
}
|
|
3622
|
+
return null;
|
|
3623
|
+
}
|
|
3624
|
+
function ensureEsmJsxLocImport(program2) {
|
|
3625
|
+
if (!program2 || program2.type !== "Program") return { changed: false };
|
|
3626
|
+
const existing = (program2.body ?? []).find(
|
|
3627
|
+
(s) => s?.type === "ImportDeclaration" && s.source?.value === "jsx-loc-plugin/vite"
|
|
3628
|
+
);
|
|
3629
|
+
if (existing) {
|
|
3630
|
+
const has = (existing.specifiers ?? []).some(
|
|
3631
|
+
(sp) => sp?.type === "ImportSpecifier" && (sp.imported?.name === "jsxLoc" || sp.imported?.value === "jsxLoc")
|
|
3632
|
+
);
|
|
3633
|
+
if (has) return { changed: false };
|
|
3634
|
+
const spec = parseModule4('import { jsxLoc } from "jsx-loc-plugin/vite";').$ast.body?.[0]?.specifiers?.[0];
|
|
3635
|
+
if (!spec) return { changed: false };
|
|
3636
|
+
existing.specifiers = [...existing.specifiers ?? [], spec];
|
|
3637
|
+
return { changed: true };
|
|
3638
|
+
}
|
|
3639
|
+
const importDecl = parseModule4('import { jsxLoc } from "jsx-loc-plugin/vite";').$ast.body?.[0];
|
|
3640
|
+
if (!importDecl) return { changed: false };
|
|
3641
|
+
const body = program2.body ?? [];
|
|
3642
|
+
let insertAt = 0;
|
|
3643
|
+
while (insertAt < body.length && body[insertAt]?.type === "ImportDeclaration") {
|
|
3644
|
+
insertAt++;
|
|
3645
|
+
}
|
|
3646
|
+
program2.body.splice(insertAt, 0, importDecl);
|
|
3647
|
+
return { changed: true };
|
|
3648
|
+
}
|
|
3649
|
+
function ensureCjsJsxLocRequire(program2) {
|
|
3650
|
+
if (!program2 || program2.type !== "Program") return { changed: false };
|
|
3651
|
+
for (const stmt of program2.body ?? []) {
|
|
3652
|
+
if (stmt?.type !== "VariableDeclaration") continue;
|
|
3653
|
+
for (const decl of stmt.declarations ?? []) {
|
|
3654
|
+
const init = decl?.init;
|
|
3655
|
+
if (init?.type === "CallExpression" && isIdentifier3(init.callee, "require") && isStringLiteral3(init.arguments?.[0]) && init.arguments[0].value === "jsx-loc-plugin/vite") {
|
|
3656
|
+
if (decl.id?.type === "ObjectPattern") {
|
|
3657
|
+
const has = (decl.id.properties ?? []).some((p2) => {
|
|
3658
|
+
if (p2?.type !== "ObjectProperty" && p2?.type !== "Property") return false;
|
|
3659
|
+
return isIdentifier3(p2.key, "jsxLoc");
|
|
3660
|
+
});
|
|
3661
|
+
if (has) return { changed: false };
|
|
3662
|
+
const prop = parseModule4('const { jsxLoc } = require("jsx-loc-plugin/vite");').$ast.body?.[0]?.declarations?.[0]?.id?.properties?.[0];
|
|
3663
|
+
if (!prop) return { changed: false };
|
|
3664
|
+
decl.id.properties = [...decl.id.properties ?? [], prop];
|
|
3665
|
+
return { changed: true };
|
|
3666
|
+
}
|
|
3667
|
+
return { changed: false };
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
const reqDecl = parseModule4('const { jsxLoc } = require("jsx-loc-plugin/vite");').$ast.body?.[0];
|
|
3672
|
+
if (!reqDecl) return { changed: false };
|
|
3673
|
+
program2.body.unshift(reqDecl);
|
|
3674
|
+
return { changed: true };
|
|
3675
|
+
}
|
|
3676
|
+
function pluginsHasJsxLoc(arr) {
|
|
3677
|
+
if (!arr || arr.type !== "ArrayExpression") return false;
|
|
3678
|
+
for (const el of arr.elements ?? []) {
|
|
3679
|
+
const e = unwrapExpression(el);
|
|
3680
|
+
if (!e) continue;
|
|
3681
|
+
if (e.type === "CallExpression" && isIdentifier3(e.callee, "jsxLoc")) return true;
|
|
3682
|
+
}
|
|
3683
|
+
return false;
|
|
3684
|
+
}
|
|
3685
|
+
function ensurePluginsContainsJsxLoc(configObj) {
|
|
3686
|
+
const pluginsProp = getObjectProperty(configObj, "plugins");
|
|
3687
|
+
if (!pluginsProp) {
|
|
3688
|
+
const prop = parseModule4("export default { plugins: [jsxLoc()] };").$ast.body?.find((s) => s.type === "ExportDefaultDeclaration")?.declaration?.properties?.find((p2) => {
|
|
3689
|
+
const k = p2?.key;
|
|
3690
|
+
return k?.type === "Identifier" && k.name === "plugins" || isStringLiteral3(k) && k.value === "plugins";
|
|
3691
|
+
});
|
|
3692
|
+
if (!prop) return { changed: false };
|
|
3693
|
+
configObj.properties = [...configObj.properties ?? [], prop];
|
|
3694
|
+
return { changed: true };
|
|
3695
|
+
}
|
|
3696
|
+
const value = unwrapExpression(pluginsProp.value);
|
|
3697
|
+
if (!value) return { changed: false };
|
|
3698
|
+
if (value.type === "ArrayExpression") {
|
|
3699
|
+
if (pluginsHasJsxLoc(value)) return { changed: false };
|
|
3700
|
+
const jsxLocCall2 = parseModule4("const __x = jsxLoc();").$ast.body?.[0]?.declarations?.[0]?.init;
|
|
3701
|
+
if (!jsxLocCall2) return { changed: false };
|
|
3702
|
+
value.elements.push(jsxLocCall2);
|
|
3703
|
+
return { changed: true };
|
|
3704
|
+
}
|
|
3705
|
+
const jsxLocCall = parseModule4("const __x = jsxLoc();").$ast.body?.[0]?.declarations?.[0]?.init;
|
|
3706
|
+
if (!jsxLocCall) return { changed: false };
|
|
3707
|
+
const spread = { type: "SpreadElement", argument: value };
|
|
3708
|
+
pluginsProp.value = { type: "ArrayExpression", elements: [spread, jsxLocCall] };
|
|
3709
|
+
return { changed: true };
|
|
3710
|
+
}
|
|
3711
|
+
async function installViteJsxLocPlugin(opts) {
|
|
3712
|
+
const configPath = findViteConfigFile2(opts.projectPath);
|
|
3713
|
+
if (!configPath) return { configFile: null, modified: false };
|
|
3714
|
+
const configFilename = getViteConfigFilename(configPath);
|
|
3715
|
+
const original = readFileSync9(configPath, "utf-8");
|
|
3716
|
+
const isCjs = configPath.endsWith(".cjs");
|
|
3717
|
+
let mod;
|
|
3718
|
+
try {
|
|
3719
|
+
mod = parseModule4(original);
|
|
3720
|
+
} catch {
|
|
3721
|
+
return { configFile: configFilename, modified: false };
|
|
3722
|
+
}
|
|
3723
|
+
const found = findExportedConfigObjectExpression(mod);
|
|
3724
|
+
if (!found) return { configFile: configFilename, modified: false };
|
|
3725
|
+
let changed = false;
|
|
3726
|
+
if (isCjs) {
|
|
3727
|
+
const reqRes = ensureCjsJsxLocRequire(found.program);
|
|
3728
|
+
if (reqRes.changed) changed = true;
|
|
3729
|
+
} else {
|
|
3730
|
+
const impRes = ensureEsmJsxLocImport(found.program);
|
|
3731
|
+
if (impRes.changed) changed = true;
|
|
3732
|
+
}
|
|
3733
|
+
const pluginsRes = ensurePluginsContainsJsxLoc(found.objExpr);
|
|
3734
|
+
if (pluginsRes.changed) changed = true;
|
|
3735
|
+
const updated = changed ? generateCode4(mod).code : original;
|
|
3736
|
+
if (updated !== original) {
|
|
3737
|
+
writeFileSync6(configPath, updated, "utf-8");
|
|
3738
|
+
return { configFile: configFilename, modified: true };
|
|
3739
|
+
}
|
|
3740
|
+
return { configFile: configFilename, modified: false };
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
// src/utils/next-routes.ts
|
|
3744
|
+
import { existsSync as existsSync14 } from "fs";
|
|
3745
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
3746
|
+
import { join as join14 } from "path";
|
|
3747
|
+
var DEV_SOURCE_ROUTE_TS = `/**
|
|
3748
|
+
* Dev-only API route for fetching source files
|
|
3749
|
+
*
|
|
3750
|
+
* This route allows the UILint overlay to fetch and display source code
|
|
3751
|
+
* for components rendered on the page.
|
|
3752
|
+
*
|
|
3753
|
+
* Security:
|
|
3754
|
+
* - Only available in development mode
|
|
3755
|
+
* - Validates file path is within project root
|
|
3756
|
+
* - Only allows specific file extensions
|
|
3757
|
+
*/
|
|
3758
|
+
|
|
3759
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
3760
|
+
import { readFileSync, existsSync } from "fs";
|
|
3761
|
+
import { resolve, relative, dirname, extname, sep } from "path";
|
|
3762
|
+
import { fileURLToPath } from "url";
|
|
3763
|
+
|
|
3764
|
+
export const runtime = "nodejs";
|
|
3765
|
+
|
|
3766
|
+
// Allowed file extensions
|
|
3767
|
+
const ALLOWED_EXTENSIONS = new Set([".tsx", ".ts", ".jsx", ".js", ".css"]);
|
|
3768
|
+
|
|
3769
|
+
/**
|
|
3770
|
+
* Best-effort: resolve the Next.js project root (the dir that owns .next/) even in monorepos.
|
|
3771
|
+
*
|
|
3772
|
+
* Why: In monorepos, process.cwd() might be the workspace root (it also has package.json),
|
|
3773
|
+
* which would incorrectly store/read files under the wrong directory.
|
|
3774
|
+
*/
|
|
3775
|
+
function findNextProjectRoot(): string {
|
|
3776
|
+
// Prefer discovering via this route module's on-disk path.
|
|
3777
|
+
// In Next, route code is executed from within ".next/server/...".
|
|
3778
|
+
try {
|
|
3779
|
+
const selfPath = fileURLToPath(import.meta.url);
|
|
3780
|
+
const marker = sep + ".next" + sep;
|
|
3781
|
+
const idx = selfPath.lastIndexOf(marker);
|
|
3782
|
+
if (idx !== -1) {
|
|
3783
|
+
return selfPath.slice(0, idx);
|
|
3784
|
+
}
|
|
3785
|
+
} catch {
|
|
3786
|
+
// ignore
|
|
3787
|
+
}
|
|
3788
|
+
|
|
3789
|
+
// Fallback: walk up from cwd looking for .next/
|
|
3790
|
+
let dir = process.cwd();
|
|
3791
|
+
for (let i = 0; i < 20; i++) {
|
|
3792
|
+
if (existsSync(resolve(dir, ".next"))) return dir;
|
|
3793
|
+
const parent = dirname(dir);
|
|
3794
|
+
if (parent === dir) break;
|
|
3795
|
+
dir = parent;
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3798
|
+
// Final fallback: cwd
|
|
3799
|
+
return process.cwd();
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
/**
|
|
3803
|
+
* Validate that a path is within the allowed directory
|
|
3804
|
+
*/
|
|
3805
|
+
function isPathWithinRoot(filePath: string, root: string): boolean {
|
|
3806
|
+
const resolved = resolve(filePath);
|
|
3807
|
+
const resolvedRoot = resolve(root);
|
|
3808
|
+
return resolved.startsWith(resolvedRoot + "/") || resolved === resolvedRoot;
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
/**
|
|
3812
|
+
* Find workspace root by walking up looking for pnpm-workspace.yaml or .git
|
|
3813
|
+
*/
|
|
3814
|
+
function findWorkspaceRoot(startDir: string): string {
|
|
3815
|
+
let dir = startDir;
|
|
3816
|
+
for (let i = 0; i < 10; i++) {
|
|
3817
|
+
if (
|
|
3818
|
+
existsSync(resolve(dir, "pnpm-workspace.yaml")) ||
|
|
3819
|
+
existsSync(resolve(dir, ".git"))
|
|
3820
|
+
) {
|
|
3821
|
+
return dir;
|
|
3822
|
+
}
|
|
3823
|
+
const parent = dirname(dir);
|
|
3824
|
+
if (parent === dir) break;
|
|
3825
|
+
dir = parent;
|
|
3826
|
+
}
|
|
3827
|
+
return startDir;
|
|
3828
|
+
}
|
|
3829
|
+
|
|
3830
|
+
export async function GET(request: NextRequest) {
|
|
3831
|
+
// Block in production
|
|
3832
|
+
if (process.env.NODE_ENV === "production") {
|
|
3833
|
+
return NextResponse.json(
|
|
3834
|
+
{ error: "Not available in production" },
|
|
3835
|
+
{ status: 404 }
|
|
3836
|
+
);
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
const { searchParams } = new URL(request.url);
|
|
3840
|
+
const filePath = searchParams.get("path");
|
|
3841
|
+
|
|
3842
|
+
if (!filePath) {
|
|
3843
|
+
return NextResponse.json(
|
|
3844
|
+
{ error: "Missing 'path' query parameter" },
|
|
3845
|
+
{ status: 400 }
|
|
3846
|
+
);
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3238
3849
|
// Validate extension
|
|
3239
3850
|
const ext = extname(filePath).toLowerCase();
|
|
3240
3851
|
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
|
@@ -3244,8 +3855,8 @@ export async function GET(request: NextRequest) {
|
|
|
3244
3855
|
);
|
|
3245
3856
|
}
|
|
3246
3857
|
|
|
3247
|
-
// Find project root
|
|
3248
|
-
const projectRoot =
|
|
3858
|
+
// Find project root (prefer Next project root over workspace root)
|
|
3859
|
+
const projectRoot = findNextProjectRoot();
|
|
3249
3860
|
|
|
3250
3861
|
// Resolve the file path
|
|
3251
3862
|
const resolvedPath = resolve(filePath);
|
|
@@ -3274,6 +3885,8 @@ export async function GET(request: NextRequest) {
|
|
|
3274
3885
|
return NextResponse.json({
|
|
3275
3886
|
content,
|
|
3276
3887
|
relativePath,
|
|
3888
|
+
projectRoot,
|
|
3889
|
+
workspaceRoot,
|
|
3277
3890
|
});
|
|
3278
3891
|
} catch (error) {
|
|
3279
3892
|
console.error("[Dev Source API] Error reading file:", error);
|
|
@@ -3281,20 +3894,331 @@ export async function GET(request: NextRequest) {
|
|
|
3281
3894
|
}
|
|
3282
3895
|
}
|
|
3283
3896
|
`;
|
|
3897
|
+
var SCREENSHOT_ROUTE_TS = `/**
|
|
3898
|
+
* Dev-only API route for saving and retrieving vision analysis screenshots
|
|
3899
|
+
*
|
|
3900
|
+
* This route allows the UILint overlay to:
|
|
3901
|
+
* - POST: Save screenshots and element manifests for vision analysis
|
|
3902
|
+
* - GET: Retrieve screenshots or list available screenshots
|
|
3903
|
+
*
|
|
3904
|
+
* Security:
|
|
3905
|
+
* - Only available in development mode
|
|
3906
|
+
* - Saves to .uilint/screenshots/ directory within project
|
|
3907
|
+
*/
|
|
3908
|
+
|
|
3909
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
3910
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
|
|
3911
|
+
import { resolve, join, dirname, basename, sep } from "path";
|
|
3912
|
+
import { fileURLToPath } from "url";
|
|
3913
|
+
|
|
3914
|
+
export const runtime = "nodejs";
|
|
3915
|
+
|
|
3916
|
+
// Maximum screenshot size (10MB)
|
|
3917
|
+
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
|
|
3918
|
+
|
|
3919
|
+
/**
|
|
3920
|
+
* Best-effort: resolve the Next.js project root (the dir that owns .next/) even in monorepos.
|
|
3921
|
+
*/
|
|
3922
|
+
function findNextProjectRoot(): string {
|
|
3923
|
+
try {
|
|
3924
|
+
const selfPath = fileURLToPath(import.meta.url);
|
|
3925
|
+
const marker = sep + ".next" + sep;
|
|
3926
|
+
const idx = selfPath.lastIndexOf(marker);
|
|
3927
|
+
if (idx !== -1) {
|
|
3928
|
+
return selfPath.slice(0, idx);
|
|
3929
|
+
}
|
|
3930
|
+
} catch {
|
|
3931
|
+
// ignore
|
|
3932
|
+
}
|
|
3933
|
+
|
|
3934
|
+
let dir = process.cwd();
|
|
3935
|
+
for (let i = 0; i < 20; i++) {
|
|
3936
|
+
if (existsSync(resolve(dir, ".next"))) return dir;
|
|
3937
|
+
const parent = dirname(dir);
|
|
3938
|
+
if (parent === dir) break;
|
|
3939
|
+
dir = parent;
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
return process.cwd();
|
|
3943
|
+
}
|
|
3944
|
+
|
|
3945
|
+
/**
|
|
3946
|
+
* Get the screenshots directory path, creating it if needed
|
|
3947
|
+
*/
|
|
3948
|
+
function getScreenshotsDir(projectRoot: string): string {
|
|
3949
|
+
const screenshotsDir = join(projectRoot, ".uilint", "screenshots");
|
|
3950
|
+
if (!existsSync(screenshotsDir)) {
|
|
3951
|
+
mkdirSync(screenshotsDir, { recursive: true });
|
|
3952
|
+
}
|
|
3953
|
+
return screenshotsDir;
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
/**
|
|
3957
|
+
* Validate filename to prevent path traversal
|
|
3958
|
+
*/
|
|
3959
|
+
function isValidFilename(filename: string): boolean {
|
|
3960
|
+
// Only allow alphanumeric, hyphens, underscores, and dots
|
|
3961
|
+
// Must end with .png, .jpeg, .jpg, or .json
|
|
3962
|
+
const validPattern = /^[a-zA-Z0-9_-]+\\.(png|jpeg|jpg|json)$/;
|
|
3963
|
+
return validPattern.test(filename) && !filename.includes("..");
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
/**
|
|
3967
|
+
* POST: Save a screenshot and optionally its manifest
|
|
3968
|
+
*/
|
|
3969
|
+
export async function POST(request: NextRequest) {
|
|
3970
|
+
// Block in production
|
|
3971
|
+
if (process.env.NODE_ENV === "production") {
|
|
3972
|
+
return NextResponse.json(
|
|
3973
|
+
{ error: "Not available in production" },
|
|
3974
|
+
{ status: 404 }
|
|
3975
|
+
);
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
try {
|
|
3979
|
+
const body = await request.json();
|
|
3980
|
+
const { filename, imageData, manifest, analysisResult } = body;
|
|
3981
|
+
|
|
3982
|
+
if (!filename) {
|
|
3983
|
+
return NextResponse.json({ error: "Missing 'filename'" }, { status: 400 });
|
|
3984
|
+
}
|
|
3985
|
+
|
|
3986
|
+
// Validate filename
|
|
3987
|
+
if (!isValidFilename(filename)) {
|
|
3988
|
+
return NextResponse.json(
|
|
3989
|
+
{ error: "Invalid filename format" },
|
|
3990
|
+
{ status: 400 }
|
|
3991
|
+
);
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
// Allow "sidecar-only" updates (manifest/analysisResult) without re-sending image bytes.
|
|
3995
|
+
const hasImageData = typeof imageData === "string" && imageData.length > 0;
|
|
3996
|
+
const hasSidecar =
|
|
3997
|
+
typeof manifest !== "undefined" || typeof analysisResult !== "undefined";
|
|
3998
|
+
|
|
3999
|
+
if (!hasImageData && !hasSidecar) {
|
|
4000
|
+
return NextResponse.json(
|
|
4001
|
+
{ error: "Nothing to save (provide imageData and/or manifest/analysisResult)" },
|
|
4002
|
+
{ status: 400 }
|
|
4003
|
+
);
|
|
4004
|
+
}
|
|
4005
|
+
|
|
4006
|
+
// Check size (image only)
|
|
4007
|
+
if (hasImageData && imageData.length > MAX_SCREENSHOT_SIZE) {
|
|
4008
|
+
return NextResponse.json(
|
|
4009
|
+
{ error: "Screenshot too large (max 10MB)" },
|
|
4010
|
+
{ status: 413 }
|
|
4011
|
+
);
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
const projectRoot = findNextProjectRoot();
|
|
4015
|
+
const screenshotsDir = getScreenshotsDir(projectRoot);
|
|
4016
|
+
|
|
4017
|
+
const imagePath = join(screenshotsDir, filename);
|
|
4018
|
+
|
|
4019
|
+
// Save the image (base64 data URL) if provided
|
|
4020
|
+
if (hasImageData) {
|
|
4021
|
+
const base64Data = imageData.includes(",")
|
|
4022
|
+
? imageData.split(",")[1]
|
|
4023
|
+
: imageData;
|
|
4024
|
+
writeFileSync(imagePath, Buffer.from(base64Data, "base64"));
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
// Save manifest and analysis result as JSON sidecar
|
|
4028
|
+
if (hasSidecar) {
|
|
4029
|
+
const jsonFilename = filename.replace(/\\.(png|jpeg|jpg)$/, ".json");
|
|
4030
|
+
const jsonPath = join(screenshotsDir, jsonFilename);
|
|
4031
|
+
|
|
4032
|
+
// If a sidecar already exists, merge updates (lets us POST analysisResult later without re-sending image).
|
|
4033
|
+
let existing: any = null;
|
|
4034
|
+
if (existsSync(jsonPath)) {
|
|
4035
|
+
try {
|
|
4036
|
+
existing = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
|
4037
|
+
} catch {
|
|
4038
|
+
existing = null;
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
|
|
4042
|
+
const routeFromAnalysis =
|
|
4043
|
+
analysisResult && typeof analysisResult === "object"
|
|
4044
|
+
? (analysisResult as any).route
|
|
4045
|
+
: undefined;
|
|
4046
|
+
const issuesFromAnalysis =
|
|
4047
|
+
analysisResult && typeof analysisResult === "object"
|
|
4048
|
+
? (analysisResult as any).issues
|
|
4049
|
+
: undefined;
|
|
4050
|
+
|
|
4051
|
+
const jsonData = {
|
|
4052
|
+
...(existing && typeof existing === "object" ? existing : {}),
|
|
4053
|
+
timestamp: Date.now(),
|
|
4054
|
+
filename,
|
|
4055
|
+
screenshotFile: filename,
|
|
4056
|
+
route:
|
|
4057
|
+
typeof routeFromAnalysis === "string"
|
|
4058
|
+
? routeFromAnalysis
|
|
4059
|
+
: (existing as any)?.route ?? null,
|
|
4060
|
+
issues:
|
|
4061
|
+
Array.isArray(issuesFromAnalysis)
|
|
4062
|
+
? issuesFromAnalysis
|
|
4063
|
+
: (existing as any)?.issues ?? null,
|
|
4064
|
+
manifest: typeof manifest === "undefined" ? existing?.manifest ?? null : manifest,
|
|
4065
|
+
analysisResult:
|
|
4066
|
+
typeof analysisResult === "undefined"
|
|
4067
|
+
? existing?.analysisResult ?? null
|
|
4068
|
+
: analysisResult,
|
|
4069
|
+
};
|
|
4070
|
+
writeFileSync(jsonPath, JSON.stringify(jsonData, null, 2));
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
return NextResponse.json({
|
|
4074
|
+
success: true,
|
|
4075
|
+
path: imagePath,
|
|
4076
|
+
projectRoot,
|
|
4077
|
+
screenshotsDir,
|
|
4078
|
+
});
|
|
4079
|
+
} catch (error) {
|
|
4080
|
+
console.error("[Screenshot API] Error saving screenshot:", error);
|
|
4081
|
+
return NextResponse.json(
|
|
4082
|
+
{ error: "Failed to save screenshot" },
|
|
4083
|
+
{ status: 500 }
|
|
4084
|
+
);
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
/**
|
|
4089
|
+
* GET: Retrieve a screenshot or list available screenshots
|
|
4090
|
+
*/
|
|
4091
|
+
export async function GET(request: NextRequest) {
|
|
4092
|
+
// Block in production
|
|
4093
|
+
if (process.env.NODE_ENV === "production") {
|
|
4094
|
+
return NextResponse.json(
|
|
4095
|
+
{ error: "Not available in production" },
|
|
4096
|
+
{ status: 404 }
|
|
4097
|
+
);
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
const { searchParams } = new URL(request.url);
|
|
4101
|
+
const filename = searchParams.get("filename");
|
|
4102
|
+
const list = searchParams.get("list");
|
|
4103
|
+
|
|
4104
|
+
const projectRoot = findNextProjectRoot();
|
|
4105
|
+
const screenshotsDir = getScreenshotsDir(projectRoot);
|
|
4106
|
+
|
|
4107
|
+
// List mode: return all screenshots
|
|
4108
|
+
if (list === "true") {
|
|
4109
|
+
try {
|
|
4110
|
+
const files = readdirSync(screenshotsDir);
|
|
4111
|
+
const screenshots = files
|
|
4112
|
+
.filter((f) => /\\.(png|jpeg|jpg)$/.test(f))
|
|
4113
|
+
.map((f) => {
|
|
4114
|
+
const jsonFile = f.replace(/\\.(png|jpeg|jpg)$/, ".json");
|
|
4115
|
+
const jsonPath = join(screenshotsDir, jsonFile);
|
|
4116
|
+
let metadata = null;
|
|
4117
|
+
if (existsSync(jsonPath)) {
|
|
4118
|
+
try {
|
|
4119
|
+
metadata = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
|
4120
|
+
} catch {
|
|
4121
|
+
// Ignore parse errors
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
return {
|
|
4125
|
+
filename: f,
|
|
4126
|
+
metadata,
|
|
4127
|
+
};
|
|
4128
|
+
})
|
|
4129
|
+
.sort((a, b) => {
|
|
4130
|
+
// Sort by timestamp descending (newest first)
|
|
4131
|
+
const aTime = a.metadata?.timestamp || 0;
|
|
4132
|
+
const bTime = b.metadata?.timestamp || 0;
|
|
4133
|
+
return bTime - aTime;
|
|
4134
|
+
});
|
|
4135
|
+
|
|
4136
|
+
return NextResponse.json({ screenshots, projectRoot, screenshotsDir });
|
|
4137
|
+
} catch (error) {
|
|
4138
|
+
console.error("[Screenshot API] Error listing screenshots:", error);
|
|
4139
|
+
return NextResponse.json(
|
|
4140
|
+
{ error: "Failed to list screenshots" },
|
|
4141
|
+
{ status: 500 }
|
|
4142
|
+
);
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
|
|
4146
|
+
// Retrieve mode: get specific screenshot
|
|
4147
|
+
if (!filename) {
|
|
4148
|
+
return NextResponse.json(
|
|
4149
|
+
{ error: "Missing 'filename' parameter" },
|
|
4150
|
+
{ status: 400 }
|
|
4151
|
+
);
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
if (!isValidFilename(filename)) {
|
|
4155
|
+
return NextResponse.json(
|
|
4156
|
+
{ error: "Invalid filename format" },
|
|
4157
|
+
{ status: 400 }
|
|
4158
|
+
);
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
const filePath = join(screenshotsDir, filename);
|
|
4162
|
+
|
|
4163
|
+
if (!existsSync(filePath)) {
|
|
4164
|
+
return NextResponse.json(
|
|
4165
|
+
{ error: "Screenshot not found" },
|
|
4166
|
+
{ status: 404 }
|
|
4167
|
+
);
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
try {
|
|
4171
|
+
const content = readFileSync(filePath);
|
|
4172
|
+
|
|
4173
|
+
// Determine content type
|
|
4174
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
4175
|
+
const contentType =
|
|
4176
|
+
ext === "json"
|
|
4177
|
+
? "application/json"
|
|
4178
|
+
: ext === "png"
|
|
4179
|
+
? "image/png"
|
|
4180
|
+
: "image/jpeg";
|
|
4181
|
+
|
|
4182
|
+
if (ext === "json") {
|
|
4183
|
+
return NextResponse.json(JSON.parse(content.toString()));
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
return new NextResponse(content, {
|
|
4187
|
+
headers: {
|
|
4188
|
+
"Content-Type": contentType,
|
|
4189
|
+
"Cache-Control": "no-cache",
|
|
4190
|
+
},
|
|
4191
|
+
});
|
|
4192
|
+
} catch (error) {
|
|
4193
|
+
console.error("[Screenshot API] Error reading screenshot:", error);
|
|
4194
|
+
return NextResponse.json(
|
|
4195
|
+
{ error: "Failed to read screenshot" },
|
|
4196
|
+
{ status: 500 }
|
|
4197
|
+
);
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
`;
|
|
3284
4201
|
async function writeRouteFile(absPath, relPath, content, opts) {
|
|
3285
|
-
if (
|
|
4202
|
+
if (existsSync14(absPath) && !opts.force) return;
|
|
3286
4203
|
await writeFile(absPath, content, "utf-8");
|
|
3287
4204
|
}
|
|
3288
4205
|
async function installNextUILintRoutes(opts) {
|
|
3289
|
-
const baseRel =
|
|
3290
|
-
const baseAbs =
|
|
3291
|
-
await mkdir(
|
|
4206
|
+
const baseRel = join14(opts.appRoot, "api", ".uilint");
|
|
4207
|
+
const baseAbs = join14(opts.projectPath, baseRel);
|
|
4208
|
+
await mkdir(join14(baseAbs, "source"), { recursive: true });
|
|
3292
4209
|
await writeRouteFile(
|
|
3293
|
-
|
|
3294
|
-
|
|
4210
|
+
join14(baseAbs, "source", "route.ts"),
|
|
4211
|
+
join14(baseRel, "source", "route.ts"),
|
|
3295
4212
|
DEV_SOURCE_ROUTE_TS,
|
|
3296
4213
|
opts
|
|
3297
4214
|
);
|
|
4215
|
+
await mkdir(join14(baseAbs, "screenshots"), { recursive: true });
|
|
4216
|
+
await writeRouteFile(
|
|
4217
|
+
join14(baseAbs, "screenshots", "route.ts"),
|
|
4218
|
+
join14(baseRel, "screenshots", "route.ts"),
|
|
4219
|
+
SCREENSHOT_ROUTE_TS,
|
|
4220
|
+
opts
|
|
4221
|
+
);
|
|
3298
4222
|
}
|
|
3299
4223
|
|
|
3300
4224
|
// src/commands/install/execute.ts
|
|
@@ -3310,7 +4234,7 @@ async function executeAction(action, options) {
|
|
|
3310
4234
|
wouldDo: `Create directory: ${action.path}`
|
|
3311
4235
|
};
|
|
3312
4236
|
}
|
|
3313
|
-
if (!
|
|
4237
|
+
if (!existsSync15(action.path)) {
|
|
3314
4238
|
mkdirSync3(action.path, { recursive: true });
|
|
3315
4239
|
}
|
|
3316
4240
|
return { action, success: true };
|
|
@@ -3323,11 +4247,11 @@ async function executeAction(action, options) {
|
|
|
3323
4247
|
wouldDo: `Create file: ${action.path}${action.permissions ? ` (mode: ${action.permissions.toString(8)})` : ""}`
|
|
3324
4248
|
};
|
|
3325
4249
|
}
|
|
3326
|
-
const dir =
|
|
3327
|
-
if (!
|
|
4250
|
+
const dir = dirname7(action.path);
|
|
4251
|
+
if (!existsSync15(dir)) {
|
|
3328
4252
|
mkdirSync3(dir, { recursive: true });
|
|
3329
4253
|
}
|
|
3330
|
-
|
|
4254
|
+
writeFileSync7(action.path, action.content, "utf-8");
|
|
3331
4255
|
if (action.permissions) {
|
|
3332
4256
|
chmodSync(action.path, action.permissions);
|
|
3333
4257
|
}
|
|
@@ -3342,18 +4266,18 @@ async function executeAction(action, options) {
|
|
|
3342
4266
|
};
|
|
3343
4267
|
}
|
|
3344
4268
|
let existing = {};
|
|
3345
|
-
if (
|
|
4269
|
+
if (existsSync15(action.path)) {
|
|
3346
4270
|
try {
|
|
3347
|
-
existing = JSON.parse(
|
|
4271
|
+
existing = JSON.parse(readFileSync10(action.path, "utf-8"));
|
|
3348
4272
|
} catch {
|
|
3349
4273
|
}
|
|
3350
4274
|
}
|
|
3351
4275
|
const merged = deepMerge(existing, action.merge);
|
|
3352
|
-
const dir =
|
|
3353
|
-
if (!
|
|
4276
|
+
const dir = dirname7(action.path);
|
|
4277
|
+
if (!existsSync15(dir)) {
|
|
3354
4278
|
mkdirSync3(dir, { recursive: true });
|
|
3355
4279
|
}
|
|
3356
|
-
|
|
4280
|
+
writeFileSync7(action.path, JSON.stringify(merged, null, 2), "utf-8");
|
|
3357
4281
|
return { action, success: true };
|
|
3358
4282
|
}
|
|
3359
4283
|
case "delete_file": {
|
|
@@ -3364,7 +4288,7 @@ async function executeAction(action, options) {
|
|
|
3364
4288
|
wouldDo: `Delete file: ${action.path}`
|
|
3365
4289
|
};
|
|
3366
4290
|
}
|
|
3367
|
-
if (
|
|
4291
|
+
if (existsSync15(action.path)) {
|
|
3368
4292
|
unlinkSync(action.path);
|
|
3369
4293
|
}
|
|
3370
4294
|
return { action, success: true };
|
|
@@ -3377,12 +4301,12 @@ async function executeAction(action, options) {
|
|
|
3377
4301
|
wouldDo: `Append to file: ${action.path}`
|
|
3378
4302
|
};
|
|
3379
4303
|
}
|
|
3380
|
-
if (
|
|
3381
|
-
const content =
|
|
4304
|
+
if (existsSync15(action.path)) {
|
|
4305
|
+
const content = readFileSync10(action.path, "utf-8");
|
|
3382
4306
|
if (action.ifNotContains && content.includes(action.ifNotContains)) {
|
|
3383
4307
|
return { action, success: true };
|
|
3384
4308
|
}
|
|
3385
|
-
|
|
4309
|
+
writeFileSync7(action.path, content + action.content, "utf-8");
|
|
3386
4310
|
}
|
|
3387
4311
|
return { action, success: true };
|
|
3388
4312
|
}
|
|
@@ -3395,6 +4319,9 @@ async function executeAction(action, options) {
|
|
|
3395
4319
|
case "inject_next_config": {
|
|
3396
4320
|
return await executeInjectNextConfig(action, options);
|
|
3397
4321
|
}
|
|
4322
|
+
case "inject_vite_config": {
|
|
4323
|
+
return await executeInjectViteConfig(action, options);
|
|
4324
|
+
}
|
|
3398
4325
|
case "install_next_routes": {
|
|
3399
4326
|
return await executeInstallNextRoutes(action, options);
|
|
3400
4327
|
}
|
|
@@ -3450,6 +4377,7 @@ async function executeInjectReact(action, options) {
|
|
|
3450
4377
|
const result = await installReactUILintOverlay({
|
|
3451
4378
|
projectPath: action.projectPath,
|
|
3452
4379
|
appRoot: action.appRoot,
|
|
4380
|
+
mode: action.mode,
|
|
3453
4381
|
force: false,
|
|
3454
4382
|
// Auto-select first choice for execute phase
|
|
3455
4383
|
confirmFileChoice: async (choices) => choices[0]
|
|
@@ -3461,6 +4389,25 @@ async function executeInjectReact(action, options) {
|
|
|
3461
4389
|
error: success ? void 0 : "Failed to configure React overlay"
|
|
3462
4390
|
};
|
|
3463
4391
|
}
|
|
4392
|
+
async function executeInjectViteConfig(action, options) {
|
|
4393
|
+
const { dryRun = false } = options;
|
|
4394
|
+
if (dryRun) {
|
|
4395
|
+
return {
|
|
4396
|
+
action,
|
|
4397
|
+
success: true,
|
|
4398
|
+
wouldDo: `Inject jsx-loc-plugin into vite.config: ${action.projectPath}`
|
|
4399
|
+
};
|
|
4400
|
+
}
|
|
4401
|
+
const result = await installViteJsxLocPlugin({
|
|
4402
|
+
projectPath: action.projectPath,
|
|
4403
|
+
force: false
|
|
4404
|
+
});
|
|
4405
|
+
return {
|
|
4406
|
+
action,
|
|
4407
|
+
success: result.modified || result.configFile !== null,
|
|
4408
|
+
error: result.configFile === null ? "No vite.config found" : void 0
|
|
4409
|
+
};
|
|
4410
|
+
}
|
|
3464
4411
|
async function executeInjectNextConfig(action, options) {
|
|
3465
4412
|
const { dryRun = false } = options;
|
|
3466
4413
|
if (dryRun) {
|
|
@@ -3518,6 +4465,7 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
|
|
|
3518
4465
|
const filesDeleted = [];
|
|
3519
4466
|
const eslintTargets = [];
|
|
3520
4467
|
let nextApp;
|
|
4468
|
+
let viteApp;
|
|
3521
4469
|
for (const result of actionsPerformed) {
|
|
3522
4470
|
if (!result.success) continue;
|
|
3523
4471
|
const { action } = result;
|
|
@@ -3540,6 +4488,12 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
|
|
|
3540
4488
|
});
|
|
3541
4489
|
break;
|
|
3542
4490
|
case "inject_react":
|
|
4491
|
+
if (action.mode === "vite") {
|
|
4492
|
+
viteApp = { entryRoot: action.appRoot };
|
|
4493
|
+
} else {
|
|
4494
|
+
nextApp = { appRoot: action.appRoot };
|
|
4495
|
+
}
|
|
4496
|
+
break;
|
|
3543
4497
|
case "install_next_routes":
|
|
3544
4498
|
nextApp = { appRoot: action.appRoot };
|
|
3545
4499
|
break;
|
|
@@ -3561,7 +4515,8 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
|
|
|
3561
4515
|
filesDeleted,
|
|
3562
4516
|
dependenciesInstalled,
|
|
3563
4517
|
eslintTargets,
|
|
3564
|
-
nextApp
|
|
4518
|
+
nextApp,
|
|
4519
|
+
viteApp
|
|
3565
4520
|
};
|
|
3566
4521
|
}
|
|
3567
4522
|
async function execute(plan, options = {}) {
|
|
@@ -3611,11 +4566,14 @@ async function execute(plan, options = {}) {
|
|
|
3611
4566
|
if (action.path.includes("hooks.json")) items.push("hooks");
|
|
3612
4567
|
if (action.path.includes("genstyleguide.md")) items.push("genstyleguide");
|
|
3613
4568
|
if (action.path.includes("genrules.md")) items.push("genrules");
|
|
4569
|
+
if (action.path.includes("/skills/") && action.path.includes("SKILL.md")) items.push("skill");
|
|
3614
4570
|
}
|
|
3615
4571
|
if (action.type === "inject_eslint") items.push("eslint");
|
|
3616
|
-
if (action.type === "
|
|
3617
|
-
|
|
4572
|
+
if (action.type === "install_next_routes") items.push("next");
|
|
4573
|
+
if (action.type === "inject_react") {
|
|
4574
|
+
items.push(action.mode === "vite" ? "vite" : "next");
|
|
3618
4575
|
}
|
|
4576
|
+
if (action.type === "inject_vite_config") items.push("vite");
|
|
3619
4577
|
}
|
|
3620
4578
|
const uniqueItems = [...new Set(items)];
|
|
3621
4579
|
const summary = buildSummary(
|
|
@@ -3641,13 +4599,18 @@ var cliPrompter = {
|
|
|
3641
4599
|
{
|
|
3642
4600
|
value: "eslint",
|
|
3643
4601
|
label: "ESLint plugin",
|
|
3644
|
-
hint: "Installs uilint-eslint and configures eslint.config
|
|
4602
|
+
hint: "Installs uilint-eslint and configures eslint.config.*"
|
|
3645
4603
|
},
|
|
3646
4604
|
{
|
|
3647
4605
|
value: "next",
|
|
3648
4606
|
label: "UI overlay",
|
|
3649
4607
|
hint: "Installs routes + UILintProvider (Alt+Click to inspect)"
|
|
3650
4608
|
},
|
|
4609
|
+
{
|
|
4610
|
+
value: "vite",
|
|
4611
|
+
label: "UI overlay (Vite)",
|
|
4612
|
+
hint: "Installs jsx-loc-plugin + UILintProvider (Alt+Click to inspect)"
|
|
4613
|
+
},
|
|
3651
4614
|
{
|
|
3652
4615
|
value: "genstyleguide",
|
|
3653
4616
|
label: "/genstyleguide command",
|
|
@@ -3667,10 +4630,15 @@ var cliPrompter = {
|
|
|
3667
4630
|
value: "genrules",
|
|
3668
4631
|
label: "/genrules command",
|
|
3669
4632
|
hint: "Adds .cursor/commands/genrules.md for ESLint rule generation"
|
|
4633
|
+
},
|
|
4634
|
+
{
|
|
4635
|
+
value: "skill",
|
|
4636
|
+
label: "UI Consistency Agent Skill",
|
|
4637
|
+
hint: "Cursor agent skill for generating ESLint rules from UI patterns"
|
|
3670
4638
|
}
|
|
3671
4639
|
],
|
|
3672
4640
|
required: true,
|
|
3673
|
-
initialValues: ["eslint", "next", "genstyleguide"]
|
|
4641
|
+
initialValues: ["eslint", "next", "genstyleguide", "skill"]
|
|
3674
4642
|
});
|
|
3675
4643
|
},
|
|
3676
4644
|
async confirmMcpMerge() {
|
|
@@ -3700,6 +4668,17 @@ var cliPrompter = {
|
|
|
3700
4668
|
});
|
|
3701
4669
|
return apps.find((a) => a.projectPath === chosen) || apps[0];
|
|
3702
4670
|
},
|
|
4671
|
+
async selectViteApp(apps) {
|
|
4672
|
+
const chosen = await select2({
|
|
4673
|
+
message: "Which Vite + React project should UILint install into?",
|
|
4674
|
+
options: apps.map((app) => ({
|
|
4675
|
+
value: app.projectPath,
|
|
4676
|
+
label: app.projectPath
|
|
4677
|
+
})),
|
|
4678
|
+
initialValue: apps[0].projectPath
|
|
4679
|
+
});
|
|
4680
|
+
return apps.find((a) => a.projectPath === chosen) || apps[0];
|
|
4681
|
+
},
|
|
3703
4682
|
async selectEslintPackages(packages) {
|
|
3704
4683
|
if (packages.length === 1) {
|
|
3705
4684
|
const confirmed = await confirm2({
|
|
@@ -3856,13 +4835,14 @@ async function promptForField(field, ruleName) {
|
|
|
3856
4835
|
}
|
|
3857
4836
|
async function gatherChoices(state, options, prompter) {
|
|
3858
4837
|
let items;
|
|
3859
|
-
const hasExplicitFlags = options.mcp !== void 0 || options.hooks !== void 0 || options.genstyleguide !== void 0 || options.genrules !== void 0 || options.routes !== void 0 || options.react !== void 0;
|
|
4838
|
+
const hasExplicitFlags = options.mcp !== void 0 || options.hooks !== void 0 || options.genstyleguide !== void 0 || options.genrules !== void 0 || options.skill !== void 0 || options.routes !== void 0 || options.react !== void 0;
|
|
3860
4839
|
if (hasExplicitFlags || options.eslint) {
|
|
3861
4840
|
items = [];
|
|
3862
4841
|
if (options.mcp) items.push("mcp");
|
|
3863
4842
|
if (options.hooks) items.push("hooks");
|
|
3864
4843
|
if (options.genstyleguide) items.push("genstyleguide");
|
|
3865
4844
|
if (options.genrules) items.push("genrules");
|
|
4845
|
+
if (options.skill) items.push("skill");
|
|
3866
4846
|
if (options.routes || options.react) items.push("next");
|
|
3867
4847
|
if (options.eslint) items.push("eslint");
|
|
3868
4848
|
} else if (options.mode) {
|
|
@@ -3901,6 +4881,25 @@ async function gatherChoices(state, options, prompter) {
|
|
|
3901
4881
|
};
|
|
3902
4882
|
}
|
|
3903
4883
|
}
|
|
4884
|
+
let viteChoices;
|
|
4885
|
+
if (items.includes("vite")) {
|
|
4886
|
+
if (state.viteApps.length === 0) {
|
|
4887
|
+
throw new Error(
|
|
4888
|
+
"Could not find a Vite + React project (expected vite.config.* + react deps). Run this from your Vite project root."
|
|
4889
|
+
);
|
|
4890
|
+
} else if (state.viteApps.length === 1) {
|
|
4891
|
+
viteChoices = {
|
|
4892
|
+
projectPath: state.viteApps[0].projectPath,
|
|
4893
|
+
detection: state.viteApps[0].detection
|
|
4894
|
+
};
|
|
4895
|
+
} else {
|
|
4896
|
+
const selected = await prompter.selectViteApp(state.viteApps);
|
|
4897
|
+
viteChoices = {
|
|
4898
|
+
projectPath: selected.projectPath,
|
|
4899
|
+
detection: selected.detection
|
|
4900
|
+
};
|
|
4901
|
+
}
|
|
4902
|
+
}
|
|
3904
4903
|
let eslintChoices;
|
|
3905
4904
|
if (items.includes("eslint")) {
|
|
3906
4905
|
const packagesWithEslint = state.packages.filter(
|
|
@@ -3908,7 +4907,7 @@ async function gatherChoices(state, options, prompter) {
|
|
|
3908
4907
|
);
|
|
3909
4908
|
if (packagesWithEslint.length === 0) {
|
|
3910
4909
|
throw new Error(
|
|
3911
|
-
"No packages with eslint.config.{mjs,js,cjs} found. Create an ESLint config first."
|
|
4910
|
+
"No packages with eslint.config.{ts,mjs,js,cjs} found. Create an ESLint config first."
|
|
3912
4911
|
);
|
|
3913
4912
|
}
|
|
3914
4913
|
const packagePaths = await prompter.selectEslintPackages(
|
|
@@ -3940,6 +4939,7 @@ async function gatherChoices(state, options, prompter) {
|
|
|
3940
4939
|
mcpMerge,
|
|
3941
4940
|
hooksMerge,
|
|
3942
4941
|
next: nextChoices,
|
|
4942
|
+
vite: viteChoices,
|
|
3943
4943
|
eslint: eslintChoices
|
|
3944
4944
|
};
|
|
3945
4945
|
}
|
|
@@ -3988,7 +4988,7 @@ function displayResults(result) {
|
|
|
3988
4988
|
if (summary.nextApp) {
|
|
3989
4989
|
installedItems.push(
|
|
3990
4990
|
`${pc.cyan("Next Routes")} \u2192 ${pc.dim(
|
|
3991
|
-
|
|
4991
|
+
join15(summary.nextApp.appRoot, "api/.uilint")
|
|
3992
4992
|
)}`
|
|
3993
4993
|
);
|
|
3994
4994
|
installedItems.push(
|
|
@@ -3996,7 +4996,17 @@ function displayResults(result) {
|
|
|
3996
4996
|
);
|
|
3997
4997
|
installedItems.push(
|
|
3998
4998
|
`${pc.cyan("JSX Loc Plugin")} \u2192 ${pc.dim(
|
|
3999
|
-
"next.config wrapped with withJsxLoc"
|
|
4999
|
+
"next.config wrapped with withJsxLoc"
|
|
5000
|
+
)}`
|
|
5001
|
+
);
|
|
5002
|
+
}
|
|
5003
|
+
if (summary.viteApp) {
|
|
5004
|
+
installedItems.push(
|
|
5005
|
+
`${pc.cyan("Vite Overlay")} \u2192 ${pc.dim("<UILintProvider> injected")}`
|
|
5006
|
+
);
|
|
5007
|
+
installedItems.push(
|
|
5008
|
+
`${pc.cyan("JSX Loc Plugin")} \u2192 ${pc.dim(
|
|
5009
|
+
"vite.config plugins patched with jsxLoc()"
|
|
4000
5010
|
)}`
|
|
4001
5011
|
);
|
|
4002
5012
|
}
|
|
@@ -4044,6 +5054,11 @@ function displayResults(result) {
|
|
|
4044
5054
|
"Run your Next.js dev server - use Alt+Click on any element to inspect"
|
|
4045
5055
|
);
|
|
4046
5056
|
}
|
|
5057
|
+
if (summary.viteApp) {
|
|
5058
|
+
steps.push(
|
|
5059
|
+
"Run your Vite dev server - use Alt+Click on any element to inspect"
|
|
5060
|
+
);
|
|
5061
|
+
}
|
|
4047
5062
|
if (summary.eslintTargets.length > 0) {
|
|
4048
5063
|
steps.push(`Run ${pc.cyan("npx eslint src/")} to check for issues`);
|
|
4049
5064
|
steps.push(
|
|
@@ -4108,12 +5123,207 @@ async function install(options = {}, prompter = cliPrompter, executeOptions = {}
|
|
|
4108
5123
|
}
|
|
4109
5124
|
|
|
4110
5125
|
// src/commands/serve.ts
|
|
4111
|
-
import { existsSync as
|
|
5126
|
+
import { existsSync as existsSync17, statSync as statSync4, readdirSync as readdirSync5, readFileSync as readFileSync11 } from "fs";
|
|
4112
5127
|
import { createRequire as createRequire2 } from "module";
|
|
4113
|
-
import { dirname as
|
|
5128
|
+
import { dirname as dirname9, resolve as resolve5, relative as relative3, join as join17, parse as parse2 } from "path";
|
|
4114
5129
|
import { WebSocketServer, WebSocket } from "ws";
|
|
4115
5130
|
import { watch } from "chokidar";
|
|
4116
|
-
import {
|
|
5131
|
+
import {
|
|
5132
|
+
findWorkspaceRoot as findWorkspaceRoot5,
|
|
5133
|
+
getVisionAnalyzer as getCoreVisionAnalyzer
|
|
5134
|
+
} from "uilint-core/node";
|
|
5135
|
+
|
|
5136
|
+
// src/utils/vision-run.ts
|
|
5137
|
+
import { dirname as dirname8, join as join16, parse } from "path";
|
|
5138
|
+
import { existsSync as existsSync16, statSync as statSync3, mkdirSync as mkdirSync4, writeFileSync as writeFileSync8 } from "fs";
|
|
5139
|
+
import {
|
|
5140
|
+
ensureOllamaReady as ensureOllamaReady5,
|
|
5141
|
+
findStyleGuidePath as findStyleGuidePath4,
|
|
5142
|
+
findUILintStyleGuideUpwards as findUILintStyleGuideUpwards3,
|
|
5143
|
+
readStyleGuide as readStyleGuide4,
|
|
5144
|
+
VisionAnalyzer,
|
|
5145
|
+
UILINT_DEFAULT_VISION_MODEL
|
|
5146
|
+
} from "uilint-core/node";
|
|
5147
|
+
async function resolveVisionStyleGuide(args) {
|
|
5148
|
+
const projectPath = args.projectPath;
|
|
5149
|
+
const startDir = args.startDir ?? projectPath;
|
|
5150
|
+
if (args.styleguide) {
|
|
5151
|
+
const styleguideArg = resolvePathSpecifier(args.styleguide, projectPath);
|
|
5152
|
+
if (existsSync16(styleguideArg)) {
|
|
5153
|
+
const stat = statSync3(styleguideArg);
|
|
5154
|
+
if (stat.isFile()) {
|
|
5155
|
+
return {
|
|
5156
|
+
styleguideLocation: styleguideArg,
|
|
5157
|
+
styleGuide: await readStyleGuide4(styleguideArg)
|
|
5158
|
+
};
|
|
5159
|
+
}
|
|
5160
|
+
if (stat.isDirectory()) {
|
|
5161
|
+
const found = findStyleGuidePath4(styleguideArg);
|
|
5162
|
+
return {
|
|
5163
|
+
styleguideLocation: found,
|
|
5164
|
+
styleGuide: found ? await readStyleGuide4(found) : null
|
|
5165
|
+
};
|
|
5166
|
+
}
|
|
5167
|
+
}
|
|
5168
|
+
return { styleGuide: null, styleguideLocation: null };
|
|
5169
|
+
}
|
|
5170
|
+
const upwards = findUILintStyleGuideUpwards3(startDir);
|
|
5171
|
+
const fallback = upwards ?? findStyleGuidePath4(projectPath);
|
|
5172
|
+
return {
|
|
5173
|
+
styleguideLocation: fallback,
|
|
5174
|
+
styleGuide: fallback ? await readStyleGuide4(fallback) : null
|
|
5175
|
+
};
|
|
5176
|
+
}
|
|
5177
|
+
var ollamaReadyOnce = /* @__PURE__ */ new Map();
|
|
5178
|
+
async function ensureOllamaReadyCached(params) {
|
|
5179
|
+
const key = `${params.baseUrl}::${params.model}`;
|
|
5180
|
+
const existing = ollamaReadyOnce.get(key);
|
|
5181
|
+
if (existing) return existing;
|
|
5182
|
+
const p2 = ensureOllamaReady5({ model: params.model, baseUrl: params.baseUrl }).then(() => void 0).catch((e) => {
|
|
5183
|
+
ollamaReadyOnce.delete(key);
|
|
5184
|
+
throw e;
|
|
5185
|
+
});
|
|
5186
|
+
ollamaReadyOnce.set(key, p2);
|
|
5187
|
+
return p2;
|
|
5188
|
+
}
|
|
5189
|
+
function writeVisionDebugDump(params) {
|
|
5190
|
+
const resolvedDirOrFile = resolvePathSpecifier(
|
|
5191
|
+
params.dumpPath,
|
|
5192
|
+
process.cwd()
|
|
5193
|
+
);
|
|
5194
|
+
const safeStamp = params.now.toISOString().replace(/[:.]/g, "-");
|
|
5195
|
+
const dumpFile = resolvedDirOrFile.endsWith(".json") || resolvedDirOrFile.endsWith(".jsonl") ? resolvedDirOrFile : `${resolvedDirOrFile}/vision-debug-${safeStamp}.json`;
|
|
5196
|
+
mkdirSync4(dirname8(dumpFile), { recursive: true });
|
|
5197
|
+
writeFileSync8(
|
|
5198
|
+
dumpFile,
|
|
5199
|
+
JSON.stringify(
|
|
5200
|
+
{
|
|
5201
|
+
version: 1,
|
|
5202
|
+
timestamp: params.now.toISOString(),
|
|
5203
|
+
runtime: params.runtime,
|
|
5204
|
+
metadata: params.metadata ?? null,
|
|
5205
|
+
inputs: {
|
|
5206
|
+
imageBase64: params.includeSensitive ? params.inputs.imageBase64 : "(omitted; set debugDumpIncludeSensitive=true)",
|
|
5207
|
+
manifest: params.inputs.manifest,
|
|
5208
|
+
styleguideLocation: params.inputs.styleguideLocation,
|
|
5209
|
+
styleGuide: params.includeSensitive ? params.inputs.styleGuide : "(omitted; set debugDumpIncludeSensitive=true)"
|
|
5210
|
+
}
|
|
5211
|
+
},
|
|
5212
|
+
null,
|
|
5213
|
+
2
|
|
5214
|
+
),
|
|
5215
|
+
"utf-8"
|
|
5216
|
+
);
|
|
5217
|
+
return dumpFile;
|
|
5218
|
+
}
|
|
5219
|
+
async function runVisionAnalysis(args) {
|
|
5220
|
+
const visionModel = args.model || UILINT_DEFAULT_VISION_MODEL;
|
|
5221
|
+
const baseUrl = args.baseUrl ?? "http://localhost:11434";
|
|
5222
|
+
let styleGuide = null;
|
|
5223
|
+
let styleguideLocation = null;
|
|
5224
|
+
if (args.styleGuide !== void 0) {
|
|
5225
|
+
styleGuide = args.styleGuide;
|
|
5226
|
+
styleguideLocation = args.styleguideLocation ?? null;
|
|
5227
|
+
} else {
|
|
5228
|
+
args.onPhase?.("Resolving styleguide...");
|
|
5229
|
+
const resolved = await resolveVisionStyleGuide({
|
|
5230
|
+
projectPath: args.projectPath,
|
|
5231
|
+
styleguide: args.styleguide,
|
|
5232
|
+
startDir: args.styleguideStartDir
|
|
5233
|
+
});
|
|
5234
|
+
styleGuide = resolved.styleGuide;
|
|
5235
|
+
styleguideLocation = resolved.styleguideLocation;
|
|
5236
|
+
}
|
|
5237
|
+
if (!args.skipEnsureOllama) {
|
|
5238
|
+
args.onPhase?.("Preparing Ollama...");
|
|
5239
|
+
await ensureOllamaReadyCached({ model: visionModel, baseUrl });
|
|
5240
|
+
}
|
|
5241
|
+
if (args.debugDump) {
|
|
5242
|
+
writeVisionDebugDump({
|
|
5243
|
+
dumpPath: args.debugDump,
|
|
5244
|
+
now: /* @__PURE__ */ new Date(),
|
|
5245
|
+
runtime: { visionModel, baseUrl },
|
|
5246
|
+
inputs: {
|
|
5247
|
+
imageBase64: args.imageBase64,
|
|
5248
|
+
manifest: args.manifest,
|
|
5249
|
+
styleguideLocation,
|
|
5250
|
+
styleGuide
|
|
5251
|
+
},
|
|
5252
|
+
includeSensitive: Boolean(args.debugDumpIncludeSensitive),
|
|
5253
|
+
metadata: args.debugDumpMetadata
|
|
5254
|
+
});
|
|
5255
|
+
}
|
|
5256
|
+
const analyzer = args.analyzer ?? new VisionAnalyzer({
|
|
5257
|
+
baseUrl: args.baseUrl,
|
|
5258
|
+
visionModel
|
|
5259
|
+
});
|
|
5260
|
+
args.onPhase?.(`Analyzing ${args.manifest.length} elements...`);
|
|
5261
|
+
const result = await analyzer.analyzeScreenshot(
|
|
5262
|
+
args.imageBase64,
|
|
5263
|
+
args.manifest,
|
|
5264
|
+
{
|
|
5265
|
+
styleGuide,
|
|
5266
|
+
onProgress: args.onProgress
|
|
5267
|
+
}
|
|
5268
|
+
);
|
|
5269
|
+
args.onPhase?.(
|
|
5270
|
+
`Done (${result.issues.length} issues, ${result.analysisTime}ms)`
|
|
5271
|
+
);
|
|
5272
|
+
return {
|
|
5273
|
+
issues: result.issues,
|
|
5274
|
+
analysisTime: result.analysisTime,
|
|
5275
|
+
// Prompt is available in newer uilint-core versions; keep this resilient across versions.
|
|
5276
|
+
prompt: result.prompt,
|
|
5277
|
+
rawResponse: result.rawResponse,
|
|
5278
|
+
styleguideLocation,
|
|
5279
|
+
visionModel,
|
|
5280
|
+
baseUrl
|
|
5281
|
+
};
|
|
5282
|
+
}
|
|
5283
|
+
function writeVisionMarkdownReport(args) {
|
|
5284
|
+
const p2 = parse(args.imagePath);
|
|
5285
|
+
const outPath = args.outPath ?? join16(p2.dir, `${p2.name || p2.base}.vision.md`);
|
|
5286
|
+
const lines = [];
|
|
5287
|
+
lines.push(`# UILint Vision Report`);
|
|
5288
|
+
lines.push(``);
|
|
5289
|
+
lines.push(`- Image: \`${p2.base}\``);
|
|
5290
|
+
if (args.route) lines.push(`- Route: \`${args.route}\``);
|
|
5291
|
+
if (typeof args.timestamp === "number") {
|
|
5292
|
+
lines.push(`- Timestamp: \`${new Date(args.timestamp).toISOString()}\``);
|
|
5293
|
+
}
|
|
5294
|
+
if (args.visionModel) lines.push(`- Model: \`${args.visionModel}\``);
|
|
5295
|
+
if (args.baseUrl) lines.push(`- Ollama baseUrl: \`${args.baseUrl}\``);
|
|
5296
|
+
if (typeof args.analysisTimeMs === "number")
|
|
5297
|
+
lines.push(`- Analysis time: \`${args.analysisTimeMs}ms\``);
|
|
5298
|
+
lines.push(`- Generated: \`${(/* @__PURE__ */ new Date()).toISOString()}\``);
|
|
5299
|
+
lines.push(``);
|
|
5300
|
+
if (args.metadata && Object.keys(args.metadata).length > 0) {
|
|
5301
|
+
lines.push(`## Metadata`);
|
|
5302
|
+
lines.push(``);
|
|
5303
|
+
lines.push("```json");
|
|
5304
|
+
lines.push(JSON.stringify(args.metadata, null, 2));
|
|
5305
|
+
lines.push("```");
|
|
5306
|
+
lines.push(``);
|
|
5307
|
+
}
|
|
5308
|
+
lines.push(`## Prompt`);
|
|
5309
|
+
lines.push(``);
|
|
5310
|
+
lines.push("```text");
|
|
5311
|
+
lines.push((args.prompt ?? "").trim());
|
|
5312
|
+
lines.push("```");
|
|
5313
|
+
lines.push(``);
|
|
5314
|
+
lines.push(`## Raw Response`);
|
|
5315
|
+
lines.push(``);
|
|
5316
|
+
lines.push("```text");
|
|
5317
|
+
lines.push((args.rawResponse ?? "").trim());
|
|
5318
|
+
lines.push("```");
|
|
5319
|
+
lines.push(``);
|
|
5320
|
+
const content = lines.join("\n");
|
|
5321
|
+
mkdirSync4(dirname8(outPath), { recursive: true });
|
|
5322
|
+
writeFileSync8(outPath, content, "utf-8");
|
|
5323
|
+
return { outPath, content };
|
|
5324
|
+
}
|
|
5325
|
+
|
|
5326
|
+
// src/commands/serve.ts
|
|
4117
5327
|
function pickAppRoot(params) {
|
|
4118
5328
|
const { cwd, workspaceRoot } = params;
|
|
4119
5329
|
if (detectNextAppRouter(cwd)) return cwd;
|
|
@@ -4128,6 +5338,18 @@ function pickAppRoot(params) {
|
|
|
4128
5338
|
}
|
|
4129
5339
|
var cache = /* @__PURE__ */ new Map();
|
|
4130
5340
|
var eslintInstances = /* @__PURE__ */ new Map();
|
|
5341
|
+
var visionAnalyzer = null;
|
|
5342
|
+
function getVisionAnalyzerInstance() {
|
|
5343
|
+
if (!visionAnalyzer) {
|
|
5344
|
+
visionAnalyzer = getCoreVisionAnalyzer();
|
|
5345
|
+
}
|
|
5346
|
+
return visionAnalyzer;
|
|
5347
|
+
}
|
|
5348
|
+
var serverAppRootForVision = process.cwd();
|
|
5349
|
+
function isValidScreenshotFilename(filename) {
|
|
5350
|
+
const validPattern = /^[a-zA-Z0-9_-]+\.(png|jpeg|jpg)$/;
|
|
5351
|
+
return validPattern.test(filename) && !filename.includes("..");
|
|
5352
|
+
}
|
|
4131
5353
|
var resolvedPathCache = /* @__PURE__ */ new Map();
|
|
4132
5354
|
var subscriptions = /* @__PURE__ */ new Map();
|
|
4133
5355
|
var fileWatcher = null;
|
|
@@ -4146,8 +5368,8 @@ function offsetFromLineCol(lineStarts, line1, col0, codeLength) {
|
|
|
4146
5368
|
return Math.max(0, Math.min(codeLength, base + Math.max(0, col0)));
|
|
4147
5369
|
}
|
|
4148
5370
|
function buildJsxElementSpans(code, dataLocFile) {
|
|
4149
|
-
const { parse } = localRequire("@typescript-eslint/typescript-estree");
|
|
4150
|
-
const ast =
|
|
5371
|
+
const { parse: parse3 } = localRequire("@typescript-eslint/typescript-estree");
|
|
5372
|
+
const ast = parse3(code, {
|
|
4151
5373
|
loc: true,
|
|
4152
5374
|
range: true,
|
|
4153
5375
|
jsx: true,
|
|
@@ -4210,10 +5432,10 @@ function findESLintCwd(startDir) {
|
|
|
4210
5432
|
let dir = startDir;
|
|
4211
5433
|
for (let i = 0; i < 30; i++) {
|
|
4212
5434
|
for (const cfg of ESLINT_CONFIG_FILES2) {
|
|
4213
|
-
if (
|
|
5435
|
+
if (existsSync17(join17(dir, cfg))) return dir;
|
|
4214
5436
|
}
|
|
4215
|
-
if (
|
|
4216
|
-
const parent =
|
|
5437
|
+
if (existsSync17(join17(dir, "package.json"))) return dir;
|
|
5438
|
+
const parent = dirname9(dir);
|
|
4217
5439
|
if (parent === dir) break;
|
|
4218
5440
|
dir = parent;
|
|
4219
5441
|
}
|
|
@@ -4226,7 +5448,7 @@ function normalizeDataLocFilePath(absoluteFilePath, projectCwd) {
|
|
|
4226
5448
|
const abs = normalizePathSlashes(resolve5(absoluteFilePath));
|
|
4227
5449
|
const cwd = normalizePathSlashes(resolve5(projectCwd));
|
|
4228
5450
|
if (abs === cwd || abs.startsWith(cwd + "/")) {
|
|
4229
|
-
return normalizePathSlashes(
|
|
5451
|
+
return normalizePathSlashes(relative3(cwd, abs));
|
|
4230
5452
|
}
|
|
4231
5453
|
return abs;
|
|
4232
5454
|
}
|
|
@@ -4238,25 +5460,25 @@ function resolveRequestedFilePath(filePath) {
|
|
|
4238
5460
|
if (cached) return cached;
|
|
4239
5461
|
const cwd = process.cwd();
|
|
4240
5462
|
const fromCwd = resolve5(cwd, filePath);
|
|
4241
|
-
if (
|
|
5463
|
+
if (existsSync17(fromCwd)) {
|
|
4242
5464
|
resolvedPathCache.set(filePath, fromCwd);
|
|
4243
5465
|
return fromCwd;
|
|
4244
5466
|
}
|
|
4245
5467
|
const wsRoot = findWorkspaceRoot5(cwd);
|
|
4246
5468
|
const fromWs = resolve5(wsRoot, filePath);
|
|
4247
|
-
if (
|
|
5469
|
+
if (existsSync17(fromWs)) {
|
|
4248
5470
|
resolvedPathCache.set(filePath, fromWs);
|
|
4249
5471
|
return fromWs;
|
|
4250
5472
|
}
|
|
4251
5473
|
for (const top of ["apps", "packages"]) {
|
|
4252
|
-
const base =
|
|
4253
|
-
if (!
|
|
5474
|
+
const base = join17(wsRoot, top);
|
|
5475
|
+
if (!existsSync17(base)) continue;
|
|
4254
5476
|
try {
|
|
4255
|
-
const entries =
|
|
5477
|
+
const entries = readdirSync5(base, { withFileTypes: true });
|
|
4256
5478
|
for (const ent of entries) {
|
|
4257
5479
|
if (!ent.isDirectory()) continue;
|
|
4258
5480
|
const p2 = resolve5(base, ent.name, filePath);
|
|
4259
|
-
if (
|
|
5481
|
+
if (existsSync17(p2)) {
|
|
4260
5482
|
resolvedPathCache.set(filePath, p2);
|
|
4261
5483
|
return p2;
|
|
4262
5484
|
}
|
|
@@ -4271,7 +5493,7 @@ async function getESLintForProject(projectCwd) {
|
|
|
4271
5493
|
const cached = eslintInstances.get(projectCwd);
|
|
4272
5494
|
if (cached) return cached;
|
|
4273
5495
|
try {
|
|
4274
|
-
const req = createRequire2(
|
|
5496
|
+
const req = createRequire2(join17(projectCwd, "package.json"));
|
|
4275
5497
|
const mod = req("eslint");
|
|
4276
5498
|
const ESLintCtor = mod?.ESLint ?? mod?.default?.ESLint ?? mod?.default ?? mod;
|
|
4277
5499
|
if (!ESLintCtor) return null;
|
|
@@ -4284,13 +5506,13 @@ async function getESLintForProject(projectCwd) {
|
|
|
4284
5506
|
}
|
|
4285
5507
|
async function lintFile(filePath, onProgress) {
|
|
4286
5508
|
const absolutePath = resolveRequestedFilePath(filePath);
|
|
4287
|
-
if (!
|
|
5509
|
+
if (!existsSync17(absolutePath)) {
|
|
4288
5510
|
onProgress(`File not found: ${pc.dim(absolutePath)}`);
|
|
4289
5511
|
return [];
|
|
4290
5512
|
}
|
|
4291
5513
|
const mtimeMs = (() => {
|
|
4292
5514
|
try {
|
|
4293
|
-
return
|
|
5515
|
+
return statSync4(absolutePath).mtimeMs;
|
|
4294
5516
|
} catch {
|
|
4295
5517
|
return 0;
|
|
4296
5518
|
}
|
|
@@ -4300,7 +5522,7 @@ async function lintFile(filePath, onProgress) {
|
|
|
4300
5522
|
onProgress("Cache hit (unchanged)");
|
|
4301
5523
|
return cached.issues;
|
|
4302
5524
|
}
|
|
4303
|
-
const fileDir =
|
|
5525
|
+
const fileDir = dirname9(absolutePath);
|
|
4304
5526
|
const projectCwd = findESLintCwd(fileDir);
|
|
4305
5527
|
onProgress(`Resolving ESLint project... ${pc.dim(projectCwd)}`);
|
|
4306
5528
|
const eslint = await getESLintForProject(projectCwd);
|
|
@@ -4323,7 +5545,7 @@ async function lintFile(filePath, onProgress) {
|
|
|
4323
5545
|
let codeLength = 0;
|
|
4324
5546
|
try {
|
|
4325
5547
|
onProgress("Building JSX map...");
|
|
4326
|
-
const code =
|
|
5548
|
+
const code = readFileSync11(absolutePath, "utf-8");
|
|
4327
5549
|
codeLength = code.length;
|
|
4328
5550
|
lineStarts = buildLineStarts(code);
|
|
4329
5551
|
spans = buildJsxElementSpans(code, dataLocFile);
|
|
@@ -4392,6 +5614,7 @@ async function handleMessage(ws, data) {
|
|
|
4392
5614
|
message.filePath ?? "(all)"
|
|
4393
5615
|
)}`
|
|
4394
5616
|
);
|
|
5617
|
+
} else if (message.type === "vision:analyze") {
|
|
4395
5618
|
}
|
|
4396
5619
|
switch (message.type) {
|
|
4397
5620
|
case "lint:file": {
|
|
@@ -4404,7 +5627,7 @@ async function handleMessage(ws, data) {
|
|
|
4404
5627
|
});
|
|
4405
5628
|
const startedAt = Date.now();
|
|
4406
5629
|
const resolved = resolveRequestedFilePath(filePath);
|
|
4407
|
-
if (!
|
|
5630
|
+
if (!existsSync17(resolved)) {
|
|
4408
5631
|
const cwd = process.cwd();
|
|
4409
5632
|
const wsRoot = findWorkspaceRoot5(cwd);
|
|
4410
5633
|
logWarning(
|
|
@@ -4492,6 +5715,167 @@ async function handleMessage(ws, data) {
|
|
|
4492
5715
|
}
|
|
4493
5716
|
break;
|
|
4494
5717
|
}
|
|
5718
|
+
case "vision:analyze": {
|
|
5719
|
+
const {
|
|
5720
|
+
route,
|
|
5721
|
+
timestamp,
|
|
5722
|
+
screenshot,
|
|
5723
|
+
screenshotFile,
|
|
5724
|
+
manifest,
|
|
5725
|
+
requestId
|
|
5726
|
+
} = message;
|
|
5727
|
+
logInfo(
|
|
5728
|
+
`${pc.dim("[ws]")} ${pc.bold("vision:analyze")} ${pc.dim(route)}${requestId ? ` ${pc.dim(`(req ${requestId})`)}` : ""}`
|
|
5729
|
+
);
|
|
5730
|
+
sendMessage(ws, {
|
|
5731
|
+
type: "vision:progress",
|
|
5732
|
+
route,
|
|
5733
|
+
requestId,
|
|
5734
|
+
phase: "Starting vision analysis..."
|
|
5735
|
+
});
|
|
5736
|
+
const startedAt = Date.now();
|
|
5737
|
+
const analyzer = getVisionAnalyzerInstance();
|
|
5738
|
+
try {
|
|
5739
|
+
const screenshotBytes = typeof screenshot === "string" ? Buffer.byteLength(screenshot) : 0;
|
|
5740
|
+
const analyzerModel = typeof analyzer.getModel === "function" ? analyzer.getModel() : void 0;
|
|
5741
|
+
const analyzerBaseUrl = typeof analyzer.getBaseUrl === "function" ? analyzer.getBaseUrl() : void 0;
|
|
5742
|
+
logInfo(
|
|
5743
|
+
[
|
|
5744
|
+
`${pc.dim("[ws]")} ${pc.dim("vision")} details`,
|
|
5745
|
+
` route: ${pc.dim(route)}`,
|
|
5746
|
+
` requestId: ${pc.dim(requestId ?? "(none)")}`,
|
|
5747
|
+
` manifest: ${pc.dim(String(manifest.length))} element(s)`,
|
|
5748
|
+
` screenshot: ${pc.dim(
|
|
5749
|
+
screenshot ? `${Math.round(screenshotBytes / 1024)}kb` : "none"
|
|
5750
|
+
)}`,
|
|
5751
|
+
` screenshotFile: ${pc.dim(screenshotFile ?? "(none)")}`,
|
|
5752
|
+
` ollamaUrl: ${pc.dim(analyzerBaseUrl ?? "(default)")}`,
|
|
5753
|
+
` visionModel: ${pc.dim(analyzerModel ?? "(default)")}`
|
|
5754
|
+
].join("\n")
|
|
5755
|
+
);
|
|
5756
|
+
if (!screenshot) {
|
|
5757
|
+
sendMessage(ws, {
|
|
5758
|
+
type: "vision:result",
|
|
5759
|
+
route,
|
|
5760
|
+
issues: [],
|
|
5761
|
+
analysisTime: Date.now() - startedAt,
|
|
5762
|
+
error: "No screenshot provided for vision analysis",
|
|
5763
|
+
requestId
|
|
5764
|
+
});
|
|
5765
|
+
break;
|
|
5766
|
+
}
|
|
5767
|
+
const result = await runVisionAnalysis({
|
|
5768
|
+
imageBase64: screenshot,
|
|
5769
|
+
manifest,
|
|
5770
|
+
projectPath: serverAppRootForVision,
|
|
5771
|
+
// In the overlay/server context, default to upward search from app root.
|
|
5772
|
+
baseUrl: analyzerBaseUrl,
|
|
5773
|
+
model: analyzerModel,
|
|
5774
|
+
analyzer,
|
|
5775
|
+
onPhase: (phase) => {
|
|
5776
|
+
sendMessage(ws, {
|
|
5777
|
+
type: "vision:progress",
|
|
5778
|
+
route,
|
|
5779
|
+
requestId,
|
|
5780
|
+
phase
|
|
5781
|
+
});
|
|
5782
|
+
}
|
|
5783
|
+
});
|
|
5784
|
+
if (typeof screenshotFile === "string" && screenshotFile.length > 0) {
|
|
5785
|
+
if (!isValidScreenshotFilename(screenshotFile)) {
|
|
5786
|
+
logWarning(
|
|
5787
|
+
`Skipping vision report write: invalid screenshotFile ${pc.dim(
|
|
5788
|
+
screenshotFile
|
|
5789
|
+
)}`
|
|
5790
|
+
);
|
|
5791
|
+
} else {
|
|
5792
|
+
const screenshotsDir = join17(
|
|
5793
|
+
serverAppRootForVision,
|
|
5794
|
+
".uilint",
|
|
5795
|
+
"screenshots"
|
|
5796
|
+
);
|
|
5797
|
+
const imagePath = join17(screenshotsDir, screenshotFile);
|
|
5798
|
+
try {
|
|
5799
|
+
if (!existsSync17(imagePath)) {
|
|
5800
|
+
logWarning(
|
|
5801
|
+
`Skipping vision report write: screenshot file not found ${pc.dim(
|
|
5802
|
+
imagePath
|
|
5803
|
+
)}`
|
|
5804
|
+
);
|
|
5805
|
+
} else {
|
|
5806
|
+
const report = writeVisionMarkdownReport({
|
|
5807
|
+
imagePath,
|
|
5808
|
+
route,
|
|
5809
|
+
timestamp,
|
|
5810
|
+
visionModel: result.visionModel,
|
|
5811
|
+
baseUrl: result.baseUrl,
|
|
5812
|
+
analysisTimeMs: result.analysisTime,
|
|
5813
|
+
prompt: result.prompt ?? null,
|
|
5814
|
+
rawResponse: result.rawResponse ?? null,
|
|
5815
|
+
metadata: {
|
|
5816
|
+
screenshotFile: parse2(imagePath).base,
|
|
5817
|
+
appRoot: serverAppRootForVision,
|
|
5818
|
+
manifestElements: manifest.length,
|
|
5819
|
+
requestId: requestId ?? null
|
|
5820
|
+
}
|
|
5821
|
+
});
|
|
5822
|
+
logInfo(
|
|
5823
|
+
`${pc.dim("[ws]")} wrote vision report ${pc.dim(
|
|
5824
|
+
report.outPath
|
|
5825
|
+
)}`
|
|
5826
|
+
);
|
|
5827
|
+
}
|
|
5828
|
+
} catch (e) {
|
|
5829
|
+
logWarning(
|
|
5830
|
+
`Failed to write vision report for ${pc.dim(screenshotFile)}: ${e instanceof Error ? e.message : String(e)}`
|
|
5831
|
+
);
|
|
5832
|
+
}
|
|
5833
|
+
}
|
|
5834
|
+
}
|
|
5835
|
+
const elapsed = Date.now() - startedAt;
|
|
5836
|
+
logInfo(
|
|
5837
|
+
`${pc.dim("[ws]")} vision:analyze done ${pc.dim(route)} \u2192 ${pc.bold(
|
|
5838
|
+
`${result.issues.length}`
|
|
5839
|
+
)} issue(s) ${pc.dim(`(${elapsed}ms)`)}`
|
|
5840
|
+
);
|
|
5841
|
+
if (result.rawResponse) {
|
|
5842
|
+
logInfo(
|
|
5843
|
+
`${pc.dim("[ws]")} vision rawResponse ${pc.dim(
|
|
5844
|
+
`${result.rawResponse.length} chars`
|
|
5845
|
+
)}`
|
|
5846
|
+
);
|
|
5847
|
+
}
|
|
5848
|
+
sendMessage(ws, {
|
|
5849
|
+
type: "vision:result",
|
|
5850
|
+
route,
|
|
5851
|
+
issues: result.issues,
|
|
5852
|
+
analysisTime: result.analysisTime,
|
|
5853
|
+
requestId
|
|
5854
|
+
});
|
|
5855
|
+
} catch (error) {
|
|
5856
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
5857
|
+
const stack = error instanceof Error ? error.stack : void 0;
|
|
5858
|
+
logError(
|
|
5859
|
+
[
|
|
5860
|
+
`Vision analysis failed`,
|
|
5861
|
+
` route: ${route}`,
|
|
5862
|
+
` requestId: ${requestId ?? "(none)"}`,
|
|
5863
|
+
` error: ${errorMessage}`,
|
|
5864
|
+
stack ? ` stack:
|
|
5865
|
+
${stack}` : ""
|
|
5866
|
+
].filter(Boolean).join("\n")
|
|
5867
|
+
);
|
|
5868
|
+
sendMessage(ws, {
|
|
5869
|
+
type: "vision:result",
|
|
5870
|
+
route,
|
|
5871
|
+
issues: [],
|
|
5872
|
+
analysisTime: Date.now() - startedAt,
|
|
5873
|
+
error: errorMessage,
|
|
5874
|
+
requestId
|
|
5875
|
+
});
|
|
5876
|
+
}
|
|
5877
|
+
break;
|
|
5878
|
+
}
|
|
4495
5879
|
}
|
|
4496
5880
|
}
|
|
4497
5881
|
function handleDisconnect(ws) {
|
|
@@ -4522,6 +5906,7 @@ async function serve(options) {
|
|
|
4522
5906
|
const cwd = process.cwd();
|
|
4523
5907
|
const wsRoot = findWorkspaceRoot5(cwd);
|
|
4524
5908
|
const appRoot = pickAppRoot({ cwd, workspaceRoot: wsRoot });
|
|
5909
|
+
serverAppRootForVision = appRoot;
|
|
4525
5910
|
logInfo(`Workspace root: ${pc.dim(wsRoot)}`);
|
|
4526
5911
|
logInfo(`App root: ${pc.dim(appRoot)}`);
|
|
4527
5912
|
logInfo(`Server cwd: ${pc.dim(cwd)}`);
|
|
@@ -4561,22 +5946,505 @@ async function serve(options) {
|
|
|
4561
5946
|
`UILint WebSocket server running on ${pc.cyan(`ws://localhost:${port}`)}`
|
|
4562
5947
|
);
|
|
4563
5948
|
logInfo("Press Ctrl+C to stop");
|
|
4564
|
-
await new Promise((
|
|
5949
|
+
await new Promise((resolve8) => {
|
|
4565
5950
|
process.on("SIGINT", () => {
|
|
4566
5951
|
logInfo("Shutting down...");
|
|
4567
5952
|
wss.close();
|
|
4568
5953
|
fileWatcher?.close();
|
|
4569
|
-
|
|
5954
|
+
resolve8();
|
|
4570
5955
|
});
|
|
4571
5956
|
});
|
|
4572
5957
|
}
|
|
4573
5958
|
|
|
5959
|
+
// src/commands/vision.ts
|
|
5960
|
+
import { dirname as dirname10, resolve as resolve6, join as join18 } from "path";
|
|
5961
|
+
import {
|
|
5962
|
+
existsSync as existsSync18,
|
|
5963
|
+
readFileSync as readFileSync12,
|
|
5964
|
+
readdirSync as readdirSync6
|
|
5965
|
+
} from "fs";
|
|
5966
|
+
import {
|
|
5967
|
+
ensureOllamaReady as ensureOllamaReady6,
|
|
5968
|
+
STYLEGUIDE_PATHS as STYLEGUIDE_PATHS2,
|
|
5969
|
+
UILINT_DEFAULT_VISION_MODEL as UILINT_DEFAULT_VISION_MODEL2
|
|
5970
|
+
} from "uilint-core/node";
|
|
5971
|
+
function envTruthy3(name) {
|
|
5972
|
+
const v = process.env[name];
|
|
5973
|
+
if (!v) return false;
|
|
5974
|
+
return v === "1" || v.toLowerCase() === "true" || v.toLowerCase() === "yes";
|
|
5975
|
+
}
|
|
5976
|
+
function preview3(text3, maxLen) {
|
|
5977
|
+
if (text3.length <= maxLen) return text3;
|
|
5978
|
+
return text3.slice(0, maxLen) + "\n\u2026<truncated>\u2026\n" + text3.slice(-maxLen);
|
|
5979
|
+
}
|
|
5980
|
+
function debugEnabled3(options) {
|
|
5981
|
+
return Boolean(options.debug) || envTruthy3("UILINT_DEBUG");
|
|
5982
|
+
}
|
|
5983
|
+
function debugFullEnabled3(options) {
|
|
5984
|
+
return Boolean(options.debugFull) || envTruthy3("UILINT_DEBUG_FULL");
|
|
5985
|
+
}
|
|
5986
|
+
function debugDumpPath3(options) {
|
|
5987
|
+
const v = options.debugDump ?? process.env.UILINT_DEBUG_DUMP;
|
|
5988
|
+
if (!v) return null;
|
|
5989
|
+
if (v === "1" || v.toLowerCase() === "true" || v.toLowerCase() === "yes") {
|
|
5990
|
+
return resolve6(process.cwd(), ".uilint");
|
|
5991
|
+
}
|
|
5992
|
+
return v;
|
|
5993
|
+
}
|
|
5994
|
+
function debugLog3(enabled, message, obj) {
|
|
5995
|
+
if (!enabled) return;
|
|
5996
|
+
if (obj === void 0) {
|
|
5997
|
+
console.error(pc.dim("[uilint:debug]"), message);
|
|
5998
|
+
} else {
|
|
5999
|
+
try {
|
|
6000
|
+
console.error(pc.dim("[uilint:debug]"), message, obj);
|
|
6001
|
+
} catch {
|
|
6002
|
+
console.error(pc.dim("[uilint:debug]"), message);
|
|
6003
|
+
}
|
|
6004
|
+
}
|
|
6005
|
+
}
|
|
6006
|
+
function findScreenshotsDirUpwards(startDir) {
|
|
6007
|
+
let dir = startDir;
|
|
6008
|
+
for (let i = 0; i < 20; i++) {
|
|
6009
|
+
const candidate = join18(dir, ".uilint", "screenshots");
|
|
6010
|
+
if (existsSync18(candidate)) return candidate;
|
|
6011
|
+
const parent = dirname10(dir);
|
|
6012
|
+
if (parent === dir) break;
|
|
6013
|
+
dir = parent;
|
|
6014
|
+
}
|
|
6015
|
+
return null;
|
|
6016
|
+
}
|
|
6017
|
+
function listScreenshotSidecars(dirPath) {
|
|
6018
|
+
if (!existsSync18(dirPath)) return [];
|
|
6019
|
+
const entries = readdirSync6(dirPath).filter((f) => f.endsWith(".json")).map((f) => join18(dirPath, f));
|
|
6020
|
+
const out = [];
|
|
6021
|
+
for (const p2 of entries) {
|
|
6022
|
+
try {
|
|
6023
|
+
const json = loadJsonFile(p2);
|
|
6024
|
+
const issues = Array.isArray(json?.issues) ? json.issues : json?.analysisResult?.issues;
|
|
6025
|
+
out.push({
|
|
6026
|
+
path: p2,
|
|
6027
|
+
filename: json?.filename || json?.screenshotFile || p2.split("/").pop() || p2,
|
|
6028
|
+
timestamp: typeof json?.timestamp === "number" ? json.timestamp : void 0,
|
|
6029
|
+
route: typeof json?.route === "string" ? json.route : void 0,
|
|
6030
|
+
issueCount: Array.isArray(issues) ? issues.length : void 0
|
|
6031
|
+
});
|
|
6032
|
+
} catch {
|
|
6033
|
+
out.push({
|
|
6034
|
+
path: p2,
|
|
6035
|
+
filename: p2.split("/").pop() || p2
|
|
6036
|
+
});
|
|
6037
|
+
}
|
|
6038
|
+
}
|
|
6039
|
+
out.sort((a, b) => {
|
|
6040
|
+
const at = a.timestamp ?? 0;
|
|
6041
|
+
const bt = b.timestamp ?? 0;
|
|
6042
|
+
if (at !== bt) return bt - at;
|
|
6043
|
+
return b.path.localeCompare(a.path);
|
|
6044
|
+
});
|
|
6045
|
+
return out;
|
|
6046
|
+
}
|
|
6047
|
+
function readImageAsBase64(imagePath) {
|
|
6048
|
+
const bytes = readFileSync12(imagePath);
|
|
6049
|
+
return { base64: bytes.toString("base64"), sizeBytes: bytes.byteLength };
|
|
6050
|
+
}
|
|
6051
|
+
function loadJsonFile(filePath) {
|
|
6052
|
+
const raw = readFileSync12(filePath, "utf-8");
|
|
6053
|
+
return JSON.parse(raw);
|
|
6054
|
+
}
|
|
6055
|
+
function formatIssuesText(issues) {
|
|
6056
|
+
if (issues.length === 0) return "No vision issues found.\n";
|
|
6057
|
+
return issues.map((i) => {
|
|
6058
|
+
const sev = i.severity || "info";
|
|
6059
|
+
const cat = i.category || "other";
|
|
6060
|
+
const where = i.dataLoc ? ` (${i.dataLoc})` : "";
|
|
6061
|
+
return `- [${sev}/${cat}] ${i.message}${where}`;
|
|
6062
|
+
}).join("\n") + "\n";
|
|
6063
|
+
}
|
|
6064
|
+
async function vision(options) {
|
|
6065
|
+
const isJsonOutput = options.output === "json";
|
|
6066
|
+
const dbg = debugEnabled3(options);
|
|
6067
|
+
const dbgFull = debugFullEnabled3(options);
|
|
6068
|
+
const dbgDump = debugDumpPath3(options);
|
|
6069
|
+
if (!isJsonOutput) intro2("Vision (Screenshot) Analysis");
|
|
6070
|
+
try {
|
|
6071
|
+
const projectPath = process.cwd();
|
|
6072
|
+
if (options.list) {
|
|
6073
|
+
const base = (options.screenshotsDir ? resolvePathSpecifier(options.screenshotsDir, projectPath) : null) || findScreenshotsDirUpwards(projectPath);
|
|
6074
|
+
if (!base) {
|
|
6075
|
+
if (isJsonOutput) {
|
|
6076
|
+
printJSON({ screenshotsDir: null, sidecars: [] });
|
|
6077
|
+
} else {
|
|
6078
|
+
logWarning(
|
|
6079
|
+
"No `.uilint/screenshots` directory found (walked up from cwd)."
|
|
6080
|
+
);
|
|
6081
|
+
}
|
|
6082
|
+
await flushLangfuse();
|
|
6083
|
+
return;
|
|
6084
|
+
}
|
|
6085
|
+
const sidecars = listScreenshotSidecars(base);
|
|
6086
|
+
if (isJsonOutput) {
|
|
6087
|
+
printJSON({ screenshotsDir: base, sidecars });
|
|
6088
|
+
} else {
|
|
6089
|
+
logInfo(`Screenshots dir: ${pc.dim(base)}`);
|
|
6090
|
+
if (sidecars.length === 0) {
|
|
6091
|
+
process.stdout.write("No sidecars found.\n");
|
|
6092
|
+
} else {
|
|
6093
|
+
process.stdout.write(
|
|
6094
|
+
sidecars.map((s, idx) => {
|
|
6095
|
+
const stamp = s.timestamp ? new Date(s.timestamp).toLocaleString() : "(no timestamp)";
|
|
6096
|
+
const route = s.route ? ` ${pc.dim(s.route)}` : "";
|
|
6097
|
+
const count = typeof s.issueCount === "number" ? ` ${pc.dim(`(${s.issueCount} issues)`)}` : "";
|
|
6098
|
+
return `${idx === 0 ? "*" : "-"} ${s.path}${pc.dim(
|
|
6099
|
+
` \u2014 ${stamp}`
|
|
6100
|
+
)}${route}${count}`;
|
|
6101
|
+
}).join("\n") + "\n"
|
|
6102
|
+
);
|
|
6103
|
+
process.stdout.write(
|
|
6104
|
+
pc.dim(
|
|
6105
|
+
`Tip: run \`uilint vision --sidecar <path>\` (the newest is marked with "*").
|
|
6106
|
+
`
|
|
6107
|
+
)
|
|
6108
|
+
);
|
|
6109
|
+
}
|
|
6110
|
+
}
|
|
6111
|
+
await flushLangfuse();
|
|
6112
|
+
return;
|
|
6113
|
+
}
|
|
6114
|
+
const imagePath = options.image ? resolvePathSpecifier(options.image, projectPath) : void 0;
|
|
6115
|
+
const sidecarPath = options.sidecar ? resolvePathSpecifier(options.sidecar, projectPath) : void 0;
|
|
6116
|
+
const manifestFilePath = options.manifestFile ? resolvePathSpecifier(options.manifestFile, projectPath) : void 0;
|
|
6117
|
+
if (!imagePath && !sidecarPath) {
|
|
6118
|
+
if (isJsonOutput) {
|
|
6119
|
+
printJSON({ error: "No input provided", issues: [] });
|
|
6120
|
+
} else {
|
|
6121
|
+
logError("No input provided. Use --image or --sidecar.");
|
|
6122
|
+
}
|
|
6123
|
+
await flushLangfuse();
|
|
6124
|
+
process.exit(1);
|
|
6125
|
+
}
|
|
6126
|
+
if (imagePath && !existsSync18(imagePath)) {
|
|
6127
|
+
throw new Error(`Image not found: ${imagePath}`);
|
|
6128
|
+
}
|
|
6129
|
+
if (sidecarPath && !existsSync18(sidecarPath)) {
|
|
6130
|
+
throw new Error(`Sidecar not found: ${sidecarPath}`);
|
|
6131
|
+
}
|
|
6132
|
+
if (manifestFilePath && !existsSync18(manifestFilePath)) {
|
|
6133
|
+
throw new Error(`Manifest file not found: ${manifestFilePath}`);
|
|
6134
|
+
}
|
|
6135
|
+
const sidecar = sidecarPath ? loadJsonFile(sidecarPath) : null;
|
|
6136
|
+
const routeLabel = options.route || (typeof sidecar?.route === "string" ? sidecar.route : void 0) || (sidecarPath ? `(from ${sidecarPath})` : "(unknown)");
|
|
6137
|
+
let manifest = null;
|
|
6138
|
+
if (options.manifestJson) {
|
|
6139
|
+
manifest = JSON.parse(options.manifestJson);
|
|
6140
|
+
} else if (manifestFilePath) {
|
|
6141
|
+
manifest = loadJsonFile(manifestFilePath);
|
|
6142
|
+
} else if (sidecar && Array.isArray(sidecar.manifest)) {
|
|
6143
|
+
manifest = sidecar.manifest;
|
|
6144
|
+
}
|
|
6145
|
+
if (!manifest || manifest.length === 0) {
|
|
6146
|
+
throw new Error(
|
|
6147
|
+
"No manifest provided. Supply --manifest-json, --manifest-file, or a sidecar JSON with a `manifest` array."
|
|
6148
|
+
);
|
|
6149
|
+
}
|
|
6150
|
+
let styleGuide = null;
|
|
6151
|
+
let styleguideLocation = null;
|
|
6152
|
+
const startPath = (imagePath ?? sidecarPath ?? manifestFilePath ?? void 0) || void 0;
|
|
6153
|
+
{
|
|
6154
|
+
const resolved = await resolveVisionStyleGuide({
|
|
6155
|
+
projectPath,
|
|
6156
|
+
styleguide: options.styleguide,
|
|
6157
|
+
startDir: startPath ? dirname10(startPath) : projectPath
|
|
6158
|
+
});
|
|
6159
|
+
styleGuide = resolved.styleGuide;
|
|
6160
|
+
styleguideLocation = resolved.styleguideLocation;
|
|
6161
|
+
}
|
|
6162
|
+
if (styleguideLocation && styleGuide) {
|
|
6163
|
+
if (!isJsonOutput)
|
|
6164
|
+
logSuccess(`Using styleguide: ${pc.dim(styleguideLocation)}`);
|
|
6165
|
+
} else if (!styleGuide && !isJsonOutput) {
|
|
6166
|
+
logWarning("No styleguide found");
|
|
6167
|
+
note2(
|
|
6168
|
+
[
|
|
6169
|
+
`Searched in: ${options.styleguide || projectPath}`,
|
|
6170
|
+
"",
|
|
6171
|
+
"Looked for:",
|
|
6172
|
+
...STYLEGUIDE_PATHS2.map((p2) => ` \u2022 ${p2}`),
|
|
6173
|
+
"",
|
|
6174
|
+
`Create ${pc.cyan(
|
|
6175
|
+
".uilint/styleguide.md"
|
|
6176
|
+
)} (recommended: run ${pc.cyan("/genstyleguide")} in Cursor).`
|
|
6177
|
+
].join("\n"),
|
|
6178
|
+
"Missing Styleguide"
|
|
6179
|
+
);
|
|
6180
|
+
}
|
|
6181
|
+
debugLog3(dbg, "Vision input (high-level)", {
|
|
6182
|
+
imagePath: imagePath ?? null,
|
|
6183
|
+
sidecarPath: sidecarPath ?? null,
|
|
6184
|
+
manifestFile: manifestFilePath ?? null,
|
|
6185
|
+
manifestElements: manifest.length,
|
|
6186
|
+
route: routeLabel,
|
|
6187
|
+
styleguideLocation,
|
|
6188
|
+
styleGuideLength: styleGuide ? styleGuide.length : 0
|
|
6189
|
+
});
|
|
6190
|
+
const visionModel = options.model || UILINT_DEFAULT_VISION_MODEL2;
|
|
6191
|
+
const prepStartNs = nsNow();
|
|
6192
|
+
if (!isJsonOutput) {
|
|
6193
|
+
await withSpinner("Preparing Ollama", async () => {
|
|
6194
|
+
await ensureOllamaReady6({
|
|
6195
|
+
model: visionModel,
|
|
6196
|
+
baseUrl: options.baseUrl
|
|
6197
|
+
});
|
|
6198
|
+
});
|
|
6199
|
+
} else {
|
|
6200
|
+
await ensureOllamaReady6({ model: visionModel, baseUrl: options.baseUrl });
|
|
6201
|
+
}
|
|
6202
|
+
const prepEndNs = nsNow();
|
|
6203
|
+
const resolvedImagePath = imagePath || (() => {
|
|
6204
|
+
const screenshotFile = typeof sidecar?.screenshotFile === "string" ? sidecar.screenshotFile : typeof sidecar?.filename === "string" ? sidecar.filename : void 0;
|
|
6205
|
+
if (!screenshotFile) return null;
|
|
6206
|
+
const baseDir = sidecarPath ? dirname10(sidecarPath) : projectPath;
|
|
6207
|
+
const abs = resolve6(baseDir, screenshotFile);
|
|
6208
|
+
return abs;
|
|
6209
|
+
})();
|
|
6210
|
+
if (!resolvedImagePath) {
|
|
6211
|
+
throw new Error(
|
|
6212
|
+
"No image path could be resolved. Provide --image or a sidecar with `screenshotFile`/`filename`."
|
|
6213
|
+
);
|
|
6214
|
+
}
|
|
6215
|
+
if (!existsSync18(resolvedImagePath)) {
|
|
6216
|
+
throw new Error(`Image not found: ${resolvedImagePath}`);
|
|
6217
|
+
}
|
|
6218
|
+
const { base64, sizeBytes } = readImageAsBase64(resolvedImagePath);
|
|
6219
|
+
debugLog3(dbg, "Image loaded", {
|
|
6220
|
+
imagePath: resolvedImagePath,
|
|
6221
|
+
sizeBytes,
|
|
6222
|
+
base64Length: base64.length
|
|
6223
|
+
});
|
|
6224
|
+
if (dbgFull && styleGuide) {
|
|
6225
|
+
debugLog3(dbg, "Styleguide (full)", styleGuide);
|
|
6226
|
+
} else if (dbg && styleGuide) {
|
|
6227
|
+
debugLog3(dbg, "Styleguide (preview)", preview3(styleGuide, 800));
|
|
6228
|
+
}
|
|
6229
|
+
let result = null;
|
|
6230
|
+
const analysisStartNs = nsNow();
|
|
6231
|
+
let firstTokenNs = null;
|
|
6232
|
+
let firstThinkingNs = null;
|
|
6233
|
+
let lastThinkingNs = null;
|
|
6234
|
+
let firstAnswerNs = null;
|
|
6235
|
+
let lastAnswerNs = null;
|
|
6236
|
+
if (isJsonOutput) {
|
|
6237
|
+
result = await runVisionAnalysis({
|
|
6238
|
+
imageBase64: base64,
|
|
6239
|
+
manifest,
|
|
6240
|
+
projectPath,
|
|
6241
|
+
styleGuide,
|
|
6242
|
+
styleguideLocation,
|
|
6243
|
+
baseUrl: options.baseUrl,
|
|
6244
|
+
model: visionModel,
|
|
6245
|
+
skipEnsureOllama: true,
|
|
6246
|
+
debugDump: dbgDump ?? void 0,
|
|
6247
|
+
debugDumpIncludeSensitive: dbgFull,
|
|
6248
|
+
debugDumpMetadata: {
|
|
6249
|
+
route: routeLabel,
|
|
6250
|
+
imagePath: resolvedImagePath,
|
|
6251
|
+
imageSizeBytes: sizeBytes,
|
|
6252
|
+
imageBase64Length: base64.length
|
|
6253
|
+
}
|
|
6254
|
+
});
|
|
6255
|
+
} else {
|
|
6256
|
+
if (options.stream) {
|
|
6257
|
+
let lastStatus = "";
|
|
6258
|
+
let printedAnyText = false;
|
|
6259
|
+
let inThinking = false;
|
|
6260
|
+
result = await runVisionAnalysis({
|
|
6261
|
+
imageBase64: base64,
|
|
6262
|
+
manifest,
|
|
6263
|
+
projectPath,
|
|
6264
|
+
styleGuide,
|
|
6265
|
+
styleguideLocation,
|
|
6266
|
+
baseUrl: options.baseUrl,
|
|
6267
|
+
model: visionModel,
|
|
6268
|
+
skipEnsureOllama: true,
|
|
6269
|
+
debugDump: dbgDump ?? void 0,
|
|
6270
|
+
debugDumpIncludeSensitive: dbgFull,
|
|
6271
|
+
debugDumpMetadata: {
|
|
6272
|
+
route: routeLabel,
|
|
6273
|
+
imagePath: resolvedImagePath,
|
|
6274
|
+
imageSizeBytes: sizeBytes,
|
|
6275
|
+
imageBase64Length: base64.length
|
|
6276
|
+
},
|
|
6277
|
+
onProgress: (latestLine, _fullResponse, delta, thinkingDelta) => {
|
|
6278
|
+
const nowNs = nsNow();
|
|
6279
|
+
if (!firstTokenNs && (thinkingDelta || delta)) firstTokenNs = nowNs;
|
|
6280
|
+
if (thinkingDelta) {
|
|
6281
|
+
if (!firstThinkingNs) firstThinkingNs = nowNs;
|
|
6282
|
+
lastThinkingNs = nowNs;
|
|
6283
|
+
}
|
|
6284
|
+
if (delta) {
|
|
6285
|
+
if (!firstAnswerNs) firstAnswerNs = nowNs;
|
|
6286
|
+
lastAnswerNs = nowNs;
|
|
6287
|
+
}
|
|
6288
|
+
if (thinkingDelta) {
|
|
6289
|
+
if (!printedAnyText) {
|
|
6290
|
+
printedAnyText = true;
|
|
6291
|
+
console.error(pc.dim("[vision] streaming:"));
|
|
6292
|
+
process.stderr.write(pc.dim("Thinking:\n"));
|
|
6293
|
+
inThinking = true;
|
|
6294
|
+
} else if (!inThinking) {
|
|
6295
|
+
process.stderr.write(pc.dim("\n\nThinking:\n"));
|
|
6296
|
+
inThinking = true;
|
|
6297
|
+
}
|
|
6298
|
+
process.stderr.write(thinkingDelta);
|
|
6299
|
+
return;
|
|
6300
|
+
}
|
|
6301
|
+
if (delta) {
|
|
6302
|
+
if (!printedAnyText) {
|
|
6303
|
+
printedAnyText = true;
|
|
6304
|
+
console.error(pc.dim("[vision] streaming:"));
|
|
6305
|
+
}
|
|
6306
|
+
if (inThinking) {
|
|
6307
|
+
process.stderr.write(pc.dim("\n\nAnswer:\n"));
|
|
6308
|
+
inThinking = false;
|
|
6309
|
+
}
|
|
6310
|
+
process.stderr.write(delta);
|
|
6311
|
+
return;
|
|
6312
|
+
}
|
|
6313
|
+
const line = (latestLine || "").trim();
|
|
6314
|
+
if (!line || line === lastStatus) return;
|
|
6315
|
+
lastStatus = line;
|
|
6316
|
+
console.error(pc.dim("[vision]"), line);
|
|
6317
|
+
}
|
|
6318
|
+
});
|
|
6319
|
+
} else {
|
|
6320
|
+
result = await withSpinner(
|
|
6321
|
+
"Analyzing screenshot with vision model",
|
|
6322
|
+
async (s) => {
|
|
6323
|
+
return await runVisionAnalysis({
|
|
6324
|
+
imageBase64: base64,
|
|
6325
|
+
manifest,
|
|
6326
|
+
projectPath,
|
|
6327
|
+
styleGuide,
|
|
6328
|
+
styleguideLocation,
|
|
6329
|
+
baseUrl: options.baseUrl,
|
|
6330
|
+
model: visionModel,
|
|
6331
|
+
skipEnsureOllama: true,
|
|
6332
|
+
debugDump: dbgDump ?? void 0,
|
|
6333
|
+
debugDumpIncludeSensitive: dbgFull,
|
|
6334
|
+
debugDumpMetadata: {
|
|
6335
|
+
route: routeLabel,
|
|
6336
|
+
imagePath: resolvedImagePath,
|
|
6337
|
+
imageSizeBytes: sizeBytes,
|
|
6338
|
+
imageBase64Length: base64.length
|
|
6339
|
+
},
|
|
6340
|
+
onProgress: (latestLine, _fullResponse, delta, thinkingDelta) => {
|
|
6341
|
+
const nowNs = nsNow();
|
|
6342
|
+
if (!firstTokenNs && (thinkingDelta || delta))
|
|
6343
|
+
firstTokenNs = nowNs;
|
|
6344
|
+
if (thinkingDelta) {
|
|
6345
|
+
if (!firstThinkingNs) firstThinkingNs = nowNs;
|
|
6346
|
+
lastThinkingNs = nowNs;
|
|
6347
|
+
}
|
|
6348
|
+
if (delta) {
|
|
6349
|
+
if (!firstAnswerNs) firstAnswerNs = nowNs;
|
|
6350
|
+
lastAnswerNs = nowNs;
|
|
6351
|
+
}
|
|
6352
|
+
const maxLen = 60;
|
|
6353
|
+
const displayLine = latestLine.length > maxLen ? latestLine.slice(0, maxLen) + "\u2026" : latestLine;
|
|
6354
|
+
s.message(`Analyzing: ${pc.dim(displayLine || "...")}`);
|
|
6355
|
+
}
|
|
6356
|
+
});
|
|
6357
|
+
}
|
|
6358
|
+
);
|
|
6359
|
+
}
|
|
6360
|
+
}
|
|
6361
|
+
const analysisEndNs = nsNow();
|
|
6362
|
+
const issues = result?.issues ?? [];
|
|
6363
|
+
if (isJsonOutput) {
|
|
6364
|
+
printJSON({
|
|
6365
|
+
route: routeLabel,
|
|
6366
|
+
model: visionModel,
|
|
6367
|
+
issues,
|
|
6368
|
+
analysisTime: result?.analysisTime ?? 0,
|
|
6369
|
+
imagePath: resolvedImagePath,
|
|
6370
|
+
imageSizeBytes: sizeBytes
|
|
6371
|
+
});
|
|
6372
|
+
} else {
|
|
6373
|
+
logInfo(`Route: ${pc.dim(routeLabel)}`);
|
|
6374
|
+
logInfo(`Model: ${pc.dim(visionModel)}`);
|
|
6375
|
+
process.stdout.write(formatIssuesText(issues));
|
|
6376
|
+
if (process.stdout.isTTY) {
|
|
6377
|
+
const prepMs = nsToMs(prepEndNs - prepStartNs);
|
|
6378
|
+
const totalMs = nsToMs(analysisEndNs - analysisStartNs);
|
|
6379
|
+
const endToEndMs = nsToMs(analysisEndNs - prepStartNs);
|
|
6380
|
+
const ttftMs = firstTokenNs ? nsToMs(firstTokenNs - analysisStartNs) : null;
|
|
6381
|
+
const thinkingMs = firstThinkingNs && (firstAnswerNs || lastThinkingNs) ? nsToMs(
|
|
6382
|
+
(firstAnswerNs ?? lastThinkingNs ?? analysisEndNs) - firstThinkingNs
|
|
6383
|
+
) : null;
|
|
6384
|
+
const outputMs = firstAnswerNs && (lastAnswerNs || analysisEndNs) ? nsToMs((lastAnswerNs ?? analysisEndNs) - firstAnswerNs) : null;
|
|
6385
|
+
note2(
|
|
6386
|
+
[
|
|
6387
|
+
`Prepare Ollama: ${formatMs(prepMs)}`,
|
|
6388
|
+
`Time to first token: ${maybeMs(ttftMs)}`,
|
|
6389
|
+
`Thinking: ${maybeMs(thinkingMs)}`,
|
|
6390
|
+
`Outputting: ${maybeMs(outputMs)}`,
|
|
6391
|
+
`LLM total: ${formatMs(totalMs)}`,
|
|
6392
|
+
`End-to-end: ${formatMs(endToEndMs)}`,
|
|
6393
|
+
result?.analysisTime ? pc.dim(`(core analysisTime: ${formatMs(result.analysisTime)})`) : pc.dim("(core analysisTime: n/a)")
|
|
6394
|
+
].join("\n"),
|
|
6395
|
+
"Timings"
|
|
6396
|
+
);
|
|
6397
|
+
}
|
|
6398
|
+
}
|
|
6399
|
+
try {
|
|
6400
|
+
writeVisionMarkdownReport({
|
|
6401
|
+
imagePath: resolvedImagePath,
|
|
6402
|
+
route: routeLabel,
|
|
6403
|
+
visionModel,
|
|
6404
|
+
baseUrl: options.baseUrl ?? "http://localhost:11434",
|
|
6405
|
+
analysisTimeMs: result?.analysisTime ?? 0,
|
|
6406
|
+
prompt: result?.prompt ?? null,
|
|
6407
|
+
rawResponse: result?.rawResponse ?? null,
|
|
6408
|
+
metadata: {
|
|
6409
|
+
imageSizeBytes: sizeBytes,
|
|
6410
|
+
styleguideLocation
|
|
6411
|
+
}
|
|
6412
|
+
});
|
|
6413
|
+
debugLog3(dbg, "Wrote .vision.md report alongside image");
|
|
6414
|
+
} catch (e) {
|
|
6415
|
+
debugLog3(
|
|
6416
|
+
dbg,
|
|
6417
|
+
"Failed to write .vision.md report",
|
|
6418
|
+
e instanceof Error ? e.message : e
|
|
6419
|
+
);
|
|
6420
|
+
}
|
|
6421
|
+
if (issues.length > 0) {
|
|
6422
|
+
await flushLangfuse();
|
|
6423
|
+
process.exit(1);
|
|
6424
|
+
}
|
|
6425
|
+
} catch (error) {
|
|
6426
|
+
if (options.output === "json") {
|
|
6427
|
+
printJSON({
|
|
6428
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
6429
|
+
issues: []
|
|
6430
|
+
});
|
|
6431
|
+
} else {
|
|
6432
|
+
logError(
|
|
6433
|
+
error instanceof Error ? error.message : "Vision analysis failed"
|
|
6434
|
+
);
|
|
6435
|
+
}
|
|
6436
|
+
await flushLangfuse();
|
|
6437
|
+
process.exit(1);
|
|
6438
|
+
}
|
|
6439
|
+
await flushLangfuse();
|
|
6440
|
+
}
|
|
6441
|
+
|
|
4574
6442
|
// src/commands/session.ts
|
|
4575
|
-
import { existsSync as
|
|
4576
|
-
import { basename, dirname as
|
|
6443
|
+
import { existsSync as existsSync19, readFileSync as readFileSync13, writeFileSync as writeFileSync10, unlinkSync as unlinkSync2 } from "fs";
|
|
6444
|
+
import { basename, dirname as dirname11, resolve as resolve7 } from "path";
|
|
4577
6445
|
import { createStyleSummary as createStyleSummary3 } from "uilint-core";
|
|
4578
6446
|
import {
|
|
4579
|
-
ensureOllamaReady as
|
|
6447
|
+
ensureOllamaReady as ensureOllamaReady7,
|
|
4580
6448
|
parseCLIInput as parseCLIInput2,
|
|
4581
6449
|
readStyleGuideFromProject as readStyleGuideFromProject2,
|
|
4582
6450
|
readTailwindThemeTokens as readTailwindThemeTokens3
|
|
@@ -4584,18 +6452,18 @@ import {
|
|
|
4584
6452
|
var SESSION_FILE = "/tmp/uilint-session.json";
|
|
4585
6453
|
var UI_FILE_EXTENSIONS = [".tsx", ".jsx", ".css", ".scss", ".module.css"];
|
|
4586
6454
|
function readSession() {
|
|
4587
|
-
if (!
|
|
6455
|
+
if (!existsSync19(SESSION_FILE)) {
|
|
4588
6456
|
return { files: [], startedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4589
6457
|
}
|
|
4590
6458
|
try {
|
|
4591
|
-
const content =
|
|
6459
|
+
const content = readFileSync13(SESSION_FILE, "utf-8");
|
|
4592
6460
|
return JSON.parse(content);
|
|
4593
6461
|
} catch {
|
|
4594
6462
|
return { files: [], startedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4595
6463
|
}
|
|
4596
6464
|
}
|
|
4597
6465
|
function writeSession(state) {
|
|
4598
|
-
|
|
6466
|
+
writeFileSync10(SESSION_FILE, JSON.stringify(state, null, 2), "utf-8");
|
|
4599
6467
|
}
|
|
4600
6468
|
function isUIFile(filePath) {
|
|
4601
6469
|
return UI_FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
|
|
@@ -4606,7 +6474,7 @@ function isScannableMarkupFile(filePath) {
|
|
|
4606
6474
|
);
|
|
4607
6475
|
}
|
|
4608
6476
|
async function sessionClear() {
|
|
4609
|
-
if (
|
|
6477
|
+
if (existsSync19(SESSION_FILE)) {
|
|
4610
6478
|
unlinkSync2(SESSION_FILE);
|
|
4611
6479
|
}
|
|
4612
6480
|
console.log(JSON.stringify({ cleared: true }));
|
|
@@ -4673,17 +6541,17 @@ async function sessionScan(options = {}) {
|
|
|
4673
6541
|
}
|
|
4674
6542
|
return;
|
|
4675
6543
|
}
|
|
4676
|
-
await
|
|
6544
|
+
await ensureOllamaReady7();
|
|
4677
6545
|
const client = await createLLMClient({});
|
|
4678
6546
|
const results = [];
|
|
4679
6547
|
for (const filePath of session.files) {
|
|
4680
|
-
if (!
|
|
6548
|
+
if (!existsSync19(filePath)) continue;
|
|
4681
6549
|
if (!isScannableMarkupFile(filePath)) continue;
|
|
4682
6550
|
try {
|
|
4683
|
-
const absolutePath =
|
|
4684
|
-
const htmlLike =
|
|
6551
|
+
const absolutePath = resolve7(process.cwd(), filePath);
|
|
6552
|
+
const htmlLike = readFileSync13(filePath, "utf-8");
|
|
4685
6553
|
const snapshot = parseCLIInput2(htmlLike);
|
|
4686
|
-
const tailwindSearchDir =
|
|
6554
|
+
const tailwindSearchDir = dirname11(absolutePath);
|
|
4687
6555
|
const tailwindTheme = readTailwindThemeTokens3(tailwindSearchDir);
|
|
4688
6556
|
const styleSummary = createStyleSummary3(snapshot.styles, {
|
|
4689
6557
|
html: snapshot.html,
|
|
@@ -4736,7 +6604,7 @@ async function sessionScan(options = {}) {
|
|
|
4736
6604
|
};
|
|
4737
6605
|
console.log(JSON.stringify(result));
|
|
4738
6606
|
}
|
|
4739
|
-
if (
|
|
6607
|
+
if (existsSync19(SESSION_FILE)) {
|
|
4740
6608
|
unlinkSync2(SESSION_FILE);
|
|
4741
6609
|
}
|
|
4742
6610
|
await flushLangfuse();
|
|
@@ -4747,9 +6615,9 @@ async function sessionList() {
|
|
|
4747
6615
|
}
|
|
4748
6616
|
|
|
4749
6617
|
// src/index.ts
|
|
4750
|
-
import { readFileSync as
|
|
4751
|
-
import { dirname as
|
|
4752
|
-
import { fileURLToPath as
|
|
6618
|
+
import { readFileSync as readFileSync14 } from "fs";
|
|
6619
|
+
import { dirname as dirname12, join as join19 } from "path";
|
|
6620
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4753
6621
|
function assertNodeVersion(minMajor) {
|
|
4754
6622
|
const ver = process.versions.node || "";
|
|
4755
6623
|
const majorStr = ver.split(".")[0] || "";
|
|
@@ -4765,9 +6633,9 @@ assertNodeVersion(20);
|
|
|
4765
6633
|
var program = new Command();
|
|
4766
6634
|
function getCLIVersion2() {
|
|
4767
6635
|
try {
|
|
4768
|
-
const
|
|
4769
|
-
const pkgPath =
|
|
4770
|
-
const pkg = JSON.parse(
|
|
6636
|
+
const __dirname2 = dirname12(fileURLToPath3(import.meta.url));
|
|
6637
|
+
const pkgPath = join19(__dirname2, "..", "package.json");
|
|
6638
|
+
const pkg = JSON.parse(readFileSync14(pkgPath, "utf-8"));
|
|
4771
6639
|
return pkg.version || "0.0.0";
|
|
4772
6640
|
} catch {
|
|
4773
6641
|
return "0.0.0";
|
|
@@ -4870,6 +6738,40 @@ program.command("serve").description("Start WebSocket server for real-time UI li
|
|
|
4870
6738
|
port: parseInt(options.port, 10)
|
|
4871
6739
|
});
|
|
4872
6740
|
});
|
|
6741
|
+
program.command("vision").description("Analyze a screenshot with Ollama vision models (requires a manifest)").option("--list", "List available .uilint/screenshots sidecars and exit").option(
|
|
6742
|
+
"--screenshots-dir <path>",
|
|
6743
|
+
"Screenshots directory for --list (default: nearest .uilint/screenshots)"
|
|
6744
|
+
).option("--image <path>", "Path to a screenshot image (png/jpg)").option(
|
|
6745
|
+
"--sidecar <path>",
|
|
6746
|
+
"Path to a .uilint/screenshots/*.json sidecar (contains manifest + metadata)"
|
|
6747
|
+
).option("--manifest-file <path>", "Path to a manifest JSON file (array)").option("--manifest-json <json>", "Inline manifest JSON (array)").option("--route <route>", "Optional route label (e.g., /todos)").option(
|
|
6748
|
+
"-s, --styleguide <path>",
|
|
6749
|
+
"Path to style guide file OR project directory (falls back to upward search)"
|
|
6750
|
+
).option("-o, --output <format>", "Output format: text or json", "text").option("--model <name>", "Ollama vision model override", void 0).option("--base-url <url>", "Ollama base URL (default: http://localhost:11434)").option("--stream", "Stream model output/progress to stderr (text mode only)").option("--debug", "Enable debug logging (stderr)").option(
|
|
6751
|
+
"--debug-full",
|
|
6752
|
+
"Print full prompt/styleguide and include base64 in dumps (can be very large)"
|
|
6753
|
+
).option(
|
|
6754
|
+
"--debug-dump <path>",
|
|
6755
|
+
"Write full analysis payload dump to JSON file (or directory to auto-name)"
|
|
6756
|
+
).action(async (options) => {
|
|
6757
|
+
await vision({
|
|
6758
|
+
list: options.list,
|
|
6759
|
+
screenshotsDir: options.screenshotsDir,
|
|
6760
|
+
image: options.image,
|
|
6761
|
+
sidecar: options.sidecar,
|
|
6762
|
+
manifestFile: options.manifestFile,
|
|
6763
|
+
manifestJson: options.manifestJson,
|
|
6764
|
+
route: options.route,
|
|
6765
|
+
styleguide: options.styleguide,
|
|
6766
|
+
output: options.output,
|
|
6767
|
+
model: options.model,
|
|
6768
|
+
baseUrl: options.baseUrl,
|
|
6769
|
+
stream: options.stream,
|
|
6770
|
+
debug: options.debug,
|
|
6771
|
+
debugFull: options.debugFull,
|
|
6772
|
+
debugDump: options.debugDump
|
|
6773
|
+
});
|
|
6774
|
+
});
|
|
4873
6775
|
var sessionCmd = program.command("session").description(
|
|
4874
6776
|
"Manage file tracking for agentic sessions (used by Cursor hooks)"
|
|
4875
6777
|
);
|