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/proc.mjs ADDED
@@ -0,0 +1,132 @@
1
+ // Small process helpers built on node:child_process, so the CLI runs on plain
2
+ // Node.js (no Bun APIs). Nothing here goes through a shell on POSIX; arguments
3
+ // are always passed as an array.
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { createRequire } from "node:module";
7
+ import { dirname, join } from "node:path";
8
+
9
+ const isWindows = process.platform === "win32";
10
+
11
+ /** Path of a binary in the project's node_modules/.bin (a .cmd shim on Windows). */
12
+ export function binPath(cwd, name) {
13
+ return join(cwd, "node_modules", ".bin", isWindows ? `${name}.cmd` : name);
14
+ }
15
+
16
+ /**
17
+ * The command (as an argv prefix) that runs this project's Biome, or null.
18
+ * Prefers the project's own node_modules/.bin/biome; falls back to the Biome
19
+ * that resolves from lintlane itself (its peer dependency).
20
+ */
21
+ export function resolveBiome(cwd) {
22
+ const local = binPath(cwd, "biome");
23
+ if (existsSync(local)) return [local];
24
+ try {
25
+ const script = createRequire(import.meta.url).resolve("@biomejs/biome/bin/biome");
26
+ return [process.execPath, script];
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The argv that runs a binary shipped by one of lintlane's own dependencies
34
+ * (lint-staged, commitlint, commitizen). Resolved from lintlane's location, so it
35
+ * works with every package manager, including pnpm's strict node_modules layout.
36
+ * Returns null if the package isn't installed.
37
+ */
38
+ export function resolveTool(pkgName, binName = pkgName) {
39
+ try {
40
+ const require = createRequire(import.meta.url);
41
+ const manifestPath = require.resolve(`${pkgName}/package.json`);
42
+ const manifest = require(manifestPath);
43
+ const rel = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[binName];
44
+ if (!rel) return null;
45
+ return [process.execPath, join(dirname(manifestPath), rel)];
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // Windows can't spawn .cmd shims (npm, pnpm, yarn, node_modules/.bin/*) without
52
+ // a shell. git and node are real executables and are spawned directly.
53
+ function prepare(command, args) {
54
+ const direct = !isWindows || /(^|[\\/])(git|node)(\.exe)?$/i.test(command);
55
+ if (direct) return { command, args, options: {} };
56
+ const quote = (arg) => (/[\s"&|<>^()]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg);
57
+ return {
58
+ command: [command, ...args].map(quote).join(" "),
59
+ args: [],
60
+ options: { shell: true },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Run a command and collect its output. Never rejects: a command that can't be
66
+ * started resolves with `error` set and code 127.
67
+ */
68
+ export function runProcess(command, args = [], { cwd, input, env } = {}) {
69
+ return new Promise((resolve) => {
70
+ const prepared = prepare(command, args);
71
+ const stdout = [];
72
+ const stderr = [];
73
+ let settled = false;
74
+ const finish = (result) => {
75
+ if (settled) return;
76
+ settled = true;
77
+ resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), ...result });
78
+ };
79
+
80
+ let child;
81
+ try {
82
+ child = spawn(prepared.command, prepared.args, {
83
+ cwd,
84
+ env: env ?? process.env,
85
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
86
+ ...prepared.options,
87
+ });
88
+ } catch (error) {
89
+ finish({ code: 127, error });
90
+ return;
91
+ }
92
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
93
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
94
+ child.on("error", (error) => finish({ code: 127, error }));
95
+ child.on("close", (code, signal) => finish({ code: code ?? (signal ? 128 : 1), signal }));
96
+ if (input !== undefined) {
97
+ child.stdin.on("error", () => {});
98
+ child.stdin.end(input);
99
+ }
100
+ });
101
+ }
102
+
103
+ /** Run a command with the terminal attached. Resolves with its exit code. */
104
+ export function runInherit(command, args = [], { cwd, env } = {}) {
105
+ return new Promise((resolve) => {
106
+ const prepared = prepare(command, args);
107
+ let child;
108
+ try {
109
+ child = spawn(prepared.command, prepared.args, {
110
+ cwd,
111
+ env: env ?? process.env,
112
+ stdio: "inherit",
113
+ ...prepared.options,
114
+ });
115
+ } catch (error) {
116
+ process.stderr.write(`✘ ${error.message}\n`);
117
+ resolve(127);
118
+ return;
119
+ }
120
+ child.on("error", (error) => {
121
+ process.stderr.write(`✘ ${error.message}\n`);
122
+ resolve(127);
123
+ });
124
+ child.on("close", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
125
+ });
126
+ }
127
+
128
+ /** Spawn for streaming callers (the CI tab); same Windows handling as above. */
129
+ export function spawnPrepared(command, args, options) {
130
+ const prepared = prepare(command, args);
131
+ return spawn(prepared.command, prepared.args, { ...options, ...prepared.options });
132
+ }
@@ -0,0 +1,276 @@
1
+ // Project detection and generated-file rendering used by `lintlane init`:
2
+ // package manager, framework preset, install commands, the CI workflow.
3
+ // Pure functions where possible, so they can be tested without a package manager.
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ export const PRESET_PATHS = {
8
+ base: "biome",
9
+ strict: "biome/strict",
10
+ next: "biome/next",
11
+ };
12
+
13
+ // Node 20 left maintenance in April 2026; the generated workflows use 22.
14
+ const NODE_VERSION = 22;
15
+
16
+ export const PACKAGE_MANAGERS = {
17
+ bun: {
18
+ lockfiles: ["bun.lock", "bun.lockb"],
19
+ install: (pkgs) => ["bun", "add", "--dev", "--exact", ...pkgs],
20
+ runCheckCi: "bunx biome ci --reporter=github .",
21
+ setupSteps: [
22
+ " - uses: oven-sh/setup-bun@v2",
23
+ " - run: bun install --frozen-lockfile",
24
+ ],
25
+ },
26
+ npm: {
27
+ lockfiles: ["package-lock.json", "npm-shrinkwrap.json"],
28
+ install: (pkgs) => ["npm", "install", "--save-dev", "--save-exact", ...pkgs],
29
+ runCheckCi: "npx biome ci --reporter=github .",
30
+ setupSteps: [
31
+ " - uses: actions/setup-node@v4",
32
+ " with:",
33
+ ` node-version: ${NODE_VERSION}`,
34
+ " cache: npm",
35
+ " - run: npm ci",
36
+ ],
37
+ },
38
+ pnpm: {
39
+ lockfiles: ["pnpm-lock.yaml"],
40
+ install: (pkgs) => ["pnpm", "add", "--save-dev", "--save-exact", ...pkgs],
41
+ runCheckCi: "pnpm exec biome ci --reporter=github .",
42
+ setupSteps: [
43
+ " - uses: pnpm/action-setup@v4",
44
+ " - uses: actions/setup-node@v4",
45
+ " with:",
46
+ ` node-version: ${NODE_VERSION}`,
47
+ " cache: pnpm",
48
+ " - run: pnpm install --frozen-lockfile",
49
+ ],
50
+ },
51
+ yarn: {
52
+ lockfiles: ["yarn.lock"],
53
+ install: (pkgs) => ["yarn", "add", "--dev", "--exact", ...pkgs],
54
+ runCheckCi: "yarn biome ci --reporter=github .",
55
+ setupSteps: [
56
+ " - uses: actions/setup-node@v4",
57
+ " with:",
58
+ ` node-version: ${NODE_VERSION}`,
59
+ " cache: yarn",
60
+ " - run: yarn install --frozen-lockfile",
61
+ ],
62
+ },
63
+ };
64
+
65
+ // How each package manager's "run a package binary" command is spelled.
66
+ const RUNNERS = { npm: "npx", pnpm: "pnpm dlx", yarn: "yarn dlx", bun: "bunx" };
67
+
68
+ /** "npm/10.8.0 node/v22.1.0 darwin arm64 ..." -> "npm" (null if unknown). */
69
+ export function parseUserAgent(userAgent) {
70
+ const name = String(userAgent ?? "")
71
+ .split(" ")[0]
72
+ ?.split("/")[0];
73
+ return name && name in PACKAGE_MANAGERS ? name : null;
74
+ }
75
+
76
+ /**
77
+ * Which package manager should install things in this project?
78
+ * In order: the `packageManager` field, a lockfile, the tool that launched us
79
+ * (npm_config_user_agent, set by npx / pnpm dlx / yarn dlx / bunx), then npm,
80
+ * which ships with Node.js. Returns { name, source }.
81
+ */
82
+ export function detectPackageManager(cwd, pkg, env = process.env) {
83
+ const declared = pkg?.packageManager?.split("@")[0];
84
+ if (declared && declared in PACKAGE_MANAGERS) {
85
+ return { name: declared, source: "the packageManager field in package.json" };
86
+ }
87
+ for (const [name, pm] of Object.entries(PACKAGE_MANAGERS)) {
88
+ const lockfile = pm.lockfiles.find((file) => existsSync(join(cwd, file)));
89
+ if (lockfile) return { name, source: lockfile };
90
+ }
91
+ const fromAgent = parseUserAgent(env.npm_config_user_agent);
92
+ if (fromAgent) return { name: fromAgent, source: "the command that launched lintlane" };
93
+ return { name: "npm", source: "default" };
94
+ }
95
+
96
+ /** The runner prefix to show in messages: "npx", "pnpm dlx", "yarn dlx" or "bunx". */
97
+ export function runnerCommand(env = process.env) {
98
+ return RUNNERS[parseUserAgent(env.npm_config_user_agent) ?? "npm"];
99
+ }
100
+
101
+ /** The argv that installs `packages` as exact dev dependencies. */
102
+ export function installCommand(pmName, packages) {
103
+ const pm = PACKAGE_MANAGERS[pmName];
104
+ if (!pm) throw new Error(`Unknown package manager "${pmName}"`);
105
+ return pm.install(packages);
106
+ }
107
+
108
+ // Checked in order; the first match wins. Only "next" has its own Biome
109
+ // lint domain — the others are still React under the hood, so they map to
110
+ // the "base" preset, but we name what we found instead of guessing silently.
111
+ const FRAMEWORK_DETECTORS = [
112
+ { label: "Next.js", preset: "next", deps: ["next"] },
113
+ { label: "TanStack Start", preset: "base", deps: ["@tanstack/react-start", "@tanstack/start"] },
114
+ { label: "React Router (framework mode)", preset: "base", deps: ["@react-router/dev"] },
115
+ { label: "Remix", preset: "base", deps: ["@remix-run/react", "@remix-run/node"] },
116
+ { label: "React", preset: "base", deps: ["react"] },
117
+ ];
118
+
119
+ export function detectPreset(pkg) {
120
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
121
+ for (const detector of FRAMEWORK_DETECTORS) {
122
+ if (detector.deps.some((dep) => dep in deps)) {
123
+ return { preset: detector.preset, label: detector.label };
124
+ }
125
+ }
126
+ return { preset: "base", label: null };
127
+ }
128
+
129
+ const RUN_SCRIPT_PREFIX = { npm: "npm run", pnpm: "pnpm run", yarn: "yarn run", bun: "bun run" };
130
+
131
+ /** The `run:` command a check has in the generated workflow, or null if it has none. */
132
+ export function ciCommand(pmName, step) {
133
+ if (step.id === "biome") return PACKAGE_MANAGERS[pmName].runCheckCi;
134
+ if (step.ci && step.script) return `${RUN_SCRIPT_PREFIX[pmName]} ${step.script}`;
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * The GitHub Actions workflow `init` writes. `checks` is the resolved list from
140
+ * checks.mjs, so the workflow runs exactly what the CLI and dashboard run.
141
+ * Without it, only Biome.
142
+ */
143
+ export function renderWorkflow({ pmName, kitName, withCommitCheck, checks }) {
144
+ const pm = PACKAGE_MANAGERS[pmName];
145
+ // Catches commits that skipped the local hooks (--no-verify, GUI clients, fresh clones).
146
+ const checkout = withCommitCheck ? "\n with:\n fetch-depth: 0" : "";
147
+ const commitStep = withCommitCheck
148
+ ? [
149
+ "",
150
+ " - name: Check commit messages",
151
+ " if: github.event_name == 'pull_request'",
152
+ ` run: node node_modules/${kitName}/bin/cli.mjs check-commit --rev-range origin/\${{ github.base_ref }}..HEAD`,
153
+ ].join("\n")
154
+ : "";
155
+ const commands = (checks ?? [{ id: "biome", ci: true }])
156
+ .map((step) => ciCommand(pmName, step))
157
+ .filter(Boolean);
158
+ const runSteps = commands.map((command) => ` - run: ${command}`).join("\n");
159
+ return `name: Biome
160
+
161
+ on:
162
+ pull_request:
163
+ push:
164
+ branches:
165
+ - main
166
+
167
+ jobs:
168
+ biome:
169
+ runs-on: ubuntu-latest
170
+ steps:
171
+ - uses: actions/checkout@v4${checkout}
172
+ ${pm.setupSteps.join("\n")}${commitStep}
173
+ ${runSteps}
174
+ `;
175
+ }
176
+
177
+ /**
178
+ * The pre-commit hook `init` used to write (<= 2.5.1). It re-staged files with
179
+ * `git update-index --again`, which also stages unstaged hunks of partially
180
+ * staged files. `init` replaces an untouched copy of it.
181
+ */
182
+ export function isLegacyPreCommit(content) {
183
+ const lines = content
184
+ .split("\n")
185
+ .map((line) => line.trim())
186
+ .filter(Boolean);
187
+ return (
188
+ lines.length === 2 &&
189
+ /^(\.\/)?node_modules\/\.bin\/biome check --write --staged\b/.test(lines[0]) &&
190
+ lines[1] === "git update-index --again"
191
+ );
192
+ }
193
+
194
+ /** A pre-commit hook that still has the unsafe re-staging step, but was edited. */
195
+ export function hasUnsafeRestaging(content) {
196
+ return /git\s+update-index\s+--again/.test(content);
197
+ }
198
+
199
+ // Hooks written by earlier versions of lintlane that did their own work in
200
+ // bin/*.mjs files which no longer exist. An untouched copy is safe to replace.
201
+ const LEGACY_HOOKS = {
202
+ "pre-commit": [
203
+ 'if [ -f node_modules/lintlane/bin/pre-commit.mjs ]; then\n node node_modules/lintlane/bin/pre-commit.mjs\nelse\n echo "lintlane: node_modules/lintlane not found; skipping staged-file checks. Run your package manager\'s install." >&2\nfi',
204
+ ],
205
+ "commit-msg": [
206
+ '[ -f node_modules/lintlane/bin/commit-msg.mjs ] || exit 0\nnode node_modules/lintlane/bin/commit-msg.mjs "$1"',
207
+ ],
208
+ "prepare-commit-msg": [
209
+ '[ -f node_modules/lintlane/bin/prepare-commit-msg.mjs ] || exit 0\nnode node_modules/lintlane/bin/prepare-commit-msg.mjs "$1" "$2"',
210
+ ],
211
+ "pre-push": [
212
+ '[ -f node_modules/lintlane/bin/pre-push-check.mjs ] || exit 0\nnode node_modules/lintlane/bin/pre-push-check.mjs "$1" "$2"',
213
+ ],
214
+ };
215
+
216
+ const REMOVED_ENTRYPOINTS =
217
+ /bin\/(pre-commit|commit-msg|prepare-commit-msg|pre-push-check|staged|commit-lint)\.mjs/;
218
+
219
+ /**
220
+ * What a hook file is, relative to this version of lintlane:
221
+ * "current" the shipped template
222
+ * "legacy" an untouched hook from an older lintlane (safe to replace)
223
+ * "stale" edited, but still calls files lintlane no longer ships (needs a human)
224
+ * "unsafe" edited, and re-stages with `git update-index --again`
225
+ * "custom" the project's own hook
226
+ */
227
+ export function classifyHook(name, content, template) {
228
+ const normalized = content.trim();
229
+ if (normalized === template.trim()) return "current";
230
+ if (name === "pre-commit" && isLegacyPreCommit(content)) return "legacy";
231
+ if ((LEGACY_HOOKS[name] ?? []).some((legacy) => legacy === normalized)) return "legacy";
232
+ if (REMOVED_ENTRYPOINTS.test(content)) return "stale";
233
+ if (name === "pre-commit" && hasUnsafeRestaging(content)) return "unsafe";
234
+ return "custom";
235
+ }
236
+
237
+ /** Does the project already configure lint-staged? (package.json key or a config file) */
238
+ export function hasLintStagedConfig(cwd, pkg) {
239
+ if (pkg?.["lint-staged"]) return true;
240
+ return [
241
+ ".lintstagedrc",
242
+ ".lintstagedrc.json",
243
+ ".lintstagedrc.yaml",
244
+ ".lintstagedrc.yml",
245
+ ".lintstagedrc.js",
246
+ ".lintstagedrc.cjs",
247
+ ".lintstagedrc.mjs",
248
+ "lint-staged.config.js",
249
+ "lint-staged.config.cjs",
250
+ "lint-staged.config.mjs",
251
+ ].some((file) => existsSync(join(cwd, file)));
252
+ }
253
+
254
+ /** Does the project already configure commitlint? */
255
+ export function hasCommitlintConfig(cwd, pkg) {
256
+ if (pkg?.commitlint) return true;
257
+ return [
258
+ ".commitlintrc",
259
+ ".commitlintrc.json",
260
+ ".commitlintrc.yaml",
261
+ ".commitlintrc.yml",
262
+ ".commitlintrc.js",
263
+ ".commitlintrc.cjs",
264
+ ".commitlintrc.mjs",
265
+ ".commitlintrc.ts",
266
+ "commitlint.config.js",
267
+ "commitlint.config.cjs",
268
+ "commitlint.config.mjs",
269
+ "commitlint.config.ts",
270
+ ].some((file) => existsSync(join(cwd, file)));
271
+ }
272
+
273
+ export const LINT_STAGED_CONFIG = {
274
+ "*": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",
275
+ };
276
+ export const COMMITIZEN_PATH = "lintlane/presets/commitizen.cjs";