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/bin/ci.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ import { binPath, spawnPrepared } from "./proc.mjs";
3
4
 
4
5
  const MAX_LOG_CHARS = 2_000_000;
5
6
 
@@ -30,6 +31,7 @@ function firstScript(scripts, names) {
30
31
  * and the browser only ever sends step ids back — never command lines.
31
32
  */
32
33
  export function detectCiSteps({ cwd, biome, pmName, pkg }) {
34
+ // `biome` is an argv prefix: [path] or [node, script] (see resolveBiome).
33
35
  const scripts = pkg?.scripts ?? {};
34
36
  const run = RUN_SCRIPT[pmName] ?? RUN_SCRIPT.npm;
35
37
  const steps = [];
@@ -45,7 +47,7 @@ export function detectCiSteps({ cwd, biome, pmName, pkg }) {
45
47
  steps.push({
46
48
  id: "biome",
47
49
  label: "Biome CI (lint + format)",
48
- cmd: [biome, "ci", "--colors=off", "."],
50
+ cmd: [...biome, "ci", "--colors=off", "."],
49
51
  display: "biome ci .",
50
52
  defaultOn: true,
51
53
  note: "The same check the GitHub workflow runs.",
@@ -59,14 +61,11 @@ export function detectCiSteps({ cwd, biome, pmName, pkg }) {
59
61
  cmd: run(typecheck),
60
62
  defaultOn: true,
61
63
  });
62
- } else if (
63
- existsSync(join(cwd, "tsconfig.json")) &&
64
- existsSync(join(cwd, "node_modules", ".bin", "tsc"))
65
- ) {
64
+ } else if (existsSync(`${cwd}/tsconfig.json`) && existsSync(binPath(cwd, "tsc"))) {
66
65
  steps.push({
67
66
  id: "typecheck",
68
67
  label: "Typecheck",
69
- cmd: [join(cwd, "node_modules", ".bin", "tsc"), "--noEmit"],
68
+ cmd: [binPath(cwd, "tsc"), "--noEmit"],
70
69
  display: "tsc --noEmit",
71
70
  defaultOn: true,
72
71
  });
@@ -83,80 +82,87 @@ export function detectCiSteps({ cwd, biome, pmName, pkg }) {
83
82
  return steps.map((step) => ({ ...step, display: step.display ?? step.cmd.join(" ") }));
84
83
  }
85
84
 
85
+ // Kill the step and everything it started (a build tool's child processes too).
86
+ function killTree(child) {
87
+ if (!child || child.exitCode !== null) return;
88
+ try {
89
+ if (process.platform === "win32") child.kill();
90
+ else process.kill(-child.pid, "SIGTERM");
91
+ } catch {
92
+ try {
93
+ child.kill();
94
+ } catch {}
95
+ }
96
+ }
97
+
86
98
  /**
87
- * Run steps one after another and stream NDJSON events:
99
+ * Run steps one after another, reporting events through `send`:
88
100
  * start / log / end / skip / done
89
- * Closing the response (the browser's Stop button) kills the running process.
101
+ * Aborting `signal` (the browser's Stop button, or the page closing) kills the
102
+ * running step. Resolves when the run is over; never rejects.
90
103
  */
91
- export function ciStream({ cwd, steps, stopOnFail, release }) {
92
- const encoder = new TextEncoder();
93
- let proc = null;
94
- let cancelled = false;
95
-
96
- return new ReadableStream({
97
- async start(controller) {
98
- const send = (event) => {
99
- if (!cancelled) controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
100
- };
101
- let failed = false;
102
-
103
- try {
104
- for (const step of steps) {
105
- if (cancelled) break;
106
- if (failed && stopOnFail) {
107
- send({ type: "skip", id: step.id });
108
- continue;
109
- }
104
+ export async function runCiSteps({ cwd, steps, stopOnFail, send, signal }) {
105
+ let child = null;
106
+ const onAbort = () => killTree(child);
107
+ signal?.addEventListener("abort", onAbort);
108
+ let failed = false;
109
+
110
+ try {
111
+ for (const step of steps) {
112
+ if (signal?.aborted) return;
113
+ if (failed && stopOnFail) {
114
+ send({ type: "skip", id: step.id });
115
+ continue;
116
+ }
110
117
 
111
- send({ type: "start", id: step.id });
112
- const t0 = performance.now();
113
- let code = 1;
114
- let sent = 0;
115
- let truncated = false;
116
-
117
- const pump = async (readable) => {
118
- const decoder = new TextDecoder();
119
- for await (const chunk of readable) {
120
- const text = decoder.decode(chunk, { stream: true });
121
- if (sent < MAX_LOG_CHARS) {
122
- sent += text.length;
123
- send({ type: "log", id: step.id, text });
124
- } else if (!truncated) {
125
- truncated = true;
126
- send({ type: "log", id: step.id, text: "\n… output truncated …\n" });
127
- }
128
- }
129
- };
130
-
131
- try {
132
- proc = Bun.spawn(step.cmd, {
133
- cwd,
134
- env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0" },
135
- stdin: "ignore",
136
- stdout: "pipe",
137
- stderr: "pipe",
138
- });
139
- await Promise.all([pump(proc.stdout), pump(proc.stderr)]);
140
- code = await proc.exited;
141
- } catch (error) {
142
- send({ type: "log", id: step.id, text: `\n${error.message}\n` });
143
- code = 127;
118
+ send({ type: "start", id: step.id });
119
+ const t0 = performance.now();
120
+ let sent = 0;
121
+ let truncated = false;
122
+
123
+ const pump = (stream) => {
124
+ const decoder = new StringDecoder("utf8");
125
+ stream.on("data", (chunk) => {
126
+ const text = decoder.write(chunk);
127
+ if (sent < MAX_LOG_CHARS) {
128
+ sent += text.length;
129
+ send({ type: "log", id: step.id, text });
130
+ } else if (!truncated) {
131
+ truncated = true;
132
+ send({ type: "log", id: step.id, text: "\n… output truncated …\n" });
144
133
  }
145
- proc = null;
134
+ });
135
+ };
146
136
 
147
- if (cancelled) break;
148
- if (code !== 0) failed = true;
149
- send({ type: "end", id: step.id, code, ms: Math.round(performance.now() - t0) });
137
+ const code = await new Promise((resolve) => {
138
+ try {
139
+ child = spawnPrepared(step.cmd[0], step.cmd.slice(1), {
140
+ cwd,
141
+ env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0" },
142
+ stdio: ["ignore", "pipe", "pipe"],
143
+ detached: process.platform !== "win32", // own process group, so Stop can kill the tree
144
+ });
145
+ } catch (error) {
146
+ send({ type: "log", id: step.id, text: `\n${error.message}\n` });
147
+ resolve(127);
148
+ return;
150
149
  }
151
- send({ type: "done" });
152
- } finally {
153
- release();
154
- if (!cancelled) controller.close();
155
- }
156
- },
157
- cancel() {
158
- cancelled = true;
159
- proc?.kill();
160
- },
161
- });
150
+ pump(child.stdout);
151
+ pump(child.stderr);
152
+ child.on("error", (error) => {
153
+ send({ type: "log", id: step.id, text: `\n${error.message}\n` });
154
+ resolve(127);
155
+ });
156
+ child.on("close", (exitCode, killedBy) => resolve(exitCode ?? (killedBy ? 143 : 1)));
157
+ });
158
+ child = null;
159
+
160
+ if (signal?.aborted) return;
161
+ if (code !== 0) failed = true;
162
+ send({ type: "end", id: step.id, code, ms: Math.round(performance.now() - t0) });
163
+ }
164
+ send({ type: "done" });
165
+ } finally {
166
+ signal?.removeEventListener("abort", onAbort);
167
+ }
162
168
  }
package/bin/cli.mjs CHANGED
@@ -1,68 +1,27 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
  import { existsSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
4
6
  import { parseArgs } from "node:util";
5
- import { $ } from "bun";
6
-
7
- const KIT_DIR = join(import.meta.dir, "..");
8
- const KIT_PKG = await Bun.file(join(KIT_DIR, "package.json")).json();
7
+ import { binPath, resolveBiome, runInherit, runProcess } from "./proc.mjs";
8
+ import {
9
+ detectPackageManager,
10
+ detectPreset,
11
+ hasUnsafeRestaging,
12
+ installCommand,
13
+ isLegacyPreCommit,
14
+ PRESET_PATHS,
15
+ renderWorkflow,
16
+ runnerCommand,
17
+ } from "./project.mjs";
18
+
19
+ const KIT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
20
+ const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
21
+ const KIT_PKG = await readJson(join(KIT_DIR, "package.json"));
9
22
  const KIT_NAME = KIT_PKG.name;
10
23
  const KIT_VERSION = KIT_PKG.version;
11
-
12
- const PRESET_PATHS = {
13
- base: "biome",
14
- strict: "biome/strict",
15
- next: "biome/next",
16
- };
17
-
18
- const PACKAGE_MANAGERS = {
19
- bun: {
20
- lockfile: "bun.lock",
21
- install: (pkgs) => ["bun", "add", "--dev", "--exact", ...pkgs],
22
- runCheckCi: "bunx biome ci --reporter=github .",
23
- setupSteps: [
24
- " - uses: oven-sh/setup-bun@v2",
25
- " - run: bun install --frozen-lockfile",
26
- ],
27
- },
28
- npm: {
29
- lockfile: "package-lock.json",
30
- install: (pkgs) => ["npm", "install", "--save-dev", "--save-exact", ...pkgs],
31
- runCheckCi: "npx biome ci --reporter=github .",
32
- setupSteps: [
33
- " - uses: actions/setup-node@v4",
34
- " with:",
35
- " node-version: 20",
36
- " cache: npm",
37
- " - run: npm ci",
38
- ],
39
- },
40
- pnpm: {
41
- lockfile: "pnpm-lock.yaml",
42
- install: (pkgs) => ["pnpm", "add", "--save-dev", "--save-exact", ...pkgs],
43
- runCheckCi: "pnpm exec biome ci --reporter=github .",
44
- setupSteps: [
45
- " - uses: pnpm/action-setup@v4",
46
- " - uses: actions/setup-node@v4",
47
- " with:",
48
- " node-version: 20",
49
- " cache: pnpm",
50
- " - run: pnpm install --frozen-lockfile",
51
- ],
52
- },
53
- yarn: {
54
- lockfile: "yarn.lock",
55
- install: (pkgs) => ["yarn", "add", "--dev", "--exact", ...pkgs],
56
- runCheckCi: "yarn biome ci --reporter=github .",
57
- setupSteps: [
58
- " - uses: actions/setup-node@v4",
59
- " with:",
60
- " node-version: 20",
61
- " cache: yarn",
62
- " - run: yarn install --frozen-lockfile",
63
- ],
64
- },
65
- };
24
+ const RUNNER = runnerCommand();
66
25
 
67
26
  const BIOME_SUBCOMMANDS = {
68
27
  check: (paths) => ["check", ...paths],
@@ -70,18 +29,7 @@ const BIOME_SUBCOMMANDS = {
70
29
  ci: (paths) => ["ci", "--reporter=github", ...paths],
71
30
  };
72
31
 
73
- // Checked in order; the first match wins. Only "next" has its own Biome
74
- // lint domain — the others are still React under the hood, so they map to
75
- // the "base" preset, but we name what we found instead of guessing silently.
76
- const FRAMEWORK_DETECTORS = [
77
- { label: "Next.js", preset: "next", deps: ["next"] },
78
- { label: "TanStack Start", preset: "base", deps: ["@tanstack/react-start", "@tanstack/start"] },
79
- { label: "React Router (framework mode)", preset: "base", deps: ["@react-router/dev"] },
80
- { label: "Remix", preset: "base", deps: ["@remix-run/react", "@remix-run/node"] },
81
- { label: "React", preset: "base", deps: ["react"] },
82
- ];
83
-
84
- const HELP = `Usage: bunx ${KIT_NAME} <command> [options]
32
+ const HELP = `Usage: ${RUNNER} ${KIT_NAME} <command> [options]
85
33
 
86
34
  Commands:
87
35
  init [options] Set up Biome, scripts, hooks, and CI in the current project
@@ -116,13 +64,14 @@ auto-selected — pass --preset strict explicitly when you want it.
116
64
 
117
65
  check / check:fix / ci run the Biome binary already installed in this
118
66
  project's node_modules — you never need to call biome yourself. The
119
- package manager (bun, npm, pnpm, or yarn) used by \`init\` is detected
120
- from the "packageManager" field in package.json or from whichever
121
- lockfile is present. Bun is used as the fallback when neither is found.
122
-
123
- After \`init\` the command is installed in your project, so these run the
124
- local copy: \`bunx lintlane check\`, \`bunx lintlane check:fix\`,
125
- \`bunx lintlane ci\`, \`bunx lintlane commit\`, \`bunx lintlane ui\`.
67
+ package manager (npm, pnpm, yarn, or bun) used by \`init\` is detected from
68
+ the "packageManager" field in package.json, then from whichever lockfile is
69
+ present, then from the tool you launched lintlane with (npx, pnpm dlx, yarn
70
+ dlx, bunx). npm is used when none of those apply.
71
+
72
+ After \`init\` the \`lintlane\` command is installed in your project, for
73
+ example: \`npx lintlane check\`, \`pnpm exec lintlane check\`,
74
+ \`yarn lintlane check\`, \`bunx lintlane check\`.
126
75
  `;
127
76
 
128
77
  function fail(message) {
@@ -138,32 +87,9 @@ function warn(message) {
138
87
  console.log(`! ${message}`);
139
88
  }
140
89
 
141
- function detectPackageManager(cwd, pkg) {
142
- const declared = pkg.packageManager?.split("@")[0];
143
- if (declared && declared in PACKAGE_MANAGERS) return declared;
144
- for (const [name, pm] of Object.entries(PACKAGE_MANAGERS)) {
145
- if (existsSync(join(cwd, pm.lockfile))) return name;
146
- }
147
- return "bun";
148
- }
149
-
150
- function detectPreset(pkg) {
151
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
152
- for (const detector of FRAMEWORK_DETECTORS) {
153
- if (detector.deps.some((dep) => dep in deps)) {
154
- return { preset: detector.preset, label: detector.label };
155
- }
156
- }
157
- return { preset: "base", label: null };
158
- }
159
-
160
- function binPath(cwd, name) {
161
- return join(cwd, "node_modules", ".bin", name);
162
- }
163
-
164
90
  // Everything after a bare "--" belongs to the subcommand (e.g. `commit -- -a -S`
165
91
  // hands "-a -S" to git commit), so it never reaches our own option parsing.
166
- const rawArgs = Bun.argv.slice(2);
92
+ const rawArgs = process.argv.slice(2);
167
93
  const doubleDash = rawArgs.indexOf("--");
168
94
  const ownArgs = doubleDash < 0 ? rawArgs : rawArgs.slice(0, doubleDash);
169
95
  const passthroughArgs = doubleDash < 0 ? [] : rawArgs.slice(doubleDash + 1);
@@ -195,18 +121,13 @@ if (values.help && !["commit", "c", "check-commit"].includes(command)) {
195
121
  }
196
122
 
197
123
  if (command in BIOME_SUBCOMMANDS) {
198
- const biome = binPath(cwd, "biome");
199
- if (!existsSync(biome)) {
200
- fail(`Biome isn't installed in this project. Run: bunx ${KIT_NAME} init`);
124
+ const biome = resolveBiome(cwd);
125
+ if (!biome) {
126
+ fail(`Biome isn't installed in this project. Run: ${RUNNER} ${KIT_NAME} init`);
201
127
  }
202
128
  const extraPaths = positionals.slice(1);
203
129
  const biomeArgs = BIOME_SUBCOMMANDS[command](extraPaths.length ? extraPaths : ["."]);
204
- try {
205
- await $`${biome} ${biomeArgs}`.cwd(cwd);
206
- } catch (error) {
207
- process.exit(error.exitCode ?? 1);
208
- }
209
- process.exit(0);
130
+ process.exit(await runInherit(biome[0], [...biome.slice(1), ...biomeArgs], { cwd }));
210
131
  }
211
132
 
212
133
  if (command === "commit" || command === "c") {
@@ -220,28 +141,32 @@ if (command === "check-commit") {
220
141
  }
221
142
 
222
143
  if (command === "ui") {
223
- const biome = binPath(cwd, "biome");
224
- if (!existsSync(biome)) {
225
- fail(`Biome isn't installed in this project. Run: bunx ${KIT_NAME} init`);
144
+ const biome = resolveBiome(cwd);
145
+ if (!biome) {
146
+ fail(`Biome isn't installed in this project. Run: ${RUNNER} ${KIT_NAME} init`);
226
147
  }
227
148
  const { startUi } = await import("./ui-server.mjs");
228
149
  const uiPkgPath = join(cwd, "package.json");
229
- const uiPkg = existsSync(uiPkgPath) ? await Bun.file(uiPkgPath).json() : {};
230
- await startUi({
231
- cwd,
232
- biome,
233
- pkg: uiPkg,
234
- pmName: detectPackageManager(cwd, uiPkg),
235
- kitDir: KIT_DIR,
236
- port: values.port === undefined ? 4321 : Number(values.port),
237
- open: !values["no-open"],
238
- });
150
+ const uiPkg = existsSync(uiPkgPath) ? await readJson(uiPkgPath) : {};
151
+ try {
152
+ await startUi({
153
+ cwd,
154
+ biome,
155
+ pkg: uiPkg,
156
+ pmName: detectPackageManager(cwd, uiPkg).name,
157
+ kitDir: KIT_DIR,
158
+ port: values.port === undefined ? 4321 : Number(values.port),
159
+ open: !values["no-open"],
160
+ });
161
+ } catch (error) {
162
+ fail(error.message);
163
+ }
239
164
  await new Promise(() => {});
240
165
  }
241
166
 
242
167
  if (command !== "init") {
243
168
  fail(
244
- `Unknown command "${command}". Usage: bunx ${KIT_NAME} <init|check|check:fix|ci|commit|check-commit|ui> [options]`,
169
+ `Unknown command "${command}". Usage: ${RUNNER} ${KIT_NAME} <init|check|check:fix|ci|commit|check-commit|ui> [options]`,
245
170
  );
246
171
  }
247
172
 
@@ -256,10 +181,9 @@ if (!existsSync(pkgPath)) {
256
181
  fail("No package.json found in the current directory.");
257
182
  }
258
183
 
259
- const pkg = await Bun.file(pkgPath).json();
260
- const pmName = detectPackageManager(cwd, pkg);
261
- const pm = PACKAGE_MANAGERS[pmName];
262
- ok(`Detected package manager: ${pmName}`);
184
+ let pkg = await readJson(pkgPath);
185
+ const { name: pmName, source: pmSource } = detectPackageManager(cwd, pkg);
186
+ ok(`Detected package manager: ${pmName} (${pmSource})`);
263
187
 
264
188
  let preset = values.preset;
265
189
  if (preset === undefined) {
@@ -272,20 +196,31 @@ if (preset === undefined) {
272
196
  }
273
197
  }
274
198
 
275
- const devPackages = ["@biomejs/biome", `${KIT_NAME}@${KIT_VERSION}`];
199
+ // LINTLANE_INSTALL_SPEC lets you install lintlane from somewhere other than the
200
+ // registry (a tarball from `npm pack`, a local folder) when testing a build.
201
+ const kitSpec = process.env.LINTLANE_INSTALL_SPEC || `${KIT_NAME}@${KIT_VERSION}`;
202
+ const devPackages = ["@biomejs/biome", kitSpec];
276
203
  if (!values["no-hooks"]) devPackages.push("husky");
277
- const installArgv = pm.install(devPackages);
278
- const installCmd = installArgv.join(" ");
204
+ const installArgv = installCommand(pmName, devPackages);
279
205
 
280
206
  if (values["skip-install"]) {
281
- warn(`Skipped install. Run: ${installCmd}`);
207
+ warn(`Skipped install. Run: ${installArgv.join(" ")}`);
282
208
  } else {
283
- try {
284
- await $`${installArgv}`.cwd(cwd).quiet();
285
- ok(`Installed ${devPackages.join(", ")}`);
286
- } catch (error) {
287
- fail(`Install failed: ${error.message}`);
209
+ const install = await runProcess(installArgv[0], installArgv.slice(1), { cwd });
210
+ if (install.code !== 0) {
211
+ const reason =
212
+ install.error?.code === "ENOENT"
213
+ ? `"${installArgv[0]}" was not found. Install it, or run again with --skip-install and install the packages yourself:\n ${installArgv.join(" ")}`
214
+ : (install.stderr.toString().trim() || install.stdout.toString().trim())
215
+ .split("\n")
216
+ .slice(-8)
217
+ .join("\n");
218
+ fail(`Install failed (${installArgv.join(" ")}):\n${reason}`);
288
219
  }
220
+ ok(`Installed ${devPackages.join(", ")}`);
221
+ // The install just rewrote package.json (devDependencies). Re-read it, or the
222
+ // scripts we add below would be written over a stale copy and erase those entries.
223
+ pkg = await readJson(pkgPath);
289
224
  }
290
225
 
291
226
  const biomeJsonPath = join(cwd, "biome.json");
@@ -302,7 +237,7 @@ if (existsSync(biomeJsonPath) || existsSync(biomeJsoncPath)) {
302
237
  if (preset === "next") {
303
238
  config.files = { includes: ["**", "!**/.next", "!**/out", "!**/next-env.d.ts"] };
304
239
  }
305
- await Bun.write(biomeJsonPath, `${JSON.stringify(config, null, 2)}\n`);
240
+ await writeFile(biomeJsonPath, `${JSON.stringify(config, null, 2)}\n`);
306
241
  ok(`Wrote biome.json (extends ${extendsSpecifier})`);
307
242
  }
308
243
 
@@ -340,7 +275,7 @@ if (!values["no-hooks"]) {
340
275
  }
341
276
 
342
277
  if (pkgChanged) {
343
- await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
278
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
344
279
  }
345
280
 
346
281
  if (!values["no-hooks"]) {
@@ -355,13 +290,29 @@ if (!values["no-hooks"]) {
355
290
 
356
291
  for (const hook of hooks) {
357
292
  const hookPath = join(huskyDir, hook);
293
+ const template = await readFile(join(KIT_DIR, "templates", hook), "utf8");
358
294
  if (existsSync(hookPath)) {
359
- warn(`.husky/${hook} already exists — left untouched`);
295
+ const existing = await readFile(hookPath, "utf8");
296
+ if (hook === "pre-commit" && isLegacyPreCommit(existing)) {
297
+ // The pre-2.6 hook re-staged files with `git update-index --again`, which also
298
+ // stages unstaged hunks of partially staged files. It's ours and untouched, so replace it.
299
+ await writeFile(hookPath, template);
300
+ await chmod(hookPath, 0o755);
301
+ ok("Replaced .husky/pre-commit (the old one could stage changes you hadn't staged)");
302
+ wroteHook = true;
303
+ } else if (hook === "pre-commit" && hasUnsafeRestaging(existing)) {
304
+ warn(
305
+ ".husky/pre-commit runs `git update-index --again`, which can stage changes you didn't stage. " +
306
+ "Replace its contents with node_modules/lintlane/templates/pre-commit (or delete it and re-run init).",
307
+ );
308
+ } else {
309
+ warn(`.husky/${hook} already exists — left untouched`);
310
+ }
360
311
  continue;
361
312
  }
362
- await $`mkdir -p ${huskyDir}`.quiet();
363
- await Bun.write(hookPath, await Bun.file(join(KIT_DIR, "templates", hook)).text());
364
- await $`chmod 755 ${hookPath}`.quiet();
313
+ await mkdir(huskyDir, { recursive: true });
314
+ await writeFile(hookPath, template);
315
+ await chmod(hookPath, 0o755);
365
316
  ok(`Wrote .husky/${hook}`);
366
317
  wroteHook = true;
367
318
  }
@@ -372,12 +323,9 @@ if (!values["no-hooks"]) {
372
323
  } else {
373
324
  const husky = binPath(cwd, "husky");
374
325
  if (existsSync(husky)) {
375
- try {
376
- await $`${husky}`.cwd(cwd).quiet();
377
- ok("Ran husky");
378
- } catch (error) {
379
- warn(`husky failed: ${error.message}`);
380
- }
326
+ const ran = await runProcess(husky, [], { cwd });
327
+ if (ran.code === 0) ok("Ran husky");
328
+ else warn(`husky failed: ${(ran.stderr.toString() || ran.error?.message || "").trim()}`);
381
329
  } else {
382
330
  warn(`husky binary not found at ${husky} — run it manually once installed`);
383
331
  }
@@ -392,35 +340,9 @@ if (!values["no-ci"]) {
392
340
  if (existsSync(ciPath)) {
393
341
  warn(".github/workflows/biome.yml already exists — left untouched");
394
342
  } else {
395
- // Catches commits that skipped the local hooks (--no-verify, GUI clients, fresh clones).
396
343
  const withCommitCheck = !values["no-commit-lint"] && !values["no-hooks"];
397
- const commitCheckCheckout = withCommitCheck ? "\n with:\n fetch-depth: 0" : "";
398
- const commitCheckStep = withCommitCheck
399
- ? [
400
- "",
401
- " - name: Check commit messages",
402
- " if: github.event_name == 'pull_request'",
403
- " run: node node_modules/lintlane/bin/check-commit.mjs --rev-range origin/${{ github.base_ref }}..HEAD",
404
- ].join("\n")
405
- : "";
406
- const workflow = `name: Biome
407
-
408
- on:
409
- pull_request:
410
- push:
411
- branches:
412
- - main
413
-
414
- jobs:
415
- biome:
416
- runs-on: ubuntu-latest
417
- steps:
418
- - uses: actions/checkout@v4${commitCheckCheckout}
419
- ${pm.setupSteps.join("\n")}${commitCheckStep}
420
- - run: ${pm.runCheckCi}
421
- `;
422
- await $`mkdir -p ${workflowsDir}`.quiet();
423
- await Bun.write(ciPath, workflow);
344
+ await mkdir(workflowsDir, { recursive: true });
345
+ await writeFile(ciPath, renderWorkflow({ pmName, kitName: KIT_NAME, withCommitCheck }));
424
346
  ok("Wrote .github/workflows/biome.yml");
425
347
  }
426
348
  }
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ // git `pre-commit` hook entry: formats and lints the staged files with Biome
3
+ // without ever staging anything the developer didn't stage (see staged.mjs).
4
+ //
5
+ // node pre-commit.mjs run the checks (what the hook does)
6
+ // node pre-commit.mjs --restore recover working files after an interrupted run
7
+ import { resolveBiome, runProcess } from "./proc.mjs";
8
+ import { restoreFromBackup, runStagedChecks, StaleBackupError } from "./staged.mjs";
9
+
10
+ const out = (text) => process.stdout.write(text);
11
+ const err = (text) => process.stderr.write(text);
12
+
13
+ const top = await runProcess("git", ["rev-parse", "--show-toplevel"]);
14
+ if (top.code !== 0) {
15
+ err("lintlane: not inside a git repository.\n");
16
+ process.exit(1);
17
+ }
18
+ const cwd = top.stdout.toString().trim();
19
+ process.chdir(cwd);
20
+
21
+ if (process.argv.includes("--restore")) {
22
+ const { restored, kept, found, backupDir } = await restoreFromBackup(cwd);
23
+ if (!found) out("lintlane: nothing to restore.\n");
24
+ for (const path of restored) out(`restored ${path}\n`);
25
+ for (const path of kept) err(`kept ${path}: it changed since the backup (see ${backupDir})\n`);
26
+ process.exit(kept.length > 0 ? 1 : 0);
27
+ }
28
+
29
+ const biome = resolveBiome(cwd);
30
+ if (!biome) {
31
+ err("lintlane: Biome isn't installed. Run your package manager's install, then commit again.\n");
32
+ process.exit(1);
33
+ }
34
+
35
+ try {
36
+ const result = await runStagedChecks({
37
+ cwd,
38
+ biome,
39
+ colors: process.stderr.isTTY ? "force" : "off",
40
+ log: { out, err },
41
+ });
42
+ for (const warning of result.warnings) err(`lintlane: ${warning}\n`);
43
+ process.exit(result.ok ? 0 : 1);
44
+ } catch (error) {
45
+ if (error instanceof StaleBackupError) {
46
+ err(
47
+ `\nlintlane: an earlier run was interrupted before it could restore your files.\n` +
48
+ ` Your working copies are saved in ${error.backupDir}\n` +
49
+ ` Put them back with: node node_modules/lintlane/bin/pre-commit.mjs --restore\n\n`,
50
+ );
51
+ } else {
52
+ err(`lintlane: ${error.message}\n`);
53
+ }
54
+ process.exit(1);
55
+ }