uilint 0.2.0 → 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 +2200 -236
- 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,14 +1957,69 @@ function collectUilintRuleIdsFromRulesObject(rulesObj) {
|
|
|
1732
1957
|
return ids;
|
|
1733
1958
|
}
|
|
1734
1959
|
function findExportedConfigArrayExpression(mod) {
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1960
|
+
function unwrapExpression2(expr) {
|
|
1961
|
+
let e = expr;
|
|
1962
|
+
while (e) {
|
|
1963
|
+
if (e.type === "TSAsExpression" || e.type === "TSNonNullExpression") {
|
|
1964
|
+
e = e.expression;
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
if (e.type === "TSSatisfiesExpression") {
|
|
1968
|
+
e = e.expression;
|
|
1969
|
+
continue;
|
|
1970
|
+
}
|
|
1971
|
+
if (e.type === "ParenthesizedExpression") {
|
|
1972
|
+
e = e.expression;
|
|
1973
|
+
continue;
|
|
1974
|
+
}
|
|
1975
|
+
break;
|
|
1976
|
+
}
|
|
1977
|
+
return e;
|
|
1978
|
+
}
|
|
1979
|
+
function resolveTopLevelIdentifierToArrayExpr(program3, name) {
|
|
1980
|
+
if (!program3 || program3.type !== "Program") return null;
|
|
1981
|
+
for (const stmt of program3.body ?? []) {
|
|
1982
|
+
if (stmt?.type !== "VariableDeclaration") continue;
|
|
1983
|
+
for (const decl of stmt.declarations ?? []) {
|
|
1984
|
+
const id = decl?.id;
|
|
1985
|
+
if (!isIdentifier(id, name)) continue;
|
|
1986
|
+
const init = unwrapExpression2(decl?.init);
|
|
1987
|
+
if (!init) return null;
|
|
1988
|
+
if (init.type === "ArrayExpression") return init;
|
|
1989
|
+
if (init.type === "CallExpression" && isIdentifier(init.callee, "defineConfig") && unwrapExpression2(init.arguments?.[0])?.type === "ArrayExpression") {
|
|
1990
|
+
return unwrapExpression2(init.arguments?.[0]);
|
|
1991
|
+
}
|
|
1992
|
+
return null;
|
|
1993
|
+
}
|
|
1740
1994
|
}
|
|
1995
|
+
return null;
|
|
1741
1996
|
}
|
|
1742
1997
|
const program2 = mod?.$ast;
|
|
1998
|
+
if (program2 && program2.type === "Program") {
|
|
1999
|
+
for (const stmt of program2.body ?? []) {
|
|
2000
|
+
if (!stmt || stmt.type !== "ExportDefaultDeclaration") continue;
|
|
2001
|
+
const decl = unwrapExpression2(stmt.declaration);
|
|
2002
|
+
if (!decl) break;
|
|
2003
|
+
if (decl.type === "ArrayExpression") {
|
|
2004
|
+
return { kind: "esm", arrayExpr: decl, program: program2 };
|
|
2005
|
+
}
|
|
2006
|
+
if (decl.type === "CallExpression" && isIdentifier(decl.callee, "defineConfig") && unwrapExpression2(decl.arguments?.[0])?.type === "ArrayExpression") {
|
|
2007
|
+
return {
|
|
2008
|
+
kind: "esm",
|
|
2009
|
+
arrayExpr: unwrapExpression2(decl.arguments?.[0]),
|
|
2010
|
+
program: program2
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
if (decl.type === "Identifier" && typeof decl.name === "string") {
|
|
2014
|
+
const resolved = resolveTopLevelIdentifierToArrayExpr(
|
|
2015
|
+
program2,
|
|
2016
|
+
decl.name
|
|
2017
|
+
);
|
|
2018
|
+
if (resolved) return { kind: "esm", arrayExpr: resolved, program: program2 };
|
|
2019
|
+
}
|
|
2020
|
+
break;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
1743
2023
|
if (!program2 || program2.type !== "Program") return null;
|
|
1744
2024
|
for (const stmt of program2.body ?? []) {
|
|
1745
2025
|
if (!stmt || stmt.type !== "ExpressionStatement") continue;
|
|
@@ -1755,6 +2035,13 @@ function findExportedConfigArrayExpression(mod) {
|
|
|
1755
2035
|
if (right?.type === "CallExpression" && isIdentifier(right.callee, "defineConfig") && right.arguments?.[0]?.type === "ArrayExpression") {
|
|
1756
2036
|
return { kind: "cjs", arrayExpr: right.arguments[0], program: program2 };
|
|
1757
2037
|
}
|
|
2038
|
+
if (right?.type === "Identifier" && typeof right.name === "string") {
|
|
2039
|
+
const resolved = resolveTopLevelIdentifierToArrayExpr(
|
|
2040
|
+
program2,
|
|
2041
|
+
right.name
|
|
2042
|
+
);
|
|
2043
|
+
if (resolved) return { kind: "cjs", arrayExpr: resolved, program: program2 };
|
|
2044
|
+
}
|
|
1758
2045
|
}
|
|
1759
2046
|
return null;
|
|
1760
2047
|
}
|
|
@@ -1979,7 +2266,7 @@ async function installEslintPlugin(opts) {
|
|
|
1979
2266
|
};
|
|
1980
2267
|
}
|
|
1981
2268
|
const configFilename = getEslintConfigFilename(configPath);
|
|
1982
|
-
const original =
|
|
2269
|
+
const original = readFileSync4(configPath, "utf-8");
|
|
1983
2270
|
const isCommonJS = configPath.endsWith(".cjs");
|
|
1984
2271
|
const ast = getUilintEslintConfigInfoFromSourceAst(original);
|
|
1985
2272
|
if ("error" in ast) {
|
|
@@ -2081,7 +2368,7 @@ async function installEslintPlugin(opts) {
|
|
|
2081
2368
|
var LEGACY_HOOK_FILES = ["uilint-validate.sh", "uilint-validate.js"];
|
|
2082
2369
|
function safeParseJson(filePath) {
|
|
2083
2370
|
try {
|
|
2084
|
-
const content =
|
|
2371
|
+
const content = readFileSync5(filePath, "utf-8");
|
|
2085
2372
|
return JSON.parse(content);
|
|
2086
2373
|
} catch {
|
|
2087
2374
|
return void 0;
|
|
@@ -2090,27 +2377,27 @@ function safeParseJson(filePath) {
|
|
|
2090
2377
|
async function analyze2(projectPath = process.cwd()) {
|
|
2091
2378
|
const workspaceRoot = findWorkspaceRoot4(projectPath);
|
|
2092
2379
|
const packageManager = detectPackageManager(projectPath);
|
|
2093
|
-
const cursorDir =
|
|
2094
|
-
const cursorDirExists =
|
|
2095
|
-
const mcpPath =
|
|
2096
|
-
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);
|
|
2097
2384
|
const mcpConfig = mcpExists ? safeParseJson(mcpPath) : void 0;
|
|
2098
|
-
const hooksPath =
|
|
2099
|
-
const hooksExists =
|
|
2385
|
+
const hooksPath = join8(cursorDir, "hooks.json");
|
|
2386
|
+
const hooksExists = existsSync9(hooksPath);
|
|
2100
2387
|
const hooksConfig = hooksExists ? safeParseJson(hooksPath) : void 0;
|
|
2101
|
-
const hooksDir =
|
|
2388
|
+
const hooksDir = join8(cursorDir, "hooks");
|
|
2102
2389
|
const legacyPaths = [];
|
|
2103
2390
|
for (const legacyFile of LEGACY_HOOK_FILES) {
|
|
2104
|
-
const legacyPath =
|
|
2105
|
-
if (
|
|
2391
|
+
const legacyPath = join8(hooksDir, legacyFile);
|
|
2392
|
+
if (existsSync9(legacyPath)) {
|
|
2106
2393
|
legacyPaths.push(legacyPath);
|
|
2107
2394
|
}
|
|
2108
2395
|
}
|
|
2109
|
-
const styleguidePath =
|
|
2110
|
-
const styleguideExists =
|
|
2111
|
-
const commandsDir =
|
|
2112
|
-
const genstyleguideExists =
|
|
2113
|
-
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"));
|
|
2114
2401
|
const nextApps = [];
|
|
2115
2402
|
const directDetection = detectNextAppRouter(projectPath);
|
|
2116
2403
|
if (directDetection) {
|
|
@@ -2124,6 +2411,19 @@ async function analyze2(projectPath = process.cwd()) {
|
|
|
2124
2411
|
});
|
|
2125
2412
|
}
|
|
2126
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
|
+
}
|
|
2127
2427
|
const rawPackages = findPackages(workspaceRoot);
|
|
2128
2428
|
const packages = rawPackages.map((pkg) => {
|
|
2129
2429
|
const eslintConfigPath = findEslintConfigFile(pkg.path);
|
|
@@ -2133,7 +2433,7 @@ async function analyze2(projectPath = process.cwd()) {
|
|
|
2133
2433
|
if (eslintConfigPath) {
|
|
2134
2434
|
eslintConfigFilename = getEslintConfigFilename(eslintConfigPath);
|
|
2135
2435
|
try {
|
|
2136
|
-
const source =
|
|
2436
|
+
const source = readFileSync5(eslintConfigPath, "utf-8");
|
|
2137
2437
|
const info = getUilintEslintConfigInfoFromSource(source);
|
|
2138
2438
|
hasRules = info.configuredRuleIds.size > 0 || info.usesUilintConfigs;
|
|
2139
2439
|
configuredRuleIds = Array.from(info.configuredRuleIds);
|
|
@@ -2177,12 +2477,13 @@ async function analyze2(projectPath = process.cwd()) {
|
|
|
2177
2477
|
genrules: genrulesExists
|
|
2178
2478
|
},
|
|
2179
2479
|
nextApps,
|
|
2480
|
+
viteApps,
|
|
2180
2481
|
packages
|
|
2181
2482
|
};
|
|
2182
2483
|
}
|
|
2183
2484
|
|
|
2184
2485
|
// src/commands/install/plan.ts
|
|
2185
|
-
import { join as
|
|
2486
|
+
import { join as join10 } from "path";
|
|
2186
2487
|
import { createRequire } from "module";
|
|
2187
2488
|
|
|
2188
2489
|
// src/commands/install/constants.ts
|
|
@@ -2571,6 +2872,55 @@ Generate in \`.uilint/rules/\`:
|
|
|
2571
2872
|
- **Minimal rules** - generate 3-5 high-impact rules, not dozens
|
|
2572
2873
|
`;
|
|
2573
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
|
+
|
|
2574
2924
|
// src/commands/install/plan.ts
|
|
2575
2925
|
var require2 = createRequire(import.meta.url);
|
|
2576
2926
|
function getSelfDependencyVersionRange(pkgName) {
|
|
@@ -2621,7 +2971,7 @@ function createPlan(state, choices, options = {}) {
|
|
|
2621
2971
|
const dependencies = [];
|
|
2622
2972
|
const { force = false } = options;
|
|
2623
2973
|
const { items } = choices;
|
|
2624
|
-
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");
|
|
2625
2975
|
if (needsCursorDir && !state.cursorDir.exists) {
|
|
2626
2976
|
actions.push({
|
|
2627
2977
|
type: "create_directory",
|
|
@@ -2650,7 +3000,7 @@ function createPlan(state, choices, options = {}) {
|
|
|
2650
3000
|
}
|
|
2651
3001
|
}
|
|
2652
3002
|
if (items.includes("hooks")) {
|
|
2653
|
-
const hooksDir =
|
|
3003
|
+
const hooksDir = join10(state.cursorDir.path, "hooks");
|
|
2654
3004
|
actions.push({
|
|
2655
3005
|
type: "create_directory",
|
|
2656
3006
|
path: hooksDir
|
|
@@ -2676,47 +3026,78 @@ function createPlan(state, choices, options = {}) {
|
|
|
2676
3026
|
});
|
|
2677
3027
|
actions.push({
|
|
2678
3028
|
type: "create_file",
|
|
2679
|
-
path:
|
|
3029
|
+
path: join10(hooksDir, "uilint-session-start.sh"),
|
|
2680
3030
|
content: SESSION_START_SCRIPT,
|
|
2681
3031
|
permissions: 493
|
|
2682
3032
|
});
|
|
2683
3033
|
actions.push({
|
|
2684
3034
|
type: "create_file",
|
|
2685
|
-
path:
|
|
3035
|
+
path: join10(hooksDir, "uilint-track.sh"),
|
|
2686
3036
|
content: TRACK_SCRIPT,
|
|
2687
3037
|
permissions: 493
|
|
2688
3038
|
});
|
|
2689
3039
|
actions.push({
|
|
2690
3040
|
type: "create_file",
|
|
2691
|
-
path:
|
|
3041
|
+
path: join10(hooksDir, "uilint-session-end.sh"),
|
|
2692
3042
|
content: SESSION_END_SCRIPT,
|
|
2693
3043
|
permissions: 493
|
|
2694
3044
|
});
|
|
2695
3045
|
}
|
|
2696
3046
|
if (items.includes("genstyleguide")) {
|
|
2697
|
-
const commandsDir =
|
|
3047
|
+
const commandsDir = join10(state.cursorDir.path, "commands");
|
|
2698
3048
|
actions.push({
|
|
2699
3049
|
type: "create_directory",
|
|
2700
3050
|
path: commandsDir
|
|
2701
3051
|
});
|
|
2702
3052
|
actions.push({
|
|
2703
3053
|
type: "create_file",
|
|
2704
|
-
path:
|
|
3054
|
+
path: join10(commandsDir, "genstyleguide.md"),
|
|
2705
3055
|
content: GENSTYLEGUIDE_COMMAND_MD
|
|
2706
3056
|
});
|
|
2707
3057
|
}
|
|
2708
3058
|
if (items.includes("genrules")) {
|
|
2709
|
-
const commandsDir =
|
|
3059
|
+
const commandsDir = join10(state.cursorDir.path, "commands");
|
|
2710
3060
|
actions.push({
|
|
2711
3061
|
type: "create_directory",
|
|
2712
3062
|
path: commandsDir
|
|
2713
3063
|
});
|
|
2714
3064
|
actions.push({
|
|
2715
3065
|
type: "create_file",
|
|
2716
|
-
path:
|
|
3066
|
+
path: join10(commandsDir, "genrules.md"),
|
|
2717
3067
|
content: GENRULES_COMMAND_MD
|
|
2718
3068
|
});
|
|
2719
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
|
+
}
|
|
2720
3101
|
if (items.includes("next") && choices.next) {
|
|
2721
3102
|
const { projectPath, detection } = choices.next;
|
|
2722
3103
|
actions.push({
|
|
@@ -2739,6 +3120,24 @@ function createPlan(state, choices, options = {}) {
|
|
|
2739
3120
|
projectPath
|
|
2740
3121
|
});
|
|
2741
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
|
+
}
|
|
2742
3141
|
if (items.includes("eslint") && choices.eslint) {
|
|
2743
3142
|
const { packagePaths, selectedRules } = choices.eslint;
|
|
2744
3143
|
for (const pkgPath of packagePaths) {
|
|
@@ -2758,7 +3157,7 @@ function createPlan(state, choices, options = {}) {
|
|
|
2758
3157
|
});
|
|
2759
3158
|
}
|
|
2760
3159
|
}
|
|
2761
|
-
const gitignorePath =
|
|
3160
|
+
const gitignorePath = join10(state.workspaceRoot, ".gitignore");
|
|
2762
3161
|
actions.push({
|
|
2763
3162
|
type: "append_to_file",
|
|
2764
3163
|
path: gitignorePath,
|
|
@@ -2771,34 +3170,49 @@ function createPlan(state, choices, options = {}) {
|
|
|
2771
3170
|
|
|
2772
3171
|
// src/commands/install/execute.ts
|
|
2773
3172
|
import {
|
|
2774
|
-
existsSync as
|
|
3173
|
+
existsSync as existsSync15,
|
|
2775
3174
|
mkdirSync as mkdirSync3,
|
|
2776
|
-
writeFileSync as
|
|
2777
|
-
readFileSync as
|
|
3175
|
+
writeFileSync as writeFileSync7,
|
|
3176
|
+
readFileSync as readFileSync10,
|
|
2778
3177
|
unlinkSync,
|
|
2779
3178
|
chmodSync
|
|
2780
3179
|
} from "fs";
|
|
2781
|
-
import { dirname as
|
|
3180
|
+
import { dirname as dirname7 } from "path";
|
|
2782
3181
|
|
|
2783
3182
|
// src/utils/react-inject.ts
|
|
2784
|
-
import { existsSync as
|
|
2785
|
-
import { join as
|
|
3183
|
+
import { existsSync as existsSync11, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
|
|
3184
|
+
import { join as join11 } from "path";
|
|
2786
3185
|
import { parseModule as parseModule2, generateCode as generateCode2 } from "magicast";
|
|
2787
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;
|
|
2788
3202
|
const layoutCandidates = [
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
3203
|
+
join11(appRoot, "layout.tsx"),
|
|
3204
|
+
join11(appRoot, "layout.jsx"),
|
|
3205
|
+
join11(appRoot, "layout.ts"),
|
|
3206
|
+
join11(appRoot, "layout.js")
|
|
2793
3207
|
];
|
|
2794
3208
|
const existingLayouts = layoutCandidates.filter(
|
|
2795
|
-
(rel) =>
|
|
3209
|
+
(rel) => existsSync11(join11(projectPath, rel))
|
|
2796
3210
|
);
|
|
2797
3211
|
if (existingLayouts.length > 0) {
|
|
2798
3212
|
return existingLayouts;
|
|
2799
3213
|
}
|
|
2800
|
-
const pageCandidates = [
|
|
2801
|
-
return pageCandidates.filter((rel) =>
|
|
3214
|
+
const pageCandidates = [join11(appRoot, "page.tsx"), join11(appRoot, "page.jsx")];
|
|
3215
|
+
return pageCandidates.filter((rel) => existsSync11(join11(projectPath, rel)));
|
|
2802
3216
|
}
|
|
2803
3217
|
function isUseClientDirective(stmt) {
|
|
2804
3218
|
return stmt?.type === "ExpressionStatement" && stmt.expression?.type === "StringLiteral" && stmt.expression.value === "use client";
|
|
@@ -2885,11 +3299,44 @@ function wrapFirstChildrenExpressionWithProvider(program2) {
|
|
|
2885
3299
|
}
|
|
2886
3300
|
return { changed: true };
|
|
2887
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
|
+
}
|
|
2888
3335
|
async function installReactUILintOverlay(opts) {
|
|
2889
3336
|
const candidates = getDefaultCandidates(opts.projectPath, opts.appRoot);
|
|
2890
3337
|
if (!candidates.length) {
|
|
2891
3338
|
throw new Error(
|
|
2892
|
-
`No suitable
|
|
3339
|
+
`No suitable entry files found under ${opts.appRoot} (expected Next.js layout/page or Vite main/App).`
|
|
2893
3340
|
);
|
|
2894
3341
|
}
|
|
2895
3342
|
let chosen;
|
|
@@ -2898,8 +3345,8 @@ async function installReactUILintOverlay(opts) {
|
|
|
2898
3345
|
} else {
|
|
2899
3346
|
chosen = candidates[0];
|
|
2900
3347
|
}
|
|
2901
|
-
const absTarget =
|
|
2902
|
-
const original =
|
|
3348
|
+
const absTarget = join11(opts.projectPath, chosen);
|
|
3349
|
+
const original = readFileSync7(absTarget, "utf-8");
|
|
2903
3350
|
let mod;
|
|
2904
3351
|
try {
|
|
2905
3352
|
mod = parseModule2(original);
|
|
@@ -2917,7 +3364,8 @@ async function installReactUILintOverlay(opts) {
|
|
|
2917
3364
|
"UILintProvider"
|
|
2918
3365
|
);
|
|
2919
3366
|
if (importRes.changed) changed = true;
|
|
2920
|
-
const
|
|
3367
|
+
const mode = opts.mode ?? "next";
|
|
3368
|
+
const wrapRes = mode === "vite" ? wrapFirstRenderCallArgumentWithProvider(program2) : wrapFirstChildrenExpressionWithProvider(program2);
|
|
2921
3369
|
if (wrapRes.changed) changed = true;
|
|
2922
3370
|
const updated = changed ? generateCode2(mod).code : original;
|
|
2923
3371
|
const modified = updated !== original;
|
|
@@ -2932,14 +3380,14 @@ async function installReactUILintOverlay(opts) {
|
|
|
2932
3380
|
}
|
|
2933
3381
|
|
|
2934
3382
|
// src/utils/next-config-inject.ts
|
|
2935
|
-
import { existsSync as
|
|
2936
|
-
import { join as
|
|
3383
|
+
import { existsSync as existsSync12, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
|
|
3384
|
+
import { join as join12 } from "path";
|
|
2937
3385
|
import { parseModule as parseModule3, generateCode as generateCode3 } from "magicast";
|
|
2938
3386
|
var CONFIG_EXTENSIONS2 = [".ts", ".mjs", ".js", ".cjs"];
|
|
2939
3387
|
function findNextConfigFile(projectPath) {
|
|
2940
3388
|
for (const ext of CONFIG_EXTENSIONS2) {
|
|
2941
|
-
const configPath =
|
|
2942
|
-
if (
|
|
3389
|
+
const configPath = join12(projectPath, `next.config${ext}`);
|
|
3390
|
+
if (existsSync12(configPath)) {
|
|
2943
3391
|
return configPath;
|
|
2944
3392
|
}
|
|
2945
3393
|
}
|
|
@@ -3052,7 +3500,7 @@ async function installJsxLocPlugin(opts) {
|
|
|
3052
3500
|
return { configFile: null, modified: false };
|
|
3053
3501
|
}
|
|
3054
3502
|
const configFilename = getNextConfigFilename(configPath);
|
|
3055
|
-
const original =
|
|
3503
|
+
const original = readFileSync8(configPath, "utf-8");
|
|
3056
3504
|
let mod;
|
|
3057
3505
|
try {
|
|
3058
3506
|
mod = parseModule3(original);
|
|
@@ -3081,82 +3529,307 @@ async function installJsxLocPlugin(opts) {
|
|
|
3081
3529
|
return { configFile: configFilename, modified: false };
|
|
3082
3530
|
}
|
|
3083
3531
|
|
|
3084
|
-
// src/utils/
|
|
3085
|
-
import { existsSync as
|
|
3086
|
-
import {
|
|
3087
|
-
import {
|
|
3088
|
-
var
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
*
|
|
3094
|
-
* Security:
|
|
3095
|
-
* - Only available in development mode
|
|
3096
|
-
* - Validates file path is within project root
|
|
3097
|
-
* - Only allows specific file extensions
|
|
3098
|
-
*/
|
|
3099
|
-
|
|
3100
|
-
import { NextRequest, NextResponse } from "next/server";
|
|
3101
|
-
import { readFileSync, existsSync } from "fs";
|
|
3102
|
-
import { resolve, relative, dirname, extname } from "path";
|
|
3103
|
-
|
|
3104
|
-
export const runtime = "nodejs";
|
|
3105
|
-
|
|
3106
|
-
// Allowed file extensions
|
|
3107
|
-
const ALLOWED_EXTENSIONS = new Set([".tsx", ".ts", ".jsx", ".js", ".css"]);
|
|
3108
|
-
|
|
3109
|
-
/**
|
|
3110
|
-
* Find the project root by looking for package.json or next.config
|
|
3111
|
-
*/
|
|
3112
|
-
function findProjectRoot(startDir: string): string {
|
|
3113
|
-
let dir = startDir;
|
|
3114
|
-
for (let i = 0; i < 10; i++) {
|
|
3115
|
-
if (
|
|
3116
|
-
existsSync(resolve(dir, "package.json")) ||
|
|
3117
|
-
existsSync(resolve(dir, "next.config.js")) ||
|
|
3118
|
-
existsSync(resolve(dir, "next.config.ts"))
|
|
3119
|
-
) {
|
|
3120
|
-
return dir;
|
|
3121
|
-
}
|
|
3122
|
-
const parent = dirname(dir);
|
|
3123
|
-
if (parent === dir) break;
|
|
3124
|
-
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;
|
|
3125
3541
|
}
|
|
3126
|
-
return
|
|
3542
|
+
return null;
|
|
3127
3543
|
}
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
*/
|
|
3132
|
-
function isPathWithinRoot(filePath: string, root: string): boolean {
|
|
3133
|
-
const resolved = resolve(filePath);
|
|
3134
|
-
const resolvedRoot = resolve(root);
|
|
3135
|
-
return resolved.startsWith(resolvedRoot + "/") || resolved === resolvedRoot;
|
|
3544
|
+
function getViteConfigFilename(configPath) {
|
|
3545
|
+
const parts = configPath.split("/");
|
|
3546
|
+
return parts[parts.length - 1] || "vite.config.ts";
|
|
3136
3547
|
}
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
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;
|
|
3149
3560
|
}
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
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;
|
|
3153
3570
|
}
|
|
3154
|
-
return
|
|
3571
|
+
return e;
|
|
3155
3572
|
}
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
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;
|
|
3591
|
+
}
|
|
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") {
|
|
3160
3833
|
return NextResponse.json(
|
|
3161
3834
|
{ error: "Not available in production" },
|
|
3162
3835
|
{ status: 404 }
|
|
@@ -3182,8 +3855,8 @@ export async function GET(request: NextRequest) {
|
|
|
3182
3855
|
);
|
|
3183
3856
|
}
|
|
3184
3857
|
|
|
3185
|
-
// Find project root
|
|
3186
|
-
const projectRoot =
|
|
3858
|
+
// Find project root (prefer Next project root over workspace root)
|
|
3859
|
+
const projectRoot = findNextProjectRoot();
|
|
3187
3860
|
|
|
3188
3861
|
// Resolve the file path
|
|
3189
3862
|
const resolvedPath = resolve(filePath);
|
|
@@ -3212,6 +3885,8 @@ export async function GET(request: NextRequest) {
|
|
|
3212
3885
|
return NextResponse.json({
|
|
3213
3886
|
content,
|
|
3214
3887
|
relativePath,
|
|
3888
|
+
projectRoot,
|
|
3889
|
+
workspaceRoot,
|
|
3215
3890
|
});
|
|
3216
3891
|
} catch (error) {
|
|
3217
3892
|
console.error("[Dev Source API] Error reading file:", error);
|
|
@@ -3219,20 +3894,331 @@ export async function GET(request: NextRequest) {
|
|
|
3219
3894
|
}
|
|
3220
3895
|
}
|
|
3221
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
|
+
`;
|
|
3222
4201
|
async function writeRouteFile(absPath, relPath, content, opts) {
|
|
3223
|
-
if (
|
|
4202
|
+
if (existsSync14(absPath) && !opts.force) return;
|
|
3224
4203
|
await writeFile(absPath, content, "utf-8");
|
|
3225
4204
|
}
|
|
3226
4205
|
async function installNextUILintRoutes(opts) {
|
|
3227
|
-
const baseRel =
|
|
3228
|
-
const baseAbs =
|
|
3229
|
-
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 });
|
|
3230
4209
|
await writeRouteFile(
|
|
3231
|
-
|
|
3232
|
-
|
|
4210
|
+
join14(baseAbs, "source", "route.ts"),
|
|
4211
|
+
join14(baseRel, "source", "route.ts"),
|
|
3233
4212
|
DEV_SOURCE_ROUTE_TS,
|
|
3234
4213
|
opts
|
|
3235
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
|
+
);
|
|
3236
4222
|
}
|
|
3237
4223
|
|
|
3238
4224
|
// src/commands/install/execute.ts
|
|
@@ -3248,7 +4234,7 @@ async function executeAction(action, options) {
|
|
|
3248
4234
|
wouldDo: `Create directory: ${action.path}`
|
|
3249
4235
|
};
|
|
3250
4236
|
}
|
|
3251
|
-
if (!
|
|
4237
|
+
if (!existsSync15(action.path)) {
|
|
3252
4238
|
mkdirSync3(action.path, { recursive: true });
|
|
3253
4239
|
}
|
|
3254
4240
|
return { action, success: true };
|
|
@@ -3261,11 +4247,11 @@ async function executeAction(action, options) {
|
|
|
3261
4247
|
wouldDo: `Create file: ${action.path}${action.permissions ? ` (mode: ${action.permissions.toString(8)})` : ""}`
|
|
3262
4248
|
};
|
|
3263
4249
|
}
|
|
3264
|
-
const dir =
|
|
3265
|
-
if (!
|
|
4250
|
+
const dir = dirname7(action.path);
|
|
4251
|
+
if (!existsSync15(dir)) {
|
|
3266
4252
|
mkdirSync3(dir, { recursive: true });
|
|
3267
4253
|
}
|
|
3268
|
-
|
|
4254
|
+
writeFileSync7(action.path, action.content, "utf-8");
|
|
3269
4255
|
if (action.permissions) {
|
|
3270
4256
|
chmodSync(action.path, action.permissions);
|
|
3271
4257
|
}
|
|
@@ -3280,18 +4266,18 @@ async function executeAction(action, options) {
|
|
|
3280
4266
|
};
|
|
3281
4267
|
}
|
|
3282
4268
|
let existing = {};
|
|
3283
|
-
if (
|
|
4269
|
+
if (existsSync15(action.path)) {
|
|
3284
4270
|
try {
|
|
3285
|
-
existing = JSON.parse(
|
|
4271
|
+
existing = JSON.parse(readFileSync10(action.path, "utf-8"));
|
|
3286
4272
|
} catch {
|
|
3287
4273
|
}
|
|
3288
4274
|
}
|
|
3289
4275
|
const merged = deepMerge(existing, action.merge);
|
|
3290
|
-
const dir =
|
|
3291
|
-
if (!
|
|
4276
|
+
const dir = dirname7(action.path);
|
|
4277
|
+
if (!existsSync15(dir)) {
|
|
3292
4278
|
mkdirSync3(dir, { recursive: true });
|
|
3293
4279
|
}
|
|
3294
|
-
|
|
4280
|
+
writeFileSync7(action.path, JSON.stringify(merged, null, 2), "utf-8");
|
|
3295
4281
|
return { action, success: true };
|
|
3296
4282
|
}
|
|
3297
4283
|
case "delete_file": {
|
|
@@ -3302,7 +4288,7 @@ async function executeAction(action, options) {
|
|
|
3302
4288
|
wouldDo: `Delete file: ${action.path}`
|
|
3303
4289
|
};
|
|
3304
4290
|
}
|
|
3305
|
-
if (
|
|
4291
|
+
if (existsSync15(action.path)) {
|
|
3306
4292
|
unlinkSync(action.path);
|
|
3307
4293
|
}
|
|
3308
4294
|
return { action, success: true };
|
|
@@ -3315,12 +4301,12 @@ async function executeAction(action, options) {
|
|
|
3315
4301
|
wouldDo: `Append to file: ${action.path}`
|
|
3316
4302
|
};
|
|
3317
4303
|
}
|
|
3318
|
-
if (
|
|
3319
|
-
const content =
|
|
4304
|
+
if (existsSync15(action.path)) {
|
|
4305
|
+
const content = readFileSync10(action.path, "utf-8");
|
|
3320
4306
|
if (action.ifNotContains && content.includes(action.ifNotContains)) {
|
|
3321
4307
|
return { action, success: true };
|
|
3322
4308
|
}
|
|
3323
|
-
|
|
4309
|
+
writeFileSync7(action.path, content + action.content, "utf-8");
|
|
3324
4310
|
}
|
|
3325
4311
|
return { action, success: true };
|
|
3326
4312
|
}
|
|
@@ -3333,6 +4319,9 @@ async function executeAction(action, options) {
|
|
|
3333
4319
|
case "inject_next_config": {
|
|
3334
4320
|
return await executeInjectNextConfig(action, options);
|
|
3335
4321
|
}
|
|
4322
|
+
case "inject_vite_config": {
|
|
4323
|
+
return await executeInjectViteConfig(action, options);
|
|
4324
|
+
}
|
|
3336
4325
|
case "install_next_routes": {
|
|
3337
4326
|
return await executeInstallNextRoutes(action, options);
|
|
3338
4327
|
}
|
|
@@ -3388,6 +4377,7 @@ async function executeInjectReact(action, options) {
|
|
|
3388
4377
|
const result = await installReactUILintOverlay({
|
|
3389
4378
|
projectPath: action.projectPath,
|
|
3390
4379
|
appRoot: action.appRoot,
|
|
4380
|
+
mode: action.mode,
|
|
3391
4381
|
force: false,
|
|
3392
4382
|
// Auto-select first choice for execute phase
|
|
3393
4383
|
confirmFileChoice: async (choices) => choices[0]
|
|
@@ -3399,6 +4389,25 @@ async function executeInjectReact(action, options) {
|
|
|
3399
4389
|
error: success ? void 0 : "Failed to configure React overlay"
|
|
3400
4390
|
};
|
|
3401
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
|
+
}
|
|
3402
4411
|
async function executeInjectNextConfig(action, options) {
|
|
3403
4412
|
const { dryRun = false } = options;
|
|
3404
4413
|
if (dryRun) {
|
|
@@ -3456,6 +4465,7 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
|
|
|
3456
4465
|
const filesDeleted = [];
|
|
3457
4466
|
const eslintTargets = [];
|
|
3458
4467
|
let nextApp;
|
|
4468
|
+
let viteApp;
|
|
3459
4469
|
for (const result of actionsPerformed) {
|
|
3460
4470
|
if (!result.success) continue;
|
|
3461
4471
|
const { action } = result;
|
|
@@ -3478,6 +4488,12 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
|
|
|
3478
4488
|
});
|
|
3479
4489
|
break;
|
|
3480
4490
|
case "inject_react":
|
|
4491
|
+
if (action.mode === "vite") {
|
|
4492
|
+
viteApp = { entryRoot: action.appRoot };
|
|
4493
|
+
} else {
|
|
4494
|
+
nextApp = { appRoot: action.appRoot };
|
|
4495
|
+
}
|
|
4496
|
+
break;
|
|
3481
4497
|
case "install_next_routes":
|
|
3482
4498
|
nextApp = { appRoot: action.appRoot };
|
|
3483
4499
|
break;
|
|
@@ -3499,7 +4515,8 @@ function buildSummary(actionsPerformed, dependencyResults, items) {
|
|
|
3499
4515
|
filesDeleted,
|
|
3500
4516
|
dependenciesInstalled,
|
|
3501
4517
|
eslintTargets,
|
|
3502
|
-
nextApp
|
|
4518
|
+
nextApp,
|
|
4519
|
+
viteApp
|
|
3503
4520
|
};
|
|
3504
4521
|
}
|
|
3505
4522
|
async function execute(plan, options = {}) {
|
|
@@ -3549,11 +4566,14 @@ async function execute(plan, options = {}) {
|
|
|
3549
4566
|
if (action.path.includes("hooks.json")) items.push("hooks");
|
|
3550
4567
|
if (action.path.includes("genstyleguide.md")) items.push("genstyleguide");
|
|
3551
4568
|
if (action.path.includes("genrules.md")) items.push("genrules");
|
|
4569
|
+
if (action.path.includes("/skills/") && action.path.includes("SKILL.md")) items.push("skill");
|
|
3552
4570
|
}
|
|
3553
4571
|
if (action.type === "inject_eslint") items.push("eslint");
|
|
3554
|
-
if (action.type === "
|
|
3555
|
-
|
|
4572
|
+
if (action.type === "install_next_routes") items.push("next");
|
|
4573
|
+
if (action.type === "inject_react") {
|
|
4574
|
+
items.push(action.mode === "vite" ? "vite" : "next");
|
|
3556
4575
|
}
|
|
4576
|
+
if (action.type === "inject_vite_config") items.push("vite");
|
|
3557
4577
|
}
|
|
3558
4578
|
const uniqueItems = [...new Set(items)];
|
|
3559
4579
|
const summary = buildSummary(
|
|
@@ -3579,13 +4599,18 @@ var cliPrompter = {
|
|
|
3579
4599
|
{
|
|
3580
4600
|
value: "eslint",
|
|
3581
4601
|
label: "ESLint plugin",
|
|
3582
|
-
hint: "Installs uilint-eslint and configures eslint.config
|
|
4602
|
+
hint: "Installs uilint-eslint and configures eslint.config.*"
|
|
3583
4603
|
},
|
|
3584
4604
|
{
|
|
3585
4605
|
value: "next",
|
|
3586
4606
|
label: "UI overlay",
|
|
3587
4607
|
hint: "Installs routes + UILintProvider (Alt+Click to inspect)"
|
|
3588
4608
|
},
|
|
4609
|
+
{
|
|
4610
|
+
value: "vite",
|
|
4611
|
+
label: "UI overlay (Vite)",
|
|
4612
|
+
hint: "Installs jsx-loc-plugin + UILintProvider (Alt+Click to inspect)"
|
|
4613
|
+
},
|
|
3589
4614
|
{
|
|
3590
4615
|
value: "genstyleguide",
|
|
3591
4616
|
label: "/genstyleguide command",
|
|
@@ -3605,10 +4630,15 @@ var cliPrompter = {
|
|
|
3605
4630
|
value: "genrules",
|
|
3606
4631
|
label: "/genrules command",
|
|
3607
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"
|
|
3608
4638
|
}
|
|
3609
4639
|
],
|
|
3610
4640
|
required: true,
|
|
3611
|
-
initialValues: ["eslint", "next", "genstyleguide"]
|
|
4641
|
+
initialValues: ["eslint", "next", "genstyleguide", "skill"]
|
|
3612
4642
|
});
|
|
3613
4643
|
},
|
|
3614
4644
|
async confirmMcpMerge() {
|
|
@@ -3638,6 +4668,17 @@ var cliPrompter = {
|
|
|
3638
4668
|
});
|
|
3639
4669
|
return apps.find((a) => a.projectPath === chosen) || apps[0];
|
|
3640
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
|
+
},
|
|
3641
4682
|
async selectEslintPackages(packages) {
|
|
3642
4683
|
if (packages.length === 1) {
|
|
3643
4684
|
const confirmed = await confirm2({
|
|
@@ -3794,13 +4835,14 @@ async function promptForField(field, ruleName) {
|
|
|
3794
4835
|
}
|
|
3795
4836
|
async function gatherChoices(state, options, prompter) {
|
|
3796
4837
|
let items;
|
|
3797
|
-
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;
|
|
3798
4839
|
if (hasExplicitFlags || options.eslint) {
|
|
3799
4840
|
items = [];
|
|
3800
4841
|
if (options.mcp) items.push("mcp");
|
|
3801
4842
|
if (options.hooks) items.push("hooks");
|
|
3802
4843
|
if (options.genstyleguide) items.push("genstyleguide");
|
|
3803
4844
|
if (options.genrules) items.push("genrules");
|
|
4845
|
+
if (options.skill) items.push("skill");
|
|
3804
4846
|
if (options.routes || options.react) items.push("next");
|
|
3805
4847
|
if (options.eslint) items.push("eslint");
|
|
3806
4848
|
} else if (options.mode) {
|
|
@@ -3839,6 +4881,25 @@ async function gatherChoices(state, options, prompter) {
|
|
|
3839
4881
|
};
|
|
3840
4882
|
}
|
|
3841
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
|
+
}
|
|
3842
4903
|
let eslintChoices;
|
|
3843
4904
|
if (items.includes("eslint")) {
|
|
3844
4905
|
const packagesWithEslint = state.packages.filter(
|
|
@@ -3846,7 +4907,7 @@ async function gatherChoices(state, options, prompter) {
|
|
|
3846
4907
|
);
|
|
3847
4908
|
if (packagesWithEslint.length === 0) {
|
|
3848
4909
|
throw new Error(
|
|
3849
|
-
"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."
|
|
3850
4911
|
);
|
|
3851
4912
|
}
|
|
3852
4913
|
const packagePaths = await prompter.selectEslintPackages(
|
|
@@ -3878,6 +4939,7 @@ async function gatherChoices(state, options, prompter) {
|
|
|
3878
4939
|
mcpMerge,
|
|
3879
4940
|
hooksMerge,
|
|
3880
4941
|
next: nextChoices,
|
|
4942
|
+
vite: viteChoices,
|
|
3881
4943
|
eslint: eslintChoices
|
|
3882
4944
|
};
|
|
3883
4945
|
}
|
|
@@ -3926,7 +4988,7 @@ function displayResults(result) {
|
|
|
3926
4988
|
if (summary.nextApp) {
|
|
3927
4989
|
installedItems.push(
|
|
3928
4990
|
`${pc.cyan("Next Routes")} \u2192 ${pc.dim(
|
|
3929
|
-
|
|
4991
|
+
join15(summary.nextApp.appRoot, "api/.uilint")
|
|
3930
4992
|
)}`
|
|
3931
4993
|
);
|
|
3932
4994
|
installedItems.push(
|
|
@@ -3934,7 +4996,17 @@ function displayResults(result) {
|
|
|
3934
4996
|
);
|
|
3935
4997
|
installedItems.push(
|
|
3936
4998
|
`${pc.cyan("JSX Loc Plugin")} \u2192 ${pc.dim(
|
|
3937
|
-
"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()"
|
|
3938
5010
|
)}`
|
|
3939
5011
|
);
|
|
3940
5012
|
}
|
|
@@ -3982,6 +5054,11 @@ function displayResults(result) {
|
|
|
3982
5054
|
"Run your Next.js dev server - use Alt+Click on any element to inspect"
|
|
3983
5055
|
);
|
|
3984
5056
|
}
|
|
5057
|
+
if (summary.viteApp) {
|
|
5058
|
+
steps.push(
|
|
5059
|
+
"Run your Vite dev server - use Alt+Click on any element to inspect"
|
|
5060
|
+
);
|
|
5061
|
+
}
|
|
3985
5062
|
if (summary.eslintTargets.length > 0) {
|
|
3986
5063
|
steps.push(`Run ${pc.cyan("npx eslint src/")} to check for issues`);
|
|
3987
5064
|
steps.push(
|
|
@@ -4046,12 +5123,207 @@ async function install(options = {}, prompter = cliPrompter, executeOptions = {}
|
|
|
4046
5123
|
}
|
|
4047
5124
|
|
|
4048
5125
|
// src/commands/serve.ts
|
|
4049
|
-
import { existsSync as
|
|
5126
|
+
import { existsSync as existsSync17, statSync as statSync4, readdirSync as readdirSync5, readFileSync as readFileSync11 } from "fs";
|
|
4050
5127
|
import { createRequire as createRequire2 } from "module";
|
|
4051
|
-
import { dirname as
|
|
5128
|
+
import { dirname as dirname9, resolve as resolve5, relative as relative3, join as join17, parse as parse2 } from "path";
|
|
4052
5129
|
import { WebSocketServer, WebSocket } from "ws";
|
|
4053
5130
|
import { watch } from "chokidar";
|
|
4054
|
-
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
|
|
4055
5327
|
function pickAppRoot(params) {
|
|
4056
5328
|
const { cwd, workspaceRoot } = params;
|
|
4057
5329
|
if (detectNextAppRouter(cwd)) return cwd;
|
|
@@ -4066,6 +5338,18 @@ function pickAppRoot(params) {
|
|
|
4066
5338
|
}
|
|
4067
5339
|
var cache = /* @__PURE__ */ new Map();
|
|
4068
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
|
+
}
|
|
4069
5353
|
var resolvedPathCache = /* @__PURE__ */ new Map();
|
|
4070
5354
|
var subscriptions = /* @__PURE__ */ new Map();
|
|
4071
5355
|
var fileWatcher = null;
|
|
@@ -4084,8 +5368,8 @@ function offsetFromLineCol(lineStarts, line1, col0, codeLength) {
|
|
|
4084
5368
|
return Math.max(0, Math.min(codeLength, base + Math.max(0, col0)));
|
|
4085
5369
|
}
|
|
4086
5370
|
function buildJsxElementSpans(code, dataLocFile) {
|
|
4087
|
-
const { parse } = localRequire("@typescript-eslint/typescript-estree");
|
|
4088
|
-
const ast =
|
|
5371
|
+
const { parse: parse3 } = localRequire("@typescript-eslint/typescript-estree");
|
|
5372
|
+
const ast = parse3(code, {
|
|
4089
5373
|
loc: true,
|
|
4090
5374
|
range: true,
|
|
4091
5375
|
jsx: true,
|
|
@@ -4148,10 +5432,10 @@ function findESLintCwd(startDir) {
|
|
|
4148
5432
|
let dir = startDir;
|
|
4149
5433
|
for (let i = 0; i < 30; i++) {
|
|
4150
5434
|
for (const cfg of ESLINT_CONFIG_FILES2) {
|
|
4151
|
-
if (
|
|
5435
|
+
if (existsSync17(join17(dir, cfg))) return dir;
|
|
4152
5436
|
}
|
|
4153
|
-
if (
|
|
4154
|
-
const parent =
|
|
5437
|
+
if (existsSync17(join17(dir, "package.json"))) return dir;
|
|
5438
|
+
const parent = dirname9(dir);
|
|
4155
5439
|
if (parent === dir) break;
|
|
4156
5440
|
dir = parent;
|
|
4157
5441
|
}
|
|
@@ -4164,7 +5448,7 @@ function normalizeDataLocFilePath(absoluteFilePath, projectCwd) {
|
|
|
4164
5448
|
const abs = normalizePathSlashes(resolve5(absoluteFilePath));
|
|
4165
5449
|
const cwd = normalizePathSlashes(resolve5(projectCwd));
|
|
4166
5450
|
if (abs === cwd || abs.startsWith(cwd + "/")) {
|
|
4167
|
-
return normalizePathSlashes(
|
|
5451
|
+
return normalizePathSlashes(relative3(cwd, abs));
|
|
4168
5452
|
}
|
|
4169
5453
|
return abs;
|
|
4170
5454
|
}
|
|
@@ -4176,25 +5460,25 @@ function resolveRequestedFilePath(filePath) {
|
|
|
4176
5460
|
if (cached) return cached;
|
|
4177
5461
|
const cwd = process.cwd();
|
|
4178
5462
|
const fromCwd = resolve5(cwd, filePath);
|
|
4179
|
-
if (
|
|
5463
|
+
if (existsSync17(fromCwd)) {
|
|
4180
5464
|
resolvedPathCache.set(filePath, fromCwd);
|
|
4181
5465
|
return fromCwd;
|
|
4182
5466
|
}
|
|
4183
5467
|
const wsRoot = findWorkspaceRoot5(cwd);
|
|
4184
5468
|
const fromWs = resolve5(wsRoot, filePath);
|
|
4185
|
-
if (
|
|
5469
|
+
if (existsSync17(fromWs)) {
|
|
4186
5470
|
resolvedPathCache.set(filePath, fromWs);
|
|
4187
5471
|
return fromWs;
|
|
4188
5472
|
}
|
|
4189
5473
|
for (const top of ["apps", "packages"]) {
|
|
4190
|
-
const base =
|
|
4191
|
-
if (!
|
|
5474
|
+
const base = join17(wsRoot, top);
|
|
5475
|
+
if (!existsSync17(base)) continue;
|
|
4192
5476
|
try {
|
|
4193
|
-
const entries =
|
|
5477
|
+
const entries = readdirSync5(base, { withFileTypes: true });
|
|
4194
5478
|
for (const ent of entries) {
|
|
4195
5479
|
if (!ent.isDirectory()) continue;
|
|
4196
5480
|
const p2 = resolve5(base, ent.name, filePath);
|
|
4197
|
-
if (
|
|
5481
|
+
if (existsSync17(p2)) {
|
|
4198
5482
|
resolvedPathCache.set(filePath, p2);
|
|
4199
5483
|
return p2;
|
|
4200
5484
|
}
|
|
@@ -4209,7 +5493,7 @@ async function getESLintForProject(projectCwd) {
|
|
|
4209
5493
|
const cached = eslintInstances.get(projectCwd);
|
|
4210
5494
|
if (cached) return cached;
|
|
4211
5495
|
try {
|
|
4212
|
-
const req = createRequire2(
|
|
5496
|
+
const req = createRequire2(join17(projectCwd, "package.json"));
|
|
4213
5497
|
const mod = req("eslint");
|
|
4214
5498
|
const ESLintCtor = mod?.ESLint ?? mod?.default?.ESLint ?? mod?.default ?? mod;
|
|
4215
5499
|
if (!ESLintCtor) return null;
|
|
@@ -4222,13 +5506,13 @@ async function getESLintForProject(projectCwd) {
|
|
|
4222
5506
|
}
|
|
4223
5507
|
async function lintFile(filePath, onProgress) {
|
|
4224
5508
|
const absolutePath = resolveRequestedFilePath(filePath);
|
|
4225
|
-
if (!
|
|
5509
|
+
if (!existsSync17(absolutePath)) {
|
|
4226
5510
|
onProgress(`File not found: ${pc.dim(absolutePath)}`);
|
|
4227
5511
|
return [];
|
|
4228
5512
|
}
|
|
4229
5513
|
const mtimeMs = (() => {
|
|
4230
5514
|
try {
|
|
4231
|
-
return
|
|
5515
|
+
return statSync4(absolutePath).mtimeMs;
|
|
4232
5516
|
} catch {
|
|
4233
5517
|
return 0;
|
|
4234
5518
|
}
|
|
@@ -4238,7 +5522,7 @@ async function lintFile(filePath, onProgress) {
|
|
|
4238
5522
|
onProgress("Cache hit (unchanged)");
|
|
4239
5523
|
return cached.issues;
|
|
4240
5524
|
}
|
|
4241
|
-
const fileDir =
|
|
5525
|
+
const fileDir = dirname9(absolutePath);
|
|
4242
5526
|
const projectCwd = findESLintCwd(fileDir);
|
|
4243
5527
|
onProgress(`Resolving ESLint project... ${pc.dim(projectCwd)}`);
|
|
4244
5528
|
const eslint = await getESLintForProject(projectCwd);
|
|
@@ -4261,7 +5545,7 @@ async function lintFile(filePath, onProgress) {
|
|
|
4261
5545
|
let codeLength = 0;
|
|
4262
5546
|
try {
|
|
4263
5547
|
onProgress("Building JSX map...");
|
|
4264
|
-
const code =
|
|
5548
|
+
const code = readFileSync11(absolutePath, "utf-8");
|
|
4265
5549
|
codeLength = code.length;
|
|
4266
5550
|
lineStarts = buildLineStarts(code);
|
|
4267
5551
|
spans = buildJsxElementSpans(code, dataLocFile);
|
|
@@ -4330,6 +5614,7 @@ async function handleMessage(ws, data) {
|
|
|
4330
5614
|
message.filePath ?? "(all)"
|
|
4331
5615
|
)}`
|
|
4332
5616
|
);
|
|
5617
|
+
} else if (message.type === "vision:analyze") {
|
|
4333
5618
|
}
|
|
4334
5619
|
switch (message.type) {
|
|
4335
5620
|
case "lint:file": {
|
|
@@ -4342,7 +5627,7 @@ async function handleMessage(ws, data) {
|
|
|
4342
5627
|
});
|
|
4343
5628
|
const startedAt = Date.now();
|
|
4344
5629
|
const resolved = resolveRequestedFilePath(filePath);
|
|
4345
|
-
if (!
|
|
5630
|
+
if (!existsSync17(resolved)) {
|
|
4346
5631
|
const cwd = process.cwd();
|
|
4347
5632
|
const wsRoot = findWorkspaceRoot5(cwd);
|
|
4348
5633
|
logWarning(
|
|
@@ -4430,6 +5715,167 @@ async function handleMessage(ws, data) {
|
|
|
4430
5715
|
}
|
|
4431
5716
|
break;
|
|
4432
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
|
+
}
|
|
4433
5879
|
}
|
|
4434
5880
|
}
|
|
4435
5881
|
function handleDisconnect(ws) {
|
|
@@ -4460,6 +5906,7 @@ async function serve(options) {
|
|
|
4460
5906
|
const cwd = process.cwd();
|
|
4461
5907
|
const wsRoot = findWorkspaceRoot5(cwd);
|
|
4462
5908
|
const appRoot = pickAppRoot({ cwd, workspaceRoot: wsRoot });
|
|
5909
|
+
serverAppRootForVision = appRoot;
|
|
4463
5910
|
logInfo(`Workspace root: ${pc.dim(wsRoot)}`);
|
|
4464
5911
|
logInfo(`App root: ${pc.dim(appRoot)}`);
|
|
4465
5912
|
logInfo(`Server cwd: ${pc.dim(cwd)}`);
|
|
@@ -4499,22 +5946,505 @@ async function serve(options) {
|
|
|
4499
5946
|
`UILint WebSocket server running on ${pc.cyan(`ws://localhost:${port}`)}`
|
|
4500
5947
|
);
|
|
4501
5948
|
logInfo("Press Ctrl+C to stop");
|
|
4502
|
-
await new Promise((
|
|
5949
|
+
await new Promise((resolve8) => {
|
|
4503
5950
|
process.on("SIGINT", () => {
|
|
4504
5951
|
logInfo("Shutting down...");
|
|
4505
5952
|
wss.close();
|
|
4506
5953
|
fileWatcher?.close();
|
|
4507
|
-
|
|
5954
|
+
resolve8();
|
|
4508
5955
|
});
|
|
4509
5956
|
});
|
|
4510
5957
|
}
|
|
4511
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
|
+
|
|
4512
6442
|
// src/commands/session.ts
|
|
4513
|
-
import { existsSync as
|
|
4514
|
-
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";
|
|
4515
6445
|
import { createStyleSummary as createStyleSummary3 } from "uilint-core";
|
|
4516
6446
|
import {
|
|
4517
|
-
ensureOllamaReady as
|
|
6447
|
+
ensureOllamaReady as ensureOllamaReady7,
|
|
4518
6448
|
parseCLIInput as parseCLIInput2,
|
|
4519
6449
|
readStyleGuideFromProject as readStyleGuideFromProject2,
|
|
4520
6450
|
readTailwindThemeTokens as readTailwindThemeTokens3
|
|
@@ -4522,18 +6452,18 @@ import {
|
|
|
4522
6452
|
var SESSION_FILE = "/tmp/uilint-session.json";
|
|
4523
6453
|
var UI_FILE_EXTENSIONS = [".tsx", ".jsx", ".css", ".scss", ".module.css"];
|
|
4524
6454
|
function readSession() {
|
|
4525
|
-
if (!
|
|
6455
|
+
if (!existsSync19(SESSION_FILE)) {
|
|
4526
6456
|
return { files: [], startedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4527
6457
|
}
|
|
4528
6458
|
try {
|
|
4529
|
-
const content =
|
|
6459
|
+
const content = readFileSync13(SESSION_FILE, "utf-8");
|
|
4530
6460
|
return JSON.parse(content);
|
|
4531
6461
|
} catch {
|
|
4532
6462
|
return { files: [], startedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
4533
6463
|
}
|
|
4534
6464
|
}
|
|
4535
6465
|
function writeSession(state) {
|
|
4536
|
-
|
|
6466
|
+
writeFileSync10(SESSION_FILE, JSON.stringify(state, null, 2), "utf-8");
|
|
4537
6467
|
}
|
|
4538
6468
|
function isUIFile(filePath) {
|
|
4539
6469
|
return UI_FILE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
|
|
@@ -4544,7 +6474,7 @@ function isScannableMarkupFile(filePath) {
|
|
|
4544
6474
|
);
|
|
4545
6475
|
}
|
|
4546
6476
|
async function sessionClear() {
|
|
4547
|
-
if (
|
|
6477
|
+
if (existsSync19(SESSION_FILE)) {
|
|
4548
6478
|
unlinkSync2(SESSION_FILE);
|
|
4549
6479
|
}
|
|
4550
6480
|
console.log(JSON.stringify({ cleared: true }));
|
|
@@ -4611,17 +6541,17 @@ async function sessionScan(options = {}) {
|
|
|
4611
6541
|
}
|
|
4612
6542
|
return;
|
|
4613
6543
|
}
|
|
4614
|
-
await
|
|
6544
|
+
await ensureOllamaReady7();
|
|
4615
6545
|
const client = await createLLMClient({});
|
|
4616
6546
|
const results = [];
|
|
4617
6547
|
for (const filePath of session.files) {
|
|
4618
|
-
if (!
|
|
6548
|
+
if (!existsSync19(filePath)) continue;
|
|
4619
6549
|
if (!isScannableMarkupFile(filePath)) continue;
|
|
4620
6550
|
try {
|
|
4621
|
-
const absolutePath =
|
|
4622
|
-
const htmlLike =
|
|
6551
|
+
const absolutePath = resolve7(process.cwd(), filePath);
|
|
6552
|
+
const htmlLike = readFileSync13(filePath, "utf-8");
|
|
4623
6553
|
const snapshot = parseCLIInput2(htmlLike);
|
|
4624
|
-
const tailwindSearchDir =
|
|
6554
|
+
const tailwindSearchDir = dirname11(absolutePath);
|
|
4625
6555
|
const tailwindTheme = readTailwindThemeTokens3(tailwindSearchDir);
|
|
4626
6556
|
const styleSummary = createStyleSummary3(snapshot.styles, {
|
|
4627
6557
|
html: snapshot.html,
|
|
@@ -4674,7 +6604,7 @@ async function sessionScan(options = {}) {
|
|
|
4674
6604
|
};
|
|
4675
6605
|
console.log(JSON.stringify(result));
|
|
4676
6606
|
}
|
|
4677
|
-
if (
|
|
6607
|
+
if (existsSync19(SESSION_FILE)) {
|
|
4678
6608
|
unlinkSync2(SESSION_FILE);
|
|
4679
6609
|
}
|
|
4680
6610
|
await flushLangfuse();
|
|
@@ -4685,9 +6615,9 @@ async function sessionList() {
|
|
|
4685
6615
|
}
|
|
4686
6616
|
|
|
4687
6617
|
// src/index.ts
|
|
4688
|
-
import { readFileSync as
|
|
4689
|
-
import { dirname as
|
|
4690
|
-
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";
|
|
4691
6621
|
function assertNodeVersion(minMajor) {
|
|
4692
6622
|
const ver = process.versions.node || "";
|
|
4693
6623
|
const majorStr = ver.split(".")[0] || "";
|
|
@@ -4703,9 +6633,9 @@ assertNodeVersion(20);
|
|
|
4703
6633
|
var program = new Command();
|
|
4704
6634
|
function getCLIVersion2() {
|
|
4705
6635
|
try {
|
|
4706
|
-
const
|
|
4707
|
-
const pkgPath =
|
|
4708
|
-
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"));
|
|
4709
6639
|
return pkg.version || "0.0.0";
|
|
4710
6640
|
} catch {
|
|
4711
6641
|
return "0.0.0";
|
|
@@ -4808,6 +6738,40 @@ program.command("serve").description("Start WebSocket server for real-time UI li
|
|
|
4808
6738
|
port: parseInt(options.port, 10)
|
|
4809
6739
|
});
|
|
4810
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
|
+
});
|
|
4811
6775
|
var sessionCmd = program.command("session").description(
|
|
4812
6776
|
"Manage file tracking for agentic sessions (used by Cursor hooks)"
|
|
4813
6777
|
);
|