lintlane 2.5.0 → 4.0.0

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/cli.mjs CHANGED
@@ -1,68 +1,31 @@
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 { resolveChecks } from "./checks.mjs";
8
+ import { binPath, resolveBiome, runInherit, runProcess } from "./proc.mjs";
9
+ import {
10
+ COMMITIZEN_PATH,
11
+ classifyHook,
12
+ detectPackageManager,
13
+ detectPreset,
14
+ hasCommitlintConfig,
15
+ hasLintStagedConfig,
16
+ installCommand,
17
+ LINT_STAGED_CONFIG,
18
+ PRESET_PATHS,
19
+ renderWorkflow,
20
+ runnerCommand,
21
+ } from "./project.mjs";
22
+
23
+ const KIT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
24
+ const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
25
+ const KIT_PKG = await readJson(join(KIT_DIR, "package.json"));
9
26
  const KIT_NAME = KIT_PKG.name;
10
27
  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
- };
28
+ const RUNNER = runnerCommand();
66
29
 
67
30
  const BIOME_SUBCOMMANDS = {
68
31
  check: (paths) => ["check", ...paths],
@@ -70,29 +33,21 @@ const BIOME_SUBCOMMANDS = {
70
33
  ci: (paths) => ["ci", "--reporter=github", ...paths],
71
34
  };
72
35
 
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]
36
+ const HELP = `Usage: ${RUNNER} ${KIT_NAME} <command> [options]
85
37
 
86
38
  Commands:
87
39
  init [options] Set up Biome, scripts, hooks, and CI in the current project
88
40
  check [paths] Run biome check (defaults to .)
89
41
  check:fix [paths] Run biome check --write (defaults to .)
90
42
  ci [paths] Run biome ci --reporter=github (defaults to .)
91
- commit [options] Guided commit (alias: c): asks step by step, then commits as a
92
- Conventional Commit. Flags: --dry-run, --retry, --no-retry,
93
- --write-message-to-file <f>, -l <n>; git flags go after --
94
- check-commit Validate commit messages (--message, --commit-msg-file,
95
- --rev-range, -d, --allow-abort, --allowed-prefixes, -l)
43
+ run [checks] Run the project's checks (Biome, typecheck, build, tests) in
44
+ order, the same list CI and the dashboard use. Flags:
45
+ --keep-going (don't stop at the first failure), --list
46
+ doctor [--json] Diagnose the setup: tools, hooks, configs, CI. Read-only
47
+ commit [options] Guided Conventional Commit (alias: c), run by Commitizen;
48
+ git flags go after --
49
+ check-commit Validate commit messages with commitlint (--message,
50
+ --commit-msg-file, --rev-range, -d)
96
51
  ui [options] Open the local dashboard: run checks, review errors and
97
52
  proposed fixes as a diff, apply fixes per file
98
53
 
@@ -105,6 +60,7 @@ init options:
105
60
  --no-hooks Skip husky + pre-commit hook setup
106
61
  --no-commit-lint Skip the commit-message hooks (commit-msg, prepare-commit-msg, pre-push)
107
62
  --no-ci Skip the GitHub Actions workflow
63
+ --dry-run Show everything init would do; change nothing
108
64
  --skip-install Print install commands instead of running them
109
65
  -h, --help Show this help
110
66
 
@@ -116,13 +72,14 @@ auto-selected — pass --preset strict explicitly when you want it.
116
72
 
117
73
  check / check:fix / ci run the Biome binary already installed in this
118
74
  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\`.
75
+ package manager (npm, pnpm, yarn, or bun) used by \`init\` is detected from
76
+ the "packageManager" field in package.json, then from whichever lockfile is
77
+ present, then from the tool you launched lintlane with (npx, pnpm dlx, yarn
78
+ dlx, bunx). npm is used when none of those apply.
79
+
80
+ After \`init\` the \`lintlane\` command is installed in your project, for
81
+ example: \`npx lintlane check\`, \`pnpm exec lintlane check\`,
82
+ \`yarn lintlane check\`, \`bunx lintlane check\`.
126
83
  `;
127
84
 
128
85
  function fail(message) {
@@ -138,32 +95,9 @@ function warn(message) {
138
95
  console.log(`! ${message}`);
139
96
  }
140
97
 
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
98
  // Everything after a bare "--" belongs to the subcommand (e.g. `commit -- -a -S`
165
99
  // hands "-a -S" to git commit), so it never reaches our own option parsing.
166
- const rawArgs = Bun.argv.slice(2);
100
+ const rawArgs = process.argv.slice(2);
167
101
  const doubleDash = rawArgs.indexOf("--");
168
102
  const ownArgs = doubleDash < 0 ? rawArgs : rawArgs.slice(0, doubleDash);
169
103
  const passthroughArgs = doubleDash < 0 ? [] : rawArgs.slice(doubleDash + 1);
@@ -176,6 +110,10 @@ const { values, positionals } = parseArgs({
176
110
  "no-ci": { type: "boolean", default: false },
177
111
  "no-commit-lint": { type: "boolean", default: false },
178
112
  "skip-install": { type: "boolean", default: false },
113
+ "dry-run": { type: "boolean", default: false },
114
+ json: { type: "boolean", default: false },
115
+ "keep-going": { type: "boolean", default: false },
116
+ list: { type: "boolean", default: false },
179
117
  port: { type: "string" },
180
118
  "no-open": { type: "boolean", default: false },
181
119
  help: { type: "boolean", short: "h", default: false },
@@ -195,18 +133,13 @@ if (values.help && !["commit", "c", "check-commit"].includes(command)) {
195
133
  }
196
134
 
197
135
  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`);
136
+ const biome = resolveBiome(cwd);
137
+ if (!biome) {
138
+ fail(`Biome isn't installed in this project. Run: ${RUNNER} ${KIT_NAME} init`);
201
139
  }
202
140
  const extraPaths = positionals.slice(1);
203
141
  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);
142
+ process.exit(await runInherit(biome[0], [...biome.slice(1), ...biomeArgs], { cwd }));
210
143
  }
211
144
 
212
145
  if (command === "commit" || command === "c") {
@@ -219,29 +152,98 @@ if (command === "check-commit") {
219
152
  process.exit(await main(commandArgs, cwd));
220
153
  }
221
154
 
155
+ if (command === "doctor") {
156
+ const { formatReport, runDoctor } = await import("./doctor.mjs");
157
+ const report = await runDoctor({ cwd, kitDir: KIT_DIR });
158
+ console.log(values.json ? JSON.stringify(report, null, 2) : formatReport(report));
159
+ process.exit(report.ok ? 0 : 1);
160
+ }
161
+
162
+ if (command === "hook") {
163
+ const { runHook } = await import("./hook.mjs");
164
+ const at = rawArgs.indexOf("hook");
165
+ const [name, ...hookArgs] = rawArgs.slice(at + 1);
166
+ process.exit(await runHook(name, hookArgs, cwd));
167
+ }
168
+
169
+ if (command === "run") {
170
+ const biome = resolveBiome(cwd);
171
+ if (!biome) fail(`Biome isn't installed in this project. Run: ${RUNNER} ${KIT_NAME} init`);
172
+ const pkg = existsSync(join(cwd, "package.json"))
173
+ ? await readJson(join(cwd, "package.json"))
174
+ : {};
175
+ const { steps, problems } = resolveChecks({
176
+ cwd,
177
+ biome,
178
+ pmName: detectPackageManager(cwd, pkg).name,
179
+ pkg,
180
+ });
181
+ for (const problem of problems) warn(problem);
182
+ if (values.list) {
183
+ for (const step of steps) {
184
+ console.log(`${step.id.padEnd(10)} ${step.defaultOn ? "on " : "off"} ${step.display}`);
185
+ }
186
+ process.exit(0);
187
+ }
188
+ const wanted = positionals.slice(1);
189
+ const unknown = wanted.filter((id) => !steps.some((step) => step.id === id));
190
+ if (unknown.length)
191
+ fail(`Unknown check: ${unknown.join(", ")}. Available: ${steps.map((st) => st.id).join(", ")}`);
192
+ const chosen = wanted.length
193
+ ? steps.filter((step) => wanted.includes(step.id))
194
+ : steps.filter((step) => step.defaultOn);
195
+ if (chosen.length === 0) fail("No checks to run.");
196
+ const { runCiSteps } = await import("./checks.mjs");
197
+ const results = [];
198
+ const byId = new Map(steps.map((step) => [step.id, step]));
199
+ await runCiSteps({
200
+ cwd,
201
+ steps: chosen,
202
+ stopOnFail: !values["keep-going"],
203
+ send: (event) => {
204
+ if (event.type === "start")
205
+ console.log(`▶ ${byId.get(event.id).label} (${byId.get(event.id).display})`);
206
+ else if (event.type === "log") process.stdout.write(event.text);
207
+ else if (event.type === "end") {
208
+ results.push(event);
209
+ console.log(
210
+ `${event.code === 0 ? "✔" : "✘"} ${byId.get(event.id).label} (${(event.ms / 1000).toFixed(1)}s)`,
211
+ );
212
+ } else if (event.type === "skip") console.log(`- skipped ${byId.get(event.id).label}`);
213
+ },
214
+ });
215
+ process.exit(
216
+ results.every((result) => result.code === 0) && results.length === chosen.length ? 0 : 1,
217
+ );
218
+ }
219
+
222
220
  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`);
221
+ const biome = resolveBiome(cwd);
222
+ if (!biome) {
223
+ fail(`Biome isn't installed in this project. Run: ${RUNNER} ${KIT_NAME} init`);
226
224
  }
227
225
  const { startUi } = await import("./ui-server.mjs");
228
226
  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
- });
227
+ const uiPkg = existsSync(uiPkgPath) ? await readJson(uiPkgPath) : {};
228
+ try {
229
+ await startUi({
230
+ cwd,
231
+ biome,
232
+ pkg: uiPkg,
233
+ pmName: detectPackageManager(cwd, uiPkg).name,
234
+ kitDir: KIT_DIR,
235
+ port: values.port === undefined ? 4321 : Number(values.port),
236
+ open: !values["no-open"],
237
+ });
238
+ } catch (error) {
239
+ fail(error.message);
240
+ }
239
241
  await new Promise(() => {});
240
242
  }
241
243
 
242
244
  if (command !== "init") {
243
245
  fail(
244
- `Unknown command "${command}". Usage: bunx ${KIT_NAME} <init|check|check:fix|ci|commit|check-commit|ui> [options]`,
246
+ `Unknown command "${command}". Usage: ${RUNNER} ${KIT_NAME} <init|check|check:fix|ci|run|doctor|commit|check-commit|ui> [options]`,
245
247
  );
246
248
  }
247
249
 
@@ -256,10 +258,9 @@ if (!existsSync(pkgPath)) {
256
258
  fail("No package.json found in the current directory.");
257
259
  }
258
260
 
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}`);
261
+ let pkg = await readJson(pkgPath);
262
+ const { name: pmName, source: pmSource } = detectPackageManager(cwd, pkg);
263
+ ok(`Detected package manager: ${pmName} (${pmSource})`);
263
264
 
264
265
  let preset = values.preset;
265
266
  if (preset === undefined) {
@@ -272,20 +273,53 @@ if (preset === undefined) {
272
273
  }
273
274
  }
274
275
 
275
- const devPackages = ["@biomejs/biome", `${KIT_NAME}@${KIT_VERSION}`];
276
- if (!values["no-hooks"]) devPackages.push("husky");
277
- const installArgv = pm.install(devPackages);
278
- const installCmd = installArgv.join(" ");
276
+ const dryRun = values["dry-run"];
277
+ if (dryRun) console.log("Dry run: nothing will be changed.\n");
278
+
279
+ // Every change goes through act(): in a dry run it only says what it would do.
280
+ async function act(message, fn) {
281
+ if (dryRun) {
282
+ console.log(`+ would ${message}`);
283
+ return;
284
+ }
285
+ await fn();
286
+ ok(message[0].toUpperCase() + message.slice(1));
287
+ }
279
288
 
280
- if (values["skip-install"]) {
281
- warn(`Skipped install. Run: ${installCmd}`);
289
+ // LINTLANE_INSTALL_SPEC lets you install lintlane from somewhere other than the
290
+ // registry (a tarball from `npm pack`, a local folder) when testing a build.
291
+ const kitSpec = process.env.LINTLANE_INSTALL_SPEC || `${KIT_NAME}@${KIT_VERSION}`;
292
+ const declared = { ...pkg.dependencies, ...pkg.devDependencies };
293
+ // Only install what's missing, so an existing Biome or husky version is never bumped.
294
+ const devPackages = [];
295
+ if (!("@biomejs/biome" in declared)) devPackages.push("@biomejs/biome");
296
+ if (process.env.LINTLANE_INSTALL_SPEC || declared[KIT_NAME] !== KIT_VERSION)
297
+ devPackages.push(kitSpec);
298
+ if (!values["no-hooks"] && !("husky" in declared)) devPackages.push("husky");
299
+
300
+ if (devPackages.length === 0) {
301
+ ok("Dependencies already installed");
302
+ } else if (values["skip-install"] || dryRun) {
303
+ const argv = installCommand(pmName, devPackages).join(" ");
304
+ if (dryRun) console.log(`+ would run: ${argv}`);
305
+ else warn(`Skipped install. Run: ${argv}`);
282
306
  } else {
283
- try {
284
- await $`${installArgv}`.cwd(cwd).quiet();
285
- ok(`Installed ${devPackages.join(", ")}`);
286
- } catch (error) {
287
- fail(`Install failed: ${error.message}`);
307
+ const installArgv = installCommand(pmName, devPackages);
308
+ const install = await runProcess(installArgv[0], installArgv.slice(1), { cwd });
309
+ if (install.code !== 0) {
310
+ const reason =
311
+ install.error?.code === "ENOENT"
312
+ ? `"${installArgv[0]}" was not found. Install it, or run again with --skip-install and install the packages yourself:\n ${installArgv.join(" ")}`
313
+ : (install.stderr.toString().trim() || install.stdout.toString().trim())
314
+ .split("\n")
315
+ .slice(-8)
316
+ .join("\n");
317
+ fail(`Install failed (${installArgv.join(" ")}):\n${reason}`);
288
318
  }
319
+ ok(`Installed ${devPackages.join(", ")}`);
320
+ // The install just rewrote package.json (devDependencies). Re-read it, or the
321
+ // scripts we add below would be written over a stale copy and erase those entries.
322
+ pkg = await readJson(pkgPath);
289
323
  }
290
324
 
291
325
  const biomeJsonPath = join(cwd, "biome.json");
@@ -293,7 +327,7 @@ const biomeJsoncPath = join(cwd, "biome.jsonc");
293
327
  const extendsSpecifier = `${KIT_NAME}/${PRESET_PATHS[preset]}`;
294
328
 
295
329
  if (existsSync(biomeJsonPath) || existsSync(biomeJsoncPath)) {
296
- warn(`biome.json already exists — add this to its "extends": "${extendsSpecifier}"`);
330
+ warn(`biome.json already exists — make sure its "extends" includes "${extendsSpecifier}"`);
297
331
  } else {
298
332
  const config = {
299
333
  $schema: "./node_modules/@biomejs/biome/configuration_schema.json",
@@ -302,45 +336,86 @@ if (existsSync(biomeJsonPath) || existsSync(biomeJsoncPath)) {
302
336
  if (preset === "next") {
303
337
  config.files = { includes: ["**", "!**/.next", "!**/out", "!**/next-env.d.ts"] };
304
338
  }
305
- await Bun.write(biomeJsonPath, `${JSON.stringify(config, null, 2)}\n`);
306
- ok(`Wrote biome.json (extends ${extendsSpecifier})`);
339
+ await act(`write biome.json (extends ${extendsSpecifier})`, () =>
340
+ writeFile(biomeJsonPath, `${JSON.stringify(config, null, 2)}\n`),
341
+ );
307
342
  }
308
343
 
344
+ // package.json: scripts, lint-staged, Commitizen. Only ever adds what's missing.
309
345
  pkg.scripts ??= {};
310
- let pkgChanged = false;
346
+ const pkgEdits = [];
347
+ const addScript = (name, value) => {
348
+ if (!pkg.scripts[name]) {
349
+ pkgEdits.push(() => {
350
+ pkg.scripts[name] = value;
351
+ });
352
+ return `add script "${name}"`;
353
+ }
354
+ warn(`Script "${name}" already exists — left untouched`);
355
+ return null;
356
+ };
357
+ const pending = [
358
+ addScript("check", "biome check ."),
359
+ addScript("check:fix", "biome check --write ."),
360
+ ];
311
361
 
312
- if (!pkg.scripts.check) {
313
- pkg.scripts.check = "biome check .";
314
- pkgChanged = true;
315
- ok('Added script "check"');
316
- } else {
317
- warn('Script "check" already exists — left untouched');
318
- }
362
+ if (!values["no-hooks"]) {
363
+ if (!pkg.scripts.prepare) pending.push(addScript("prepare", "husky"));
364
+ else if (!pkg.scripts.prepare.includes("husky")) {
365
+ pkgEdits.push(() => {
366
+ pkg.scripts.prepare = `${pkg.scripts.prepare} && husky`;
367
+ });
368
+ pending.push('append husky to the existing "prepare" script');
369
+ } else {
370
+ warn('Script "prepare" already runs husky — left untouched');
371
+ }
319
372
 
320
- if (!pkg.scripts["check:fix"]) {
321
- pkg.scripts["check:fix"] = "biome check --write .";
322
- pkgChanged = true;
323
- ok('Added script "check:fix"');
324
- } else {
325
- warn('Script "check:fix" already exists — left untouched');
373
+ if (hasLintStagedConfig(cwd, pkg)) {
374
+ warn("lint-staged is already configured — left untouched");
375
+ } else {
376
+ pkgEdits.push(() => {
377
+ pkg["lint-staged"] = LINT_STAGED_CONFIG;
378
+ });
379
+ pending.push('add "lint-staged" config (Biome on staged files)');
380
+ }
326
381
  }
327
382
 
328
- if (!values["no-hooks"]) {
329
- if (!pkg.scripts.prepare) {
330
- pkg.scripts.prepare = "husky";
331
- pkgChanged = true;
332
- ok('Added script "prepare"');
333
- } else if (!pkg.scripts.prepare.includes("husky")) {
334
- pkg.scripts.prepare = `${pkg.scripts.prepare} && husky`;
335
- pkgChanged = true;
336
- ok('Appended husky to existing "prepare" script');
383
+ if (!values["no-commit-lint"]) {
384
+ if (pkg.config?.commitizen?.path) {
385
+ warn("Commitizen is already configured — left untouched");
337
386
  } else {
338
- warn('Script "prepare" already runs husky — left untouched');
387
+ pkgEdits.push(() => {
388
+ pkg.config = {
389
+ ...pkg.config,
390
+ commitizen: { ...pkg.config?.commitizen, path: COMMITIZEN_PATH },
391
+ };
392
+ });
393
+ pending.push('add "config.commitizen" (guided commits)');
339
394
  }
340
395
  }
341
396
 
342
- if (pkgChanged) {
343
- await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
397
+ for (const message of pending.filter(Boolean)) {
398
+ if (dryRun) console.log(`+ would ${message} in package.json`);
399
+ }
400
+ if (pkgEdits.length > 0 && !dryRun) {
401
+ for (const edit of pkgEdits) edit();
402
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
403
+ for (const message of pending.filter(Boolean))
404
+ ok(`${message[0].toUpperCase()}${message.slice(1)}`);
405
+ }
406
+
407
+ // commitlint config
408
+ if (!values["no-commit-lint"]) {
409
+ if (hasCommitlintConfig(cwd, pkg)) {
410
+ warn("commitlint is already configured — left untouched");
411
+ } else {
412
+ await act("write commitlint.config.mjs", () =>
413
+ writeFile(
414
+ join(cwd, "commitlint.config.mjs"),
415
+ 'export { default } from "lintlane/commitlint";\n',
416
+ ),
417
+ );
418
+ }
344
419
  }
345
420
 
346
421
  if (!values["no-hooks"]) {
@@ -355,15 +430,34 @@ if (!values["no-hooks"]) {
355
430
 
356
431
  for (const hook of hooks) {
357
432
  const hookPath = join(huskyDir, hook);
358
- if (existsSync(hookPath)) {
359
- warn(`.husky/${hook} already exists — left untouched`);
433
+ const template = await readFile(join(KIT_DIR, "templates", hook), "utf8");
434
+ const writeHook = async () => {
435
+ await mkdir(huskyDir, { recursive: true });
436
+ await writeFile(hookPath, template);
437
+ await chmod(hookPath, 0o755);
438
+ wroteHook = true;
439
+ };
440
+ if (!existsSync(hookPath)) {
441
+ await act(`write .husky/${hook}`, writeHook);
360
442
  continue;
361
443
  }
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();
365
- ok(`Wrote .husky/${hook}`);
366
- wroteHook = true;
444
+ const state = classifyHook(hook, await readFile(hookPath, "utf8"), template);
445
+ if (state === "current") {
446
+ ok(`.husky/${hook} is up to date`);
447
+ } else if (state === "legacy") {
448
+ // Untouched hook from an older lintlane; the files it called are gone.
449
+ await act(`upgrade .husky/${hook} (the old one is from an earlier lintlane)`, writeHook);
450
+ } else if (state === "stale" || state === "unsafe") {
451
+ warn(
452
+ `.husky/${hook} was edited and still calls files lintlane no longer ships` +
453
+ (state === "unsafe"
454
+ ? " (and `git update-index --again` can stage changes you didn't stage)"
455
+ : "") +
456
+ `. Replace its contents with node_modules/lintlane/templates/${hook}.`,
457
+ );
458
+ } else {
459
+ warn(`.husky/${hook} already exists — left untouched`);
460
+ }
367
461
  }
368
462
 
369
463
  if (wroteHook) {
@@ -372,16 +466,15 @@ if (!values["no-hooks"]) {
372
466
  } else {
373
467
  const husky = binPath(cwd, "husky");
374
468
  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
- }
469
+ const ran = await runProcess(husky, [], { cwd });
470
+ if (ran.code === 0) ok("Ran husky");
471
+ else warn(`husky failed: ${(ran.stderr.toString() || ran.error?.message || "").trim()}`);
381
472
  } else {
382
473
  warn(`husky binary not found at ${husky} — run it manually once installed`);
383
474
  }
384
475
  }
476
+ } else if (dryRun) {
477
+ console.log("+ would run husky to point git at .husky");
385
478
  }
386
479
  }
387
480
  }
@@ -390,39 +483,25 @@ if (!values["no-ci"]) {
390
483
  const workflowsDir = join(cwd, ".github", "workflows");
391
484
  const ciPath = join(workflowsDir, "biome.yml");
392
485
  if (existsSync(ciPath)) {
393
- warn(".github/workflows/biome.yml already exists — left untouched");
486
+ warn(
487
+ ".github/workflows/biome.yml already exists — left untouched (run `lintlane doctor` to compare it with your checks)",
488
+ );
394
489
  } else {
395
- // Catches commits that skipped the local hooks (--no-verify, GUI clients, fresh clones).
396
490
  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);
424
- ok("Wrote .github/workflows/biome.yml");
491
+ const biomeArgv = resolveBiome(cwd) ?? ["biome"];
492
+ const { steps: checks } = resolveChecks({ cwd, biome: biomeArgv, pmName, pkg });
493
+ await act("write .github/workflows/biome.yml", async () => {
494
+ await mkdir(workflowsDir, { recursive: true });
495
+ await writeFile(
496
+ ciPath,
497
+ renderWorkflow({ pmName, kitName: KIT_NAME, withCommitCheck, checks }),
498
+ );
499
+ });
425
500
  }
426
501
  }
427
502
 
428
- console.log(`\nRun: ${pmName} run check:fix`);
503
+ console.log(
504
+ dryRun
505
+ ? "\nDry run finished. Run again without --dry-run to apply."
506
+ : `\nRun: ${pmName} run check:fix (and \`${RUNNER} ${KIT_NAME} doctor\` to verify the setup)`,
507
+ );