lintlane 2.5.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +223 -97
- package/bin/ci.mjs +82 -76
- package/bin/cli.mjs +102 -180
- package/bin/pre-commit.mjs +55 -0
- package/bin/proc.mjs +113 -0
- package/bin/project.mjs +180 -0
- package/bin/staged.mjs +333 -0
- package/bin/ui-server.mjs +175 -89
- package/package.json +5 -3
- package/templates/pre-commit +5 -2
package/bin/ui-server.mjs
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
2
5
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
6
|
+
import { detectCiSteps, runCiSteps } from "./ci.mjs";
|
|
7
|
+
import { runProcess, spawnPrepared } from "./proc.mjs";
|
|
5
8
|
|
|
6
9
|
const MAX_FILES = 200;
|
|
10
|
+
const MAX_BODY = 1024 * 1024;
|
|
7
11
|
|
|
8
12
|
function isInside(root, target) {
|
|
9
13
|
const rel = relative(root, target);
|
|
@@ -16,10 +20,13 @@ function safePath(cwd, file) {
|
|
|
16
20
|
return isInside(cwd, abs) && abs !== cwd ? abs : null;
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
const runBiome = (biome, args, options) =>
|
|
24
|
+
runProcess(biome[0], [...biome.slice(1), ...args], options);
|
|
25
|
+
|
|
19
26
|
async function runCheck(cwd, biome) {
|
|
20
27
|
const t0 = performance.now();
|
|
21
28
|
console.log("… running biome check");
|
|
22
|
-
const result = await
|
|
29
|
+
const result = await runBiome(biome, ["check", "--reporter=json", "."], { cwd });
|
|
23
30
|
const out = result.stdout.toString().trim();
|
|
24
31
|
console.log(
|
|
25
32
|
`… biome finished in ${((performance.now() - t0) / 1000).toFixed(1)}s (${(out.length / 1024).toFixed(0)} KB of output)`,
|
|
@@ -63,11 +70,11 @@ async function runCheck(cwd, biome) {
|
|
|
63
70
|
async function getDiff(cwd, biome, file) {
|
|
64
71
|
const abs = safePath(cwd, file);
|
|
65
72
|
if (!abs || !existsSync(abs)) throw new Error(`Invalid file: ${file}`);
|
|
66
|
-
const original = await
|
|
67
|
-
const res = await
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
const original = await readFile(abs, "utf8");
|
|
74
|
+
const res = await runBiome(biome, ["check", "--write", `--stdin-file-path=${file}`], {
|
|
75
|
+
cwd,
|
|
76
|
+
input: original,
|
|
77
|
+
});
|
|
71
78
|
const text = res.stdout.toString();
|
|
72
79
|
const fixed = text.length > 0 ? text : null;
|
|
73
80
|
return { original, fixed, fixable: fixed !== null && fixed !== original };
|
|
@@ -77,107 +84,186 @@ async function applyFixes(cwd, biome, files) {
|
|
|
77
84
|
const paths = files.map((file) => {
|
|
78
85
|
const abs = safePath(cwd, file);
|
|
79
86
|
if (!abs || !existsSync(abs)) throw new Error(`Invalid file: ${file}`);
|
|
80
|
-
return file;
|
|
87
|
+
return file.startsWith("-") ? `./${file}` : file;
|
|
81
88
|
});
|
|
82
89
|
const CHUNK = 100;
|
|
83
90
|
for (let i = 0; i < paths.length; i += CHUNK) {
|
|
84
|
-
await
|
|
91
|
+
await runBiome(biome, ["check", "--write", ...paths.slice(i, i + CHUNK)], { cwd });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class HttpError extends Error {
|
|
96
|
+
constructor(status, message) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.status = status;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sendJson(res, status, body) {
|
|
103
|
+
const data = JSON.stringify(body);
|
|
104
|
+
res.writeHead(status, {
|
|
105
|
+
"content-type": "application/json; charset=utf-8",
|
|
106
|
+
"cache-control": "no-store",
|
|
107
|
+
});
|
|
108
|
+
res.end(data);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readJson(req) {
|
|
112
|
+
const chunks = [];
|
|
113
|
+
let size = 0;
|
|
114
|
+
for await (const chunk of req) {
|
|
115
|
+
size += chunk.length;
|
|
116
|
+
if (size > MAX_BODY) throw new HttpError(413, "Request body too large");
|
|
117
|
+
chunks.push(chunk);
|
|
118
|
+
}
|
|
119
|
+
if (size === 0) return {};
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
122
|
+
} catch {
|
|
123
|
+
throw new HttpError(400, "Invalid JSON");
|
|
85
124
|
}
|
|
86
125
|
}
|
|
87
126
|
|
|
127
|
+
function openBrowser(address) {
|
|
128
|
+
const [command, args] =
|
|
129
|
+
process.platform === "darwin"
|
|
130
|
+
? ["open", [address]]
|
|
131
|
+
: process.platform === "win32"
|
|
132
|
+
? ["cmd", ["/c", "start", '""', address]]
|
|
133
|
+
: ["xdg-open", [address]];
|
|
134
|
+
try {
|
|
135
|
+
const child = spawnPrepared(command, args, { stdio: "ignore", detached: true });
|
|
136
|
+
child.on("error", () => {});
|
|
137
|
+
child.unref();
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
|
|
88
141
|
export async function startUi({ cwd, biome, kitDir, port, open, pmName, pkg }) {
|
|
89
142
|
let ciBusy = false;
|
|
90
|
-
const token =
|
|
91
|
-
const html = await
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
143
|
+
const token = randomUUID();
|
|
144
|
+
const html = await readFile(join(kitDir, "ui", "index.html"), "utf8");
|
|
145
|
+
|
|
146
|
+
async function handle(req, res) {
|
|
147
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
148
|
+
|
|
149
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
150
|
+
if (url.searchParams.get("t") !== token) {
|
|
151
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
152
|
+
res.end("Forbidden: open the URL printed by `lintlane ui`.");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
res.writeHead(200, {
|
|
156
|
+
"content-type": "text/html; charset=utf-8",
|
|
157
|
+
"cache-control": "no-store",
|
|
158
|
+
});
|
|
159
|
+
res.end(html.replace("__TOKEN__", token));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (url.pathname.startsWith("/api/")) {
|
|
164
|
+
if (req.headers["x-lintlane-token"] !== token) {
|
|
165
|
+
sendJson(res, 401, { error: "Unauthorized" });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (req.method === "POST" && url.pathname === "/api/check") {
|
|
169
|
+
sendJson(res, 200, await runCheck(cwd, biome));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (req.method === "POST" && url.pathname === "/api/fix") {
|
|
173
|
+
const { files } = await readJson(req);
|
|
174
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
175
|
+
sendJson(res, 400, { error: "No files given" });
|
|
176
|
+
return;
|
|
104
177
|
}
|
|
105
|
-
|
|
106
|
-
|
|
178
|
+
await applyFixes(cwd, biome, files);
|
|
179
|
+
sendJson(res, 200, { fixed: files.length });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (req.method === "POST" && url.pathname === "/api/ci/steps") {
|
|
183
|
+
const steps = detectCiSteps({ cwd, biome, pmName, pkg });
|
|
184
|
+
sendJson(res, 200, {
|
|
185
|
+
pmName,
|
|
186
|
+
steps: steps.map(({ id, label, display, defaultOn, note }) => ({
|
|
187
|
+
id,
|
|
188
|
+
label,
|
|
189
|
+
display,
|
|
190
|
+
defaultOn,
|
|
191
|
+
note,
|
|
192
|
+
})),
|
|
107
193
|
});
|
|
194
|
+
return;
|
|
108
195
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return
|
|
196
|
+
if (req.method === "POST" && url.pathname === "/api/ci/run") {
|
|
197
|
+
if (ciBusy) {
|
|
198
|
+
sendJson(res, 409, { error: "A CI run is already in progress" });
|
|
199
|
+
return;
|
|
113
200
|
}
|
|
201
|
+
const { steps: ids, stopOnFail } = await readJson(req);
|
|
202
|
+
const known = detectCiSteps({ cwd, biome, pmName, pkg });
|
|
203
|
+
const steps = Array.isArray(ids) ? known.filter((step) => ids.includes(step.id)) : [];
|
|
204
|
+
if (steps.length === 0) {
|
|
205
|
+
sendJson(res, 400, { error: "No steps selected" });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
ciBusy = true;
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
// The browser's Stop button (or closing the tab) ends the response early.
|
|
211
|
+
res.on("close", () => {
|
|
212
|
+
if (!res.writableFinished) controller.abort();
|
|
213
|
+
});
|
|
214
|
+
res.writeHead(200, { "content-type": "application/x-ndjson", "cache-control": "no-store" });
|
|
114
215
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
const steps = detectCiSteps({ cwd, biome, pmName, pkg });
|
|
128
|
-
return Response.json({
|
|
129
|
-
pmName,
|
|
130
|
-
steps: steps.map(({ id, label, display, defaultOn, note }) => ({
|
|
131
|
-
id,
|
|
132
|
-
label,
|
|
133
|
-
display,
|
|
134
|
-
defaultOn,
|
|
135
|
-
note,
|
|
136
|
-
})),
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
if (req.method === "POST" && url.pathname === "/api/ci/run") {
|
|
140
|
-
if (ciBusy)
|
|
141
|
-
return Response.json({ error: "A CI run is already in progress" }, { status: 409 });
|
|
142
|
-
const { steps: ids, stopOnFail } = await req.json();
|
|
143
|
-
const known = detectCiSteps({ cwd, biome, pmName, pkg });
|
|
144
|
-
const steps = Array.isArray(ids) ? known.filter((step) => ids.includes(step.id)) : [];
|
|
145
|
-
if (steps.length === 0)
|
|
146
|
-
return Response.json({ error: "No steps selected" }, { status: 400 });
|
|
147
|
-
ciBusy = true;
|
|
148
|
-
const stream = ciStream({
|
|
149
|
-
cwd,
|
|
150
|
-
steps,
|
|
151
|
-
stopOnFail: stopOnFail !== false,
|
|
152
|
-
release: () => {
|
|
153
|
-
ciBusy = false;
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
return new Response(stream, {
|
|
157
|
-
headers: { "content-type": "application/x-ndjson", "cache-control": "no-store" },
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
if (req.method === "POST" && url.pathname === "/api/diff") {
|
|
161
|
-
const { file } = await req.json();
|
|
162
|
-
return Response.json(await getDiff(cwd, biome, file));
|
|
163
|
-
}
|
|
164
|
-
} catch (error) {
|
|
165
|
-
return Response.json({ error: error.message }, { status: 500 });
|
|
216
|
+
await runCiSteps({
|
|
217
|
+
cwd,
|
|
218
|
+
steps,
|
|
219
|
+
stopOnFail: stopOnFail !== false,
|
|
220
|
+
signal: controller.signal,
|
|
221
|
+
send: (event) => {
|
|
222
|
+
if (!res.destroyed) res.write(`${JSON.stringify(event)}\n`);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
} finally {
|
|
226
|
+
ciBusy = false;
|
|
227
|
+
if (!res.destroyed) res.end();
|
|
166
228
|
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (req.method === "POST" && url.pathname === "/api/diff") {
|
|
232
|
+
const { file } = await readJson(req);
|
|
233
|
+
sendJson(res, 200, await getDiff(cwd, biome, file));
|
|
234
|
+
return;
|
|
167
235
|
}
|
|
236
|
+
}
|
|
168
237
|
|
|
169
|
-
|
|
170
|
-
|
|
238
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
239
|
+
res.end("Not found");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const server = createServer((req, res) => {
|
|
243
|
+
handle(req, res).catch((error) => {
|
|
244
|
+
if (res.headersSent) {
|
|
245
|
+
res.destroy();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
sendJson(res, error instanceof HttpError ? error.status : 500, { error: error.message });
|
|
249
|
+
});
|
|
171
250
|
});
|
|
172
251
|
|
|
173
|
-
|
|
252
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
253
|
+
server.once("error", (error) => {
|
|
254
|
+
rejectListen(
|
|
255
|
+
error.code === "EADDRINUSE"
|
|
256
|
+
? new Error(`Port ${port} is already in use. Pick another with --port <n>.`)
|
|
257
|
+
: error,
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
server.listen(port, "127.0.0.1", resolveListen);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const address = `http://127.0.0.1:${server.address().port}/?t=${token}`;
|
|
174
264
|
console.log(`✔ Lintlane dashboard running at ${address}`);
|
|
175
265
|
console.log(" Press Ctrl+C to stop.");
|
|
176
266
|
|
|
177
|
-
if (open)
|
|
178
|
-
const opener =
|
|
179
|
-
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
180
|
-
await $`${opener} ${address}`.quiet().nothrow();
|
|
181
|
-
}
|
|
267
|
+
if (open) openBrowser(address);
|
|
182
268
|
return server;
|
|
183
269
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lintlane",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "Pre-push checks for React apps: shared Biome preset, local lint and CI dashboard",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"check": "biome check .",
|
|
25
|
-
"check:fix": "biome check --write ."
|
|
25
|
+
"check:fix": "biome check --write .",
|
|
26
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
27
|
+
"test:e2e": "LINTLANE_E2E=1 node --test \"test/e2e.test.mjs\""
|
|
26
28
|
},
|
|
27
29
|
"peerDependencies": {
|
|
28
30
|
"@biomejs/biome": ">=2.2.0 <3"
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
"access": "public"
|
|
35
37
|
},
|
|
36
38
|
"engines": {
|
|
37
|
-
"
|
|
39
|
+
"node": ">=22"
|
|
38
40
|
},
|
|
39
41
|
"keywords": [
|
|
40
42
|
"lintlane",
|
package/templates/pre-commit
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
if [ -f node_modules/lintlane/bin/pre-commit.mjs ]; then
|
|
2
|
+
node node_modules/lintlane/bin/pre-commit.mjs
|
|
3
|
+
else
|
|
4
|
+
echo "lintlane: node_modules/lintlane not found; skipping staged-file checks. Run your package manager's install." >&2
|
|
5
|
+
fi
|