Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lefthook-spawn-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ultracite": patch
---

Run `lefthook install` during `ultracite init` through the same spawn adapter as every other tool invocation instead of `execSync`, so it no longer goes through a shell and gets the same Windows command resolution as `husky`, the linters, and editor extension installs.
4 changes: 0 additions & 4 deletions packages/cli/__tests__/editor-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ mock.module("node:fs/promises", () => ({
writeFile: mock(() => Promise.resolve()),
}));

mock.module("node:child_process", () => ({
spawnSync: mock(() => ({ status: 0 })),
}));

describe("createEditorConfig", () => {
describe("invalid editor", () => {
test("throws error for invalid editor id", () => {
Expand Down
5 changes: 0 additions & 5 deletions packages/cli/__tests__/husky.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@ import { husky } from "../src/integrations/husky";

const npmPm: PackageManager = { command: "npm", name: "npm" };

mock.module("node:child_process", () => ({
execSync: mock(() => ""),
spawnSync: mock(() => ({ status: 0 })),
}));

mock.module("node:fs/promises", () => ({
access: mock(() => Promise.reject(new Error("ENOENT"))),
mkdir: mock(() => Promise.resolve()),
Expand Down
5 changes: 0 additions & 5 deletions packages/cli/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,6 @@ mock.module("node:fs/promises", () => ({
writeFile: mock(() => Promise.resolve()),
}));

mock.module("node:child_process", () => ({
execSync: mock(() => ""),
spawnSync: mock(() => ({ status: 0, stdout: "1.0.0" })),
}));

mock.module("node:fs", () => ({
existsSync: mock(() => false),
}));
Expand Down
32 changes: 6 additions & 26 deletions packages/cli/__tests__/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,6 @@ mock.module("node:fs/promises", () => ({
writeFile: mock(() => Promise.resolve()),
}));

mock.module("node:child_process", () => ({
execSync: mock(() => ""),
spawnSync: mock(() => ({ status: 0 })),
}));

mock.module("../src/spawn-sync", () => ({
spawnSync: mock(() => ({ status: 0, stdout: "[]" })),
}));
Expand Down Expand Up @@ -2425,11 +2420,6 @@ describe("helper functions", () => {
writeFile: mockWriteFile,
}));

mock.module("node:child_process", () => ({
execSync: mock(() => ""),
spawnSync: mock(() => ({ status: 0 })),
}));

mock.module("@clack/prompts", () => ({
spinner: mock(() => ({
message: mock(noop),
Expand Down Expand Up @@ -2481,12 +2471,12 @@ describe("helper functions", () => {
writeFile: mockWriteFile,
}));

// Mock extension install to throw error
mock.module("node:child_process", () => ({
execSync: mock(() => ""),
spawnSync: mock(() => {
throw new Error("Extension install failed");
}),
// Mock extension install failing to spawn
mock.module("../src/spawn-sync", () => ({
spawnSync: mock(() => ({
error: new Error("Extension install failed"),
status: null,
})),
}));

mock.module("@clack/prompts", () => ({
Expand Down Expand Up @@ -2998,11 +2988,6 @@ describe("helper functions", () => {
writeFile: mockWriteFile,
}));

mock.module("node:child_process", () => ({
execSync: mock(() => ""),
spawnSync: mock(() => ({ status: 0 })),
}));

mock.module("@clack/prompts", () => ({
spinner: mock(() => ({
message: mock(noop),
Expand Down Expand Up @@ -3038,11 +3023,6 @@ describe("helper functions", () => {
writeFile: mockWriteFile,
}));

mock.module("node:child_process", () => ({
execSync: mock(() => ""),
spawnSync: mock(() => ({ status: 0 })),
}));

mock.module("@clack/prompts", () => ({
spinner: mock(() => ({
message: mock(noop),
Expand Down
29 changes: 11 additions & 18 deletions packages/cli/__tests__/lefthook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import { lefthook } from "../src/integrations/lefthook";

const npmPm: PackageManager = { command: "npm", name: "npm" };

mock.module("node:child_process", () => ({
execSync: mock(() => ""),
mock.module("../src/spawn-sync", () => ({
spawnSync: mock(() => ({ status: 0 })),
}));

Expand Down Expand Up @@ -75,12 +74,6 @@ describe("lefthook", () => {
describe("install", () => {
test("installs lefthook dependency", async () => {
const mockAddDep = mock(() => Promise.resolve());
const mockExecSync = mock(() => "");

mock.module("node:child_process", () => ({
execSync: mockExecSync,
spawnSync: mock(() => ({ status: 0 })),
}));

mock.module("node:fs/promises", () => ({
access: mock(() => Promise.resolve()),
Expand Down Expand Up @@ -112,10 +105,9 @@ describe("lefthook", () => {
});

test("runs lefthook install command", async () => {
const mockExecSync = mock(() => "");
mock.module("node:child_process", () => ({
execSync: mockExecSync,
spawnSync: mock(() => ({ status: 0 })),
const mockSpawnSync = mock(() => ({ status: 0 }));
mock.module("../src/spawn-sync", () => ({
spawnSync: mockSpawnSync,
}));

mock.module("node:fs/promises", () => ({
Expand Down Expand Up @@ -144,7 +136,13 @@ describe("lefthook", () => {

await lefthook.install(npmPm);

expect(mockExecSync).toHaveBeenCalled();
expect(mockSpawnSync).toHaveBeenCalledWith(
"npx",
["lefthook", "install"],
{
stdio: "pipe",
}
);
});

test("adds prepare script to package.json", async () => {
Expand Down Expand Up @@ -179,11 +177,6 @@ describe("lefthook", () => {
removeDependency: mock(() => Promise.resolve()),
}));

mock.module("node:child_process", () => ({
execSync: mock(() => ""),
spawnSync: mock(() => ({ status: 0 })),
}));

await lefthook.install(npmPm);

expect(mockWriteFile).toHaveBeenCalled();
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/__tests__/oxlint-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ const JS_PLUGINS = [
* text-parsing version of this helper silently return nothing.
*/
const getOxlintRulesForPlugins = (plugins: string[]): string[] => {
// Bun.spawnSync rather than node:child_process — several test files
// install module-level mocks of node:child_process that leak across
// files when the suite runs without --isolate.
// Bun.spawnSync rather than the ../src/spawn-sync adapter — several test
// files install module-level mocks of that adapter which leak across files
// when the suite runs without --isolate.
//
// Run from the system temp dir with an absolute binary path so oxlint
// does not walk up and auto-discover the repo's `oxlint.config.ts`. The
Expand Down
11 changes: 0 additions & 11 deletions packages/cli/__tests__/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,6 @@ import {

import { spawnSync as _realSpawnSync } from "../src/spawn-sync";

// Eagerly link nypm (and its transitive tinyexec, which does
// `import { spawn, spawnSync } from "node:child_process"`) against the real
// node:child_process before any test installs a partial mock of it. Several
// tests mock node:child_process with only a subset of exports (e.g. just
// spawnSync); because Bun's mock.module is global, whichever partial mock is
// active when tinyexec first links determines whether `spawn` resolves. Test
// file order differs between platforms, so on CI (Linux) a spawn-less mock can
// be active first, throwing "Export named 'spawn' not found in module
// 'node:child_process'". Linking it here makes resolution order-independent.
import "nypm";

// Typed globals for the real implementations captured below, so consumers
// (mock-fs.ts, spawn-sync.test.ts) can read them without type assertions.
declare global {
Expand Down
35 changes: 32 additions & 3 deletions packages/cli/__tests__/spawn-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,35 @@ describe("spawnSync", () => {
expect(result.status).toBe(3);
});

test("maps a spawn failure to an error with a null status", () => {
test("maps a missing command according to the host platform", () => {
const result = spawnSync("definitely-not-a-real-command", []);

if (process.platform === "win32") {
// execa can't resolve the command, so it runs it through cmd.exe, which
// exits non-zero with "is not recognized" — the process did spawn.
expect(result.error).toBeUndefined();
expect(result.status).not.toBe(0);
expect(result.status).not.toBeNull();
return;
}

expect(result.error).toBeInstanceOf(Error);
expect(result.status).toBeNull();
});

test("maps a signal kill to a null status with the signal set", () => {
test("maps process termination according to the host platform", () => {
const result = spawnSync(node, [
"-e",
"process.kill(process.pid, 'SIGKILL')",
]);

if (process.platform === "win32") {
// Windows does not expose POSIX signals through child_process.
expect(result.status).not.toBe(0);
expect(result.signal).toBeUndefined();
return;
}

expect(result.status).toBeNull();
expect(result.signal).toBe("SIGKILL");
});
Expand All @@ -55,12 +71,25 @@ describe("spawnSync", () => {
expect(result.stdout?.trim()).toBe(tricky);
});

test("does not capture stdout when stdio is inherit", () => {
test("does not capture stdout when stdio is ignore", () => {
const result = spawnSync(node, ["-e", "process.exit(0)"], {
stdio: "ignore",
});

expect(result.status).toBe(0);
expect(result.stdout).toBeUndefined();
});

// Callers such as `skills list --json` don't set maxBuffer and rely on
// execa's 100 MB default rather than Node's 1 MiB.
test("captures more than Node's 1 MiB default maxBuffer", () => {
const result = spawnSync(node, [
"-e",
"process.stdout.write('x'.repeat(2 * 1024 * 1024))",
]);

expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
expect(result.stdout?.length).toBe(2 * 1024 * 1024);
});
});
5 changes: 0 additions & 5 deletions packages/cli/__tests__/vscode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";

import { createEditorConfig } from "../src/editor-config";

mock.module("node:child_process", () => ({
execSync: mock(() => ""),
spawnSync: mock(() => ({ status: 0 })),
}));

mock.module("node:fs/promises", () => ({
access: mock(() => Promise.reject(new Error("ENOENT"))),
mkdir: mock(() => Promise.resolve()),
Expand Down
20 changes: 10 additions & 10 deletions packages/cli/src/integrations/lefthook.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { execSync } from "node:child_process";
import { readFile } from "node:fs/promises";

import { log } from "@clack/prompts";
import { addDevDependency, dlxCommand } from "nypm";
import type { PackageManager, PackageManagerName } from "nypm";

import { getRootInstallOptions } from "../package-manager";
import { spawnSync } from "../spawn-sync";
import { exists, updatePackageJson, writeProjectFile } from "../utils";

// The top-level pre-commit hook and its indented block (including blank
Expand Down Expand Up @@ -59,18 +59,18 @@ export const lefthook = {
},
});

const installCommand = dlxCommand(packageManager.name, "lefthook", {
// dlxCommand returns a full command line, e.g. "npx lefthook install" —
// split it so spawn gets a real binary and never a shell.
const [command, ...args] = dlxCommand(packageManager.name, "lefthook", {
args: ["install"],
short: packageManager.name === "npm",
});
}).split(" ");

try {
execSync(installCommand, { stdio: "pipe" });
} catch {
// lefthook install fails with exit code 128 when not in a git repository.
// The dependency and prepare script are still set up, so lefthook will
// initialize hooks on the next `prepare` run after git is initialized.
}
// The result is deliberately ignored: lefthook install fails with exit
// code 128 when not in a git repository. The dependency and prepare script
// are still set up, so lefthook will initialize hooks on the next
// `prepare` run after git is initialized.
spawnSync(command, args, { stdio: "pipe" });
},
update: async (packageManager: PackageManagerName) => {
const existingContents = await readFile(path, "utf-8");
Expand Down