You’ve built a beautiful hook system on macOS. It works. Your teammates love it. Then a Windows user clones the repo, runs npm install, and everything breaks. Python isn’t installed. Bash scripts don’t execute. Path separators are backwards. You’re suddenly debugging platform-specific nightmares that nobody warned you about.
This is the hidden tax of cross-platform development. Most JavaScript guides punt on this problem—they assume Unix-like environments. But Claude Code runs everywhere: Windows dev machines, Linux containers, macOS CI/CD pipelines. Your hooks need to work on all three, consistently, without platform-specific branching logic that creates maintenance debt.
The solution? Stop reaching for Python, Bash, or shell scripts. Build your hooks entirely in JavaScript (.mjs), leverage the Node.js standard library, and architect around platform differences with predictable patterns. This article shows you exactly how—the definitive guide to Windows-compatible hook development.
Why This Matters: The Hidden Costs of Platform Fragmentation
Cross-platform compatibility isn’t a nice-to-have in modern development—it’s a competitive advantage. When your tools just work, regardless of what OS your team is using, you eliminate a whole category of friction. Think about the organizational costs of platform-specific breakdowns: a developer on Windows can’t use a hook that everyone else relies on. They either work around it (reinventing the wheel) or stop using hooks altogether (losing quality control). That divergence compounds. Different developers run different checks. Code quality becomes inconsistent. The team loses faith in automation.
The financial impact is real. A developer blocked by tooling for 30 minutes loses not just the 30 minutes—they lose context. They switch to something else. Coming back is cognitively expensive. Over a team of six developers with one blocked per week, that’s six hours of lost productivity monthly. Across a year, that’s 72 hours—almost two full sprints worth of work, lost to platform fragmentation.
Cross-platform hooks solve this at the root. When hooks work everywhere, developers use them consistently. Quality gates apply uniformly. CI/CD matches local development. That alignment is worth far more than the effort to build it right the first time.
Why .mjs Is Your Cross-Platform Superpower
JavaScript ES modules (.mjs files) are the universal solvent for cross-platform hook problems. Here’s why:
No external dependencies: A Python script requires Python installed. A Bash script assumes Bash exists. Windows users frequently lack both. JavaScript? It’s built into Node.js, which is already in your project’s node_modules.
Consistent execution model: Whether you’re on Windows, Linux, or macOS, node script.mjs behaves identically. No shell differences, no interpreter path issues, no “works on my machine” syndrome.
Native path handling: Node.js provides path module that abstracts Windows backslashes and Unix forward slashes. Stop manually concatenating paths.
Built-in process management: The child_process module handles spawning subprocesses with consistent cross-platform semantics. No platform detection hacks needed.
Let me show you what platform hell looks like, then how to escape it.
The Traditional Nightmare
Your pre-commit hook might look like this:
#!/bin/bash
# This breaks on Windows immediately
eslint src/ --fix
prettier --write "src/**/*.js"
npm test
Windows users hit this and get: bash: line 1: eslint: command not found. Even if they install Git Bash, the shebang #!/bin/bash doesn’t execute reliably on Windows. They’re stuck.
The “solution” many teams try is detecting the OS:
// ❌ This path leads to unmaintainable hell
const isWindows = process.platform === "win32";
if (isWindows) {
// Windows version of hook
execSync('cmd.exe /c "eslint src/ --fix"');
} else {
// Unix version of hook
execSync("eslint src/ --fix");
}
You now maintain two versions of every hook. When you change the logic, you update both. When you forget, platforms diverge. When a new developer adds a hook, they add platform branching. Your hooks directory becomes spaghetti. This is unmaintainable at scale.
.mjs solves this by being the single source of truth.
Foundational Pattern: The Platform-Agnostic Hook
The key insight is this: Node.js abstracts platform differences for you. A single .mjs file runs identically on Windows, macOS, and Linux when you respect Node.js conventions. You’re not writing “cross-platform code.” You’re writing Node.js code that Node.js handles.
Here’s the architecture we’ll build toward:
// hooks/pre-commit.mjs
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = dirname(__dirname);
async function runPreCommit() {
try {
console.log("Running pre-commit checks...");
// All paths use forward slashes or path.join()
// All commands use execSync with 'inherit' stdio
// All errors are caught and logged
execSync("eslint src/ --fix", {
cwd: projectRoot,
stdio: "inherit",
});
console.log("✓ Pre-commit checks passed");
process.exit(0);
} catch (error) {
console.error("✗ Pre-commit check failed:", error.message);
process.exit(1);
}
}
runPreCommit();
Key moves here:
- No shebang: .mjs files are invoked via
node hooks/pre-commit.mjs, not as shell scripts. - Path handling:
path.join()automatically uses the correct separator for the OS. - stdio: ‘inherit’: Output from subprocesses goes directly to the terminal, not buffered.
- Consistent error handling: Try/catch wraps everything; exit codes matter.
When you configure this in .husky/pre-commit, you do:
#!/bin/sh
node hooks/pre-commit.mjs
That shebang works everywhere because sh exists on all platforms (even Windows 11’s native Linux subsystem). The actual work happens in JavaScript, which is platform-agnostic.
Problem 1: Path Handling Nightmares
Windows uses backslashes (\) as path separators. Unix uses forward slashes (/). This seems simple—just a character difference—until you’re doing string operations on paths. Suddenly you’re concatenating paths, splitting them, resolving relative paths, and comparing them. Every operation behaves differently across platforms if you treat paths as strings.
The mistake most developers make:
// ❌ This breaks on Windows
const configPath = "config\\settings.json"; // Wrong on Unix
const logPath = "logs/debug.log"; // Wrong on Windows
// ✓ This works everywhere
const configPath = join(process.cwd(), "config", "settings.json");
const logPath = resolve(__dirname, "logs", "debug.log");
The path module does this for you. Never concatenate paths with strings. It’s the single most common source of cross-platform bugs, and it’s completely preventable.
Here’s a real hook example that respects platform differences:
// hooks/validate-commit-msg.mjs
const __dirname = dirname(fileURLToPath(import.meta.url));
async function validateCommitMessage() {
try {
// Path works on all platforms
const commitMsgFile = process.argv[2];
if (!commitMsgFile) {
console.error("Commit message file not provided");
process.exit(1);
}
const message = readFileSync(commitMsgFile, "utf-8").trim();
// Validate conventional commits format
const conventionalRegex =
/^(feat|fix|docs|style|refactor|perf|test|chore)(\(.+\))?!?: .+/;
if (!conventionalRegex.test(message)) {
console.error("❌ Invalid commit message format");
console.error("Expected: type(scope): description");
console.error(`Got: ${message}`);
process.exit(1);
}
console.log("✓ Commit message valid");
process.exit(0);
} catch (error) {
console.error("Error validating commit:", error.message);
process.exit(1);
}
}
validateCommitMessage();
Husky configuration (.husky/commit-msg):
#!/bin/sh
node hooks/validate-commit-msg.mjs $1
The $1 argument (Git passes the commit message file) works identically on Windows, macOS, and Linux because it’s shell expansion, not platform-specific logic.
Problem 2: Process Spawning Differences
When you run commands from JavaScript, you’re asking the operating system to spawn a subprocess. The problem: the shell interface differs dramatically across platforms. On Unix systems, you interact with Bash or Zsh. On Windows, you’re dealing with cmd.exe or PowerShell. Each has different quoting rules, environment variable syntax, and operator support.
This difference is subtle but devastating. A Bash script that works flawlessly on Linux breaks immediately on Windows because Bash doesn’t exist. A PowerShell script with && operators works on Windows but fails on Linux because && isn’t a shell operator there—it’s interpreted as a Bash operator. You end up writing if-else branches for every platform, duplicating logic, and creating maintenance nightmares.
The universal solution is to avoid shell operators entirely. Execute one command per execSync call. No pipes, no &&, no ||. Just atomic operations. This runs identically everywhere because you’re not relying on shell-specific operators.
On Unix, you can spawn a subprocess with a shell command string using operators:
eslint src/ --fix && prettier --write "src/**/*.js"
Windows requires special handling for operators like &&. The child_process module handles this with shell: true, but cross-platform execution needs care.
// hooks/lint-and-format.mjs
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = dirname(__dirname);
async function lintAndFormat() {
try {
// ✓ Split commands into separate execSync calls
// This is more reliable than using shell operators
console.log("Linting...");
execSync("eslint src/ --fix", {
cwd: projectRoot,
stdio: "inherit",
});
console.log("Formatting...");
execSync("prettier --write src/", {
cwd: projectRoot,
stdio: "inherit",
});
console.log("✓ Lint and format passed");
process.exit(0);
} catch (error) {
console.error("✗ Lint/format failed:", error.message);
process.exit(1);
}
}
lintAndFormat();
Key principle: execute one command per execSync call. This avoids shell operator issues (&&, ||, pipes) that behave differently across platforms. When you split commands, you also get better error reporting—you know exactly which command failed, not “something in the chain failed.”
Example with proper error handling:
// ✓ Better: Explicit error handling per command
function buildProject() {
try {
console.log("Building...");
execSync("npm run build", { cwd: projectRoot, stdio: "inherit" });
logSuccess("Build passed");
} catch (error) {
logError("Build failed");
process.exit(1);
}
try {
console.log("Testing...");
execSync("npm test", { cwd: projectRoot, stdio: "inherit" });
logSuccess("Tests passed");
} catch (error) {
logError("Tests failed");
process.exit(1);
}
logSuccess("All steps complete");
}
If you absolutely need shell operators (piping output, complex conditionals), use the shell option:
// Only when you can't avoid it
execSync("npm test && npm run build", {
cwd: projectRoot,
stdio: "inherit",
shell: true, // ✓ Enables shell operators on all platforms
});
With shell: true, Node.js automatically uses cmd.exe on Windows and sh on Unix. The documentation guarantees this behavior. But prefer splitting commands—it’s clearer, avoids hidden shell differences, and provides better error messages.
Problem 3: File System Operations
Filesystem behavior is mostly consistent across platforms, but a few critical gotchas exist that silently cause failures on specific operating systems. A hook that works perfectly on macOS might fail silently on Linux servers in CI pipelines. These inconsistencies are subtle but devastating for production systems. Understanding these edge cases separates robust hooks from brittle ones.
Case sensitivity: macOS and Windows are case-insensitive by default. Linux is case-sensitive. A file named Config.js on macOS can be imported as config.js, but that import fails on Linux. This is particularly pernicious because it works fine during local development and then fails mysteriously in CI.
This is the silent killer of cross-platform compatibility. You test locally on macOS, everything works. You push to CI running on Linux, suddenly import statements fail and you’re confused. The CI logs show “file not found” for a file that clearly exists. The issue: the file exists as Config.js but your code imports it as config.js. On macOS, this casual case mismatch is silently accepted. On Linux, it’s a fatal error.
The fix seems obvious: just use the right case. But in a large codebase with hundreds of files, case inconsistencies creep in. Different developers on different platforms introduce subtle variations. The linter doesn’t catch it because it runs on the developer’s machine where case is forgiving. CI catches it, but by then you’ve wasted debugging time.
Solution: enforce consistent casing in your hooks. Check that imports match filesystem exactly.
// hooks/validate-imports.mjs
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = dirname(__dirname);
function validateImportCasing() {
const srcDir = join(projectRoot, "src");
const files = readdirSync(srcDir, { recursive: true });
const errors = [];
files.forEach((file) => {
if (!file.endsWith(".js") && !file.endsWith(".mjs")) return;
const filePath = join(srcDir, file);
const realPath = statSync(filePath).isFile() ? filePath : null;
if (!realPath) return;
// Check: is the file's actual name on disk different from what we're using?
const baseName = file.split("/").pop();
const diskName = readdirSync(dirname(realPath)).find(
(f) => f.toLowerCase() === baseName.toLowerCase(),
);
if (diskName !== baseName) {
errors.push(
`Case mismatch: expected '${baseName}' but disk has '${diskName}'`,
);
}
});
if (errors.length > 0) {
console.error("❌ Import casing violations found:");
errors.forEach((e) => console.error(` - ${e}`));
process.exit(1);
}
console.log("✓ All imports use correct casing");
process.exit(0);
}
validateImportCasing();
Newline differences: Windows uses \r\n (CRLF), Unix uses \n (LF). If your hook modifies files, normalize newlines. This is why so many projects commit .editorconfig files or use tools like eol-last in eslint.
The newline issue is subtle but omnipresent. A developer on Windows edits a file, their editor saves it with CRLF newlines. The hook doesn’t normalize them. The file gets committed with mixed newlines. CI on Linux sees the LF version. Later developers on macOS see yet another variation. You end up with a repository where every edit introduces spurious diff changes because the newline characters are inconsistent.
Some teams waste enormous effort tracking down “why is the diff showing every line as changed?” only to discover it’s a newline inconsistency. The fix is simple: normalize to LF (the Unix standard) everywhere. Most modern tools default to LF, but your hook should enforce it. If you’re modifying files, explicitly normalize them before writing.
// hooks/normalize-newlines.mjs
function normalizeNewlines() {
const files = globSync("src/**/*.js", {
ignore: ["node_modules/**"],
});
files.forEach((file) => {
const content = readFileSync(file, "utf-8");
// Normalize to LF (Unix standard)
const normalized = content.replace(/\r\n/g, "\n");
if (normalized !== content) {
writeFileSync(file, normalized, "utf-8");
console.log(`Normalized: ${file}`);
}
});
console.log("✓ Newlines normalized");
process.exit(0);
}
normalizeNewlines();
File deletion on locked files: On Windows, deleting a file that’s currently open (by another process) can fail. On Unix, the file is unlinked immediately. If your hook deletes build artifacts and VS Code or another editor has the file open, Windows will throw an error.
This is maddening in practice. You have a cleanup hook that deletes old build files. It works fine on your Linux CI pipeline. But when a developer runs it locally on Windows with VS Code open, the deletion fails because the editor has a lock on the file. The hook errors out. The developer is stuck wondering why their hook failed for no apparent reason.
The fix is to use appropriate error handling. If a file deletion fails, log it but don’t fail the entire hook. Some files might be locked temporarily; that doesn’t invalidate the entire cleanup operation. Use try-catch around file operations and fail gracefully.
Problem 4: Environment Variables
Environment variable names are case-sensitive on Unix, case-insensitive on Windows. This subtle difference creates insidious bugs: code works perfectly on Windows dev machines (case-insensitive lookup succeeds) but breaks in CI pipelines running Linux (case-sensitive lookup fails). Your tests pass locally, fail in CI, and you spend hours debugging why a variable that “obviously exists” can’t be found.
Imagine this scenario: Your CI environment sets NODE_ENV=production. On Windows, you can access it as process.env.node_env, process.env.NODE_ENV, or process.env.NoDeEn—all work because Windows is case-insensitive. On Linux CI, only process.env.NODE_ENV works. If your hook uses process.env.node_env (lowercase), it silently fails on Linux but works on Windows. The behavior diverges. Your hook works locally but fails in production CI.
The pattern:
// ❌ Inconsistent across platforms
const apiKey = process.env.API_KEY || process.env.api_key;
// ✓ Use uppercase consistently (Node.js convention)
const apiKey = process.env.API_KEY;
In your hook that needs environment setup:
// hooks/setup-env.mjs
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = dirname(__dirname);
const envFile = join(projectRoot, ".env.local");
async function setupEnvironment() {
try {
// Load .env file only if it exists
let envVars = {};
try {
const envContent = readFileSync(envFile, "utf-8");
envContent.split("\n").forEach((line) => {
const [key, value] = line.split("=");
if (key && value) {
envVars[key.trim()] = value.trim();
}
});
} catch {
// .env file optional
}
// Pass to subprocess with consistent naming
execSync("npm run validate", {
cwd: projectRoot,
stdio: "inherit",
env: {
...process.env,
...envVars,
},
});
console.log("✓ Environment setup complete");
process.exit(0);
} catch (error) {
console.error("✗ Environment setup failed:", error.message);
process.exit(1);
}
}
setupEnvironment();
Problem 5: Testing Hooks Across Platforms
The final challenge separates mature hook systems from ones that fail in production: how do you know your hooks actually work on Windows if you’re developing on macOS? How do you ensure macOS-specific issues don’t break Linux CI? How do you verify that your hook works consistently across all three platforms?
You can’t ship untested code. You need systematic testing coverage across platforms. This is non-negotiable for production systems that your entire team depends on. Untested cross-platform code is a ticking time bomb.
Option 1: Docker
Use a Windows container image (Windows Server or Windows Nano Server) in your CI pipeline.
# .github/workflows/test-hooks-windows.yml
name: Test Hooks on Windows
on: [push, pull_request]
jobs:
test-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "20"
- run: npm install
- run: node hooks/pre-commit.mjs
- run: node hooks/lint-and-format.mjs
- run: node hooks/validate-imports.mjs
Option 2: Actual Windows VM
If you have access, test locally on a real Windows machine. VirtualBox or Hyper-V work well for development. Real hardware testing catches issues that containers sometimes mask (particularly around path handling and file locking).
Option 3: Command-line testing
Write a test script that validates hook behavior:
// test-hooks.mjs
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = dirname(__dirname);
const hooks = [
"pre-commit.mjs",
"validate-commit-msg.mjs",
"lint-and-format.mjs",
"validate-imports.mjs",
];
console.log(`Testing hooks on ${process.platform}...`);
hooks.forEach((hook) => {
try {
console.log(`\n▶ Testing ${hook}`);
execSync(`node hooks/${hook}`, {
cwd: projectRoot,
stdio: "inherit",
});
console.log(`✓ ${hook} passed`);
} catch (error) {
console.error(`✗ ${hook} failed`);
process.exit(1);
}
});
console.log("\n✓ All hooks passed");
Run this with:
npm run test:hooks
And in package.json:
{
"scripts": {
"test:hooks": "node test-hooks.mjs"
}
}
Pulling It Together: A Production Hook System
Here’s a complete, battle-tested hook suite that works identically on Windows, macOS, and Linux:
// hooks/lib/utils.mjs
export const __dirname = dirname(fileURLToPath(import.meta.url));
export const projectRoot = dirname(dirname(__dirname));
export function runCommand(cmd, options = {}) {
try {
execSync(cmd, {
cwd: projectRoot,
stdio: "inherit",
...options,
});
return true;
} catch (error) {
return false;
}
}
export function logSection(title) {
console.log(`\n${"─".repeat(50)}`);
console.log(`▶ ${title}`);
console.log("─".repeat(50));
}
export function logSuccess(message) {
console.log(`✓ ${message}`);
}
export function logError(message) {
console.error(`✗ ${message}`);
}
// hooks/pre-commit.mjs
async function preCommit() {
logSection("Pre-Commit Checks");
const checks = [
{ name: "ESLint", cmd: "eslint src/ --fix" },
{ name: "Prettier", cmd: "prettier --write src/" },
{ name: "Tests", cmd: "npm test -- --maxWorkers=2" },
];
for (const check of checks) {
console.log(`\nRunning ${check.name}...`);
if (!runCommand(check.cmd)) {
logError(`${check.name} failed`);
process.exit(1);
}
logSuccess(`${check.name} passed`);
}
logSuccess("All checks passed");
process.exit(0);
}
preCommit().catch((error) => {
logError(error.message);
process.exit(1);
});
Under the Hood: How Node.js Abstracts Platform Differences
To understand why .mjs files are truly cross-platform, you need to understand what’s happening underneath. When you run node hooks/pre-commit.mjs, Node.js becomes the interpreter. The operating system doesn’t care about shell syntax anymore—it’s delegating to Node, which has consistent behavior everywhere.
Here’s the critical insight: Node’s child_process module is essentially a thin wrapper around platform-specific process spawning APIs. On Unix, it calls fork() or exec(). On Windows, it calls CreateProcess(). The module abstracts these differences away. When you call execSync("npm test"), Node translates it appropriately for the OS you’re running on. The cwd parameter works the same way. The stdio inheritance works the same way. This isn’t magic—it’s just careful API design that respects platform differences without exposing them.
The path module does something similar for file paths. When you call path.join("src", "file.js"), Node returns “src/file.js” on Unix and “src\file.js” on Windows. The module knows what separator each platform uses and applies it transparently. This is why you should never manually concatenate paths with “/” or “\”—the path module handles it correctly.
Understanding this architectural level helps you make better decisions. You’re not fighting the OS; you’re working within Node.js’s abstraction layer. The more you stay within that abstraction (using path module, using execSync without shell, using stdio inheritance), the more cross-platform you become automatically. The moment you step outside (hardcoding path separators, invoking shell syntax directly, assuming environment variables), you’re vulnerable to platform fragmentation.
Real projects run multiple hooks with shared setup and teardown logic. Git fires different hooks at different lifecycle points: pre-commit (before staging), prepare-commit-msg (before editor), commit-msg (after message entry), post-commit (after commit). Each has different purposes, but they often share infrastructure: logging utilities, command execution helpers, error handling patterns.
Instead of duplicating initialization code across each hook, build a composable hook system:
// hooks/lib/hook-runner.mjs
export class HookRunner {
constructor(name) {
this.name = name;
this.before = [];
this.steps = [];
this.after = [];
this.errors = [];
}
addBefore(name, command) {
this.before.push({ name, command });
return this;
}
addStep(name, command) {
this.steps.push({ name, command });
return this;
}
addAfter(name, command) {
this.after.push({ name, command });
return this;
}
async run() {
logSection(this.name);
// Run before hooks
for (const hook of this.before) {
console.log(`Before: ${hook.name}`);
if (!runCommand(hook.command)) {
logError(`Before hook failed: ${hook.name}`);
process.exit(1);
}
}
// Run main steps
for (const step of this.steps) {
console.log(`\nRunning ${step.name}...`);
if (!runCommand(step.command)) {
this.errors.push(step.name);
logError(`${step.name} failed`);
// Continue to cleanup
} else {
logSuccess(`${step.name} passed`);
}
}
// Run after hooks (always, even if steps fail)
for (const hook of this.after) {
console.log(`After: ${hook.name}`);
if (!runCommand(hook.command)) {
logError(`After hook failed: ${hook.name}`);
}
}
// Report
if (this.errors.length > 0) {
logError(`${this.errors.length} step(s) failed`);
process.exit(1);
}
logSuccess(`${this.name} complete`);
process.exit(0);
}
}
Usage:
// hooks/pre-push.mjs
const hook = new HookRunner("Pre-Push Verification");
hook
.addBefore("Git status", "git status --porcelain")
.addStep("Type checking", "tsc --noEmit")
.addStep("Linting", "eslint src/")
.addStep("Tests", "npm test -- --maxWorkers=2")
.addAfter("Summary", 'echo "Pre-push checks complete"')
.run();
This pattern scales elegantly. New hooks extend HookRunner. Shared logic lives in the base class. No duplication across hook files. When you need to add logging, modify error messages, or change how commands execute, you update the base class once and all hooks benefit. This is maintainability at scale.
Advanced Pattern: Hook Composition and Middleware
Debugging Cross-Platform Issues
When a hook fails on Windows but works on macOS, systematic debugging is essential. Don’t guess. Gather data. The difference in behavior between platforms usually comes down to a few variables: the executable path, environment setup, shell differences, or filesystem behavior. To isolate the issue, you need visibility into all of these.
Here’s a diagnostic tool that runs on any platform and shows you exactly what’s configured:
// hooks/debug.mjs - Platform diagnostic tool
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = dirname(__dirname);
function diagnosePlatform() {
console.log("=== Platform Diagnostics ===\n");
console.log(`OS: ${process.platform}`);
console.log(`Node version: ${process.version}`);
console.log(`npm version: ${execSync("npm --version").toString().trim()}`);
console.log(`Project root: ${projectRoot}`);
console.log(`CWD: ${process.cwd()}`);
console.log("\n=== Executable Paths ===\n");
const tools = ["eslint", "prettier", "npm", "node"];
tools.forEach((tool) => {
try {
const path = execSync(
`npm list -g ${tool} --depth=0 2>/dev/null || which ${tool}`,
)
.toString()
.trim();
console.log(`${tool}: ${path}`);
} catch {
console.log(`${tool}: NOT FOUND`);
}
});
console.log("\n=== Environment Variables ===\n");
const envKeys = ["PATH", "NODE_PATH", "SHELL", "HOME", "USERPROFILE"];
envKeys.forEach((key) => {
console.log(`${key}: ${process.env[key] || "not set"}`);
});
console.log("\n=== Git Hooks Configuration ===\n");
const hooksPath = join(projectRoot, ".git", "hooks");
try {
const hooks = execSync(`ls -la ${hooksPath}`).toString();
console.log(hooks);
} catch {
console.log("Git hooks directory not found");
}
}
diagnosePlatform();
Run on each platform to identify setup issues:
# macOS/Linux
node hooks/debug.mjs
# Windows (PowerShell)
node hooks/debug.mjs
Compare outputs to spot discrepancies. Look for missing environment variables, different node versions, or missing tools. This diagnostic approach saves hours of guesswork.
Performance Considerations
Hooks run on every commit. Slow hooks frustrate developers and reduce adoption. Optimize for speed without sacrificing correctness:
// hooks/lib/parallel-runner.mjs
export async function runParallel(tasks) {
const results = await Promise.allSettled(
tasks.map(
(task) =>
new Promise((resolve, reject) => {
try {
execSync(task.command, { stdio: "pipe" });
resolve({ task: task.name, success: true });
} catch {
reject({ task: task.name, success: false });
}
}),
),
);
const failed = results.filter((r) => r.status === "rejected");
return {
passed: results.filter((r) => r.status === "fulfilled").length,
failed: failed.length,
errors: failed.map((r) => r.reason),
};
}
// Usage
async function lintInParallel() {
const result = await runParallel([
{ name: "ESLint", command: "eslint src/" },
{ name: "Type check", command: "tsc --noEmit" },
{ name: "Tests", command: "npm test" },
]);
if (result.failed > 0) {
console.error(`${result.failed} check(s) failed`);
process.exit(1);
}
console.log(`✓ All checks passed`);
}
lintInParallel();
However, be careful with parallelization: some checks have shared state. Type checking and linting are safe in parallel. Tests that modify databases might not be. Know your dependencies before parallelizing.
Integration with Husky
Properly configuring Husky for cross-platform compatibility:
// package.json
{
"scripts": {
"prepare": "node -e \"require('fs').existsSync('.husky') || require('child_process').execSync('husky install')\""
}
}
This runs husky install only once, on npm install.
# .husky/pre-commit
#!/bin/sh
# This sh shebang works on all platforms
exec node hooks/pre-commit.mjs
The exec ensures the Node process becomes the main process (proper exit code handling).
Real-World Hook Library
Here’s a complete, production-ready hook library you can copy-paste:
// hooks/lib/constants.mjs
export const __dirname = dirname(fileURLToPath(import.meta.url));
export const PROJECT_ROOT = dirname(dirname(__dirname));
export const HOOKS_DIR = dirname(__dirname);
export const EXIT_CODES = {
SUCCESS: 0,
GENERAL_ERROR: 1,
LINT_FAILED: 2,
TEST_FAILED: 3,
VALIDATION_FAILED: 4,
};
export const LOG_COLORS = {
reset: "\x1b[0m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
cyan: "\x1b[36m",
};
// hooks/lib/enhanced-utils.mjs
export function runCommand(cmd, options = {}) {
try {
const defaultOptions = {
cwd: PROJECT_ROOT,
stdio: "inherit",
shell: true,
};
execSync(cmd, { ...defaultOptions, ...options });
return { success: true };
} catch (error) {
return { success: false, error };
}
}
export function log(message, color = "reset") {
const colorCode = LOG_COLORS[color] || LOG_COLORS.reset;
console.log(`${colorCode}${message}${LOG_COLORS.reset}`);
}
export function success(message) {
log(`✓ ${message}`, "green");
}
export function error(message) {
log(`✗ ${message}`, "red");
}
export function warning(message) {
log(`⚠ ${message}`, "yellow");
}
export function section(title) {
console.log(`\n${LOG_COLORS.cyan}${"─".repeat(60)}`);
console.log(`${title}`);
console.log(`${"─".repeat(60)}${LOG_COLORS.reset}\n`);
}
export async function guard(name, fn) {
try {
section(name);
await fn();
success(`${name} passed`);
return true;
} catch (error) {
error(`${name} failed: ${error.message}`);
return false;
}
}
// hooks/prepare-commit-msg.mjs
async function main() {
const commitMsgFile = process.argv[2];
let passed = true;
passed &= await guard("Format check", async () => {
log("Checking file formats...");
// Your format checks here
});
passed &= await guard("Dependencies", async () => {
log("Verifying dependencies...");
// Your dependency checks here
});
process.exit(passed ? 0 : 1);
}
main().catch((error) => {
log(`Fatal error: ${error.message}`, "red");
process.exit(1);
});
Production Considerations: Scaling Hooks Safely
When hooks graduate from hobby projects to team infrastructure, production concerns emerge. You’re not just running hooks locally anymore—they run in CI/CD, in Docker containers, on colleagues’ machines with varying configurations. Reliability becomes paramount.
Graceful degradation is crucial. What happens if a linter isn’t installed? Your hook might crash, preventing commits. Instead, detect missing tools and provide helpful error messages. “eslint not found. Install with: npm install –save-dev eslint” is infinitely more useful than a cryptic error.
Timeout handling prevents hooks from hanging indefinitely. If a test suite takes 20 minutes and someone interrupts it with Ctrl+C, does the hook cleanup properly? Does it leave temp files? Does the next person who runs the hook encounter stale locks? Implement timeouts so hooks fail fast if something is stuck: execSync(cmd, { timeout: 30000 }) fails after 30 seconds instead of hanging.
Logging for debugging is essential in production. When a hook fails in CI, you need detailed logs showing what command ran, what the output was, and what the exit code was. Structured logging (JSON format) makes parsing CI logs easier than plaintext.
Version pinning prevents surprises when tools update. If your hook depends on eslint version 8, but the user has version 9 installed, behavior might change. Pin tool versions in package.json and use npm ci (clean install) in production to ensure exact versions match.
Parallel execution complexity requires understanding. Running multiple checks in parallel (linting, type-checking, tests) speeds things up, but introduces race conditions. If multiple checks write to the same temp directory, they might collide. Use separate temp directories or implement explicit locking mechanisms.
Despite best efforts, something will break on Windows. A developer will report “the hook doesn’t work for me.” Here’s how to investigate systematically instead of guessing.
First, get full diagnostic information. What version of Windows? What Node version? What tools are installed? What’s the exact error message? Create a diagnostic script that developers can run:
// hooks/diagnose.mjs
const __dirname = dirname(fileURLToPath(import.meta.url));
function runDiagnostics() {
console.log("\n╔════════════════════════════════════════╗");
console.log("║ Hook Diagnostic Report ║");
console.log("╚════════════════════════════════════════╝\n");
const diagnostics = {
"System Information": {
platform: process.platform,
arch: process.arch,
osVersion: os.release(),
nodeVersion: process.version,
},
};
const tools = ["npm", "git", "eslint", "prettier"];
diagnostics["Executable Paths"] = {};
tools.forEach((tool) => {
try {
const result = execSync(`where ${tool}`, { encoding: "utf8" });
diagnostics["Executable Paths"][tool] = result.trim();
} catch {
diagnostics["Executable Paths"][tool] = "NOT FOUND";
}
});
diagnostics["Environment"] = {
PATH: process.env.PATH?.substring(0, 100) + "...",
SHELL: process.env.SHELL || "not set",
USERPROFILE: process.env.USERPROFILE?.substring(0, 50) + "...",
};
// Print formatted output
Object.entries(diagnostics).forEach(([section, data]) => {
console.log(`\n${section}:`);
console.log("─".repeat(40));
Object.entries(data).forEach(([key, value]) => {
console.log(` ${key}: ${value}`);
});
});
console.log("\n" + "─".repeat(40));
console.log("Share this output when reporting issues.");
}
runDiagnostics();
When a Windows user reports a problem, ask them to run node hooks/diagnose.mjs and share the output. Now you have concrete data. Maybe Node is version 14 (too old). Maybe eslint isn’t in PATH. Maybe they’re on Windows 11 with WSL2 (which changes behavior). You’re not guessing anymore.
Real-World Scenario: Implementing Hooks Across a Multi-Platform Team
Imagine this: you’re engineering lead at a mid-size startup. You’ve got eight developers: three on macOS, two on Windows, three on Linux. Your git hooks are currently bash scripts that only work on macOS and Linux. The Windows developers skip hooks manually with git commit --no-verify, so they occasionally commit code that fails tests. You need a solution.
Here’s how you’d implement this in reality:
Week 1: Audit your existing hooks. You have pre-commit (runs tests), commit-msg (validates format), and pre-push (runs build check). Three hooks, each is 30-50 lines of bash. Converting to .mjs takes maybe two hours total. You create hooks/ directory with utility libraries.
Week 2: Configure Husky for your team. Add .husky/pre-commit, .husky/commit-msg, .husky/pre-push that each invoke the corresponding .mjs file. Update package.json to run husky install on npm install.
Week 3: The Windows developers test. Everything works. They’re shocked that hooks run on Windows. One mentions “I thought this was impossible.” It wasn’t—just nobody had done it yet.
Month 2: You notice hooks are slow on some machines. You implement the performance monitoring we discussed earlier. Turns out type-checking is the bottleneck. You add a flag to skip type-checking on pre-commit if tests pass (it’s redundant anyway). Hooks now run in 8 seconds instead of 30.
Month 3: A developer accidentally commits a file that breaks the build. The hook should have caught it. Turns out they ran with --no-verify because they were in a hurry. You implement a team policy: --no-verify is only allowed with team approval (and team leads will catch it in code review). The culture shift is more important than the technology.
This real-world rollout shows that cross-platform hooks aren’t a one-time setup. They’re an evolving system that adapts to your team’s needs. The infrastructure doesn’t change much, but your confidence in it grows as you iterate.
Performance Optimization for Complex Hooks
As your hooks grow, they might slow down. On Windows, performance is more noticeable because spawning processes is slower than on Unix. Optimize systematically:
// hooks/lib/performance-monitor.mjs
export class PerformanceMonitor {
constructor(name) {
this.name = name;
this.timings = {};
}
start(label) {
this.timings[label] = { start: Date.now() };
}
end(label) {
if (!this.timings[label]) return;
this.timings[label].duration = Date.now() - this.timings[label].start;
}
report() {
console.log(`\n${this.name} Performance:`);
Object.entries(this.timings).forEach(([label, timing]) => {
console.log(` ${label}: ${timing.duration}ms`);
});
const total = Object.values(this.timings).reduce(
(sum, t) => sum + t.duration,
0,
);
console.log(` Total: ${total}ms`);
}
}
Usage:
const monitor = new PerformanceMonitor("Pre-commit");
monitor.start("eslint");
execSync("eslint src/", { stdio: "inherit" });
monitor.end("eslint");
monitor.start("prettier");
execSync("prettier --write src/", { stdio: "inherit" });
monitor.end("prettier");
monitor.report();
If pre-commit is taking 30 seconds, you can see exactly where time is spent. Maybe eslint is slow. Maybe prettier is slow. Maybe the problem is spawning two processes. Now you have data to optimize from.
Integration with CI/CD: Consistency Across Environments
Your hooks should work identically in CI/CD (typically Linux) as they do locally. Test this explicitly in your CI pipeline:
name: Validate Hooks
on: [push, pull_request]
jobs:
test-hooks:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "20"
- run: npm ci
- run: node hooks/pre-commit.mjs
- run: node hooks/lint-and-format.mjs
- run: node hooks/validate-imports.mjs
This runs your hooks on all three platforms in CI. If something breaks on Windows, you know immediately. If something breaks on Linux, you know immediately. No surprises when developers use the tools locally.
Extending Your Hook System: Adding Custom Hooks
As your project grows, you’ll want to add hooks beyond the standard pre-commit, commit-msg, pre-push. Here’s a pattern for adding custom hooks:
// hooks/custom/deploy-pre-check.mjs
const hook = new HookRunner("Pre-Deployment Checks");
hook
.addStep("Build", "npm run build")
.addStep("Type check", "tsc --noEmit")
.addStep("Security audit", "npm audit --audit-level=moderate")
.addStep("Docker build", "docker build .")
.run();
Register it in your CI pipeline:
- name: Pre-deployment checks
run: node hooks/custom/deploy-pre-check.mjs
Alternatives: When Not to Use .mjs Hooks
Before we conclude, let’s be honest about when .mjs hooks might not be the best choice. Not every project benefits from this approach.
If your project is Unix-only, and you have no Windows developers, plain bash hooks are simpler. They require no dependencies, no package.json, no Node.js runtime. You lose nothing by using bash if everybody’s on Unix.
If you’re already using Docker for development, you’re abstracting the OS layer anyway. Whether developers run hooks locally or inside containers, the container provides consistent environment. In this case, shell scripts inside the container are perfectly fine—the container guarantees consistency.
If your team is highly specialized in a different runtime (Python-only team, Go-only team), a Node.js-based hook infrastructure adds complexity. You’d require Node.js just for hooks, which seems wasteful. Better to use your team’s native runtime for hooks.
If your hooks need to do OS-specific things, the abstraction approach breaks down. Some rare cases genuinely need platform-specific behavior—controlling hardware, accessing native system calls, etc. In those cases, detecting the OS and branching is unavoidable, and you might as well use native tools.
For most web development projects with mixed-platform teams, though, .mjs hooks are the pragmatic choice. The effort is minimal, the benefit is substantial, and the maintenance burden is lower than managing platform-specific scripts.
Cross-platform hooks aren’t about detecting platforms and branching. They’re about writing JavaScript that respects platform differences without acknowledging them:
- Use .mjs files invoked via
node hooks/name.mjs. This gives you consistent execution everywhere. - Use the
pathmodule for all file paths. Never string-concatenate. - Split commands into individual
execSynccalls. Avoid shell operators. - Use
stdio: 'inherit'to stream output directly to the terminal. - Normalize newlines and casing to prevent Linux CI failures.
- Compose hooks with shared utilities. No duplication across files.
- Test on actual Windows or use Windows CI runners. Don’t assume it works.
- Provide diagnostics when things break. Make debugging easy.
- Monitor performance to ensure hooks don’t slow down developers.
- Validate in CI across all platforms before merging.
Your hooks now work identically on Windows, macOS, and Linux. No platform branching. No Bash scripts. No Python dependencies. Just JavaScript and the Node.js standard library.
Your Windows team members can clone, install, and run hooks without friction. Your CI pipelines won’t mysteriously fail on Linux servers. Your macOS developers won’t have false confidence that everything “just works.” That’s cross-platform done right.
The investment in getting this right pays massive dividends. Your team stays productive instead of debugging environment-specific failures. Your onboarding story becomes simpler: “clone, npm install, go.” Your CI pipelines are predictable and reliable. These are the kinds of boring infrastructure improvements that nobody notices until they’re missing, at which point they’re all anyone talks about.
Common Pitfalls: What Breaks and How to Fix It
Even with the best intentions, cross-platform hook development has pitfalls. Understanding these ahead of time saves you from reinventing debugging solutions that other teams have already suffered through. The patterns of failure are remarkably consistent across projects—certain mistakes repeat over and over because developers don’t know the gotchas exist.
Pitfall 1: Using shell operators in execSync without the shell option – When you try to use && or pipes in execSync without setting shell: true, they work on Unix (where the shell interprets them) but fail on Windows. You get mysterious errors about commands not being found. The fix: either split commands into separate execSync calls (preferred) or explicitly set shell: true if you need operators.
Pitfall 2: Assuming npm is in PATH – On some Windows systems, npm might be in PATH when you type it manually, but when Node.js spawns a subprocess, it doesn’t inherit the same PATH. Using the full path to npm (e.g., via process.env.npm_node_execpath) is safer than assuming it’s reachable as “npm”.
Pitfall 3: Not handling non-zero exit codes explicitly – A failed test returns exit code 1. A bash script would stop due to set -e implicit behavior. Node.js throws an exception in execSync only when you let it. If you’re chaining multiple commands, a failure in step 2 doesn’t automatically stop step 3. You need explicit try-catch blocks around each step.
Pitfall 4: Mixing relative and absolute paths – Relative paths like ./config work fine until you call your hook from a different directory. Use path.resolve() or path.join() with __dirname to anchor paths to your hook file’s location, not the current working directory.
Pitfall 5: Assuming environment variables exist – A CI system might set GITHUB_TOKEN, but a local developer won’t have it. Your code crashes if you don’t check. Use environment variables defensively with fallback values or explicit error messages.
Pitfall 6: Not testing on the actual platform – You can’t test Windows compatibility on macOS. That developer on Windows isn’t being difficult—they’re hitting real issues that your macOS experience hides. Run your hooks on actual Windows machines (VirtualBox, Hyper-V, or CI runners) before declaring them working.
Years of development experience distills into one principle: respect your platforms. Don’t fight Windows. Don’t assume Linux. Don’t special-case macOS. Build for all of them from day one, and you’ll have tools that work reliably for everyone.
Team Adoption: Making Hooks Part of Your Culture
The technical solution is half the battle. The other half is getting your team to care. Hooks only work if developers use them consistently. Here’s how to achieve adoption:
Make failures visible and fixable. When a hook fails, the error message should suggest a fix: “Tests failed (see above). Run npm test -- --updateSnapshots to fix snapshot mismatches.” Developers should be able to resolve most failures without asking for help.
Provide escape hatches carefully. git commit --no-verify is an escape hatch, but use it sparingly and only for legitimate reasons (emergency hotfixes, etc.). If developers bypass hooks routinely, the hooks aren’t delivering value—fix the problem, don’t blame developers for circumventing.
Celebrate wins publicly. When a hook catches a real bug before it goes to main, mention it in standup or Slack. “The pre-commit linter caught a missing await in the auth module today. Saved us from a production incident.” This builds cultural capital around the hooks.
Iterate on performance. Slow hooks frustrate developers. If pre-commit takes 45 seconds, developers will skip it. If it takes 5 seconds, they’ll love it. Invest in speed. Profile, optimize, parallelize.
Document the escape hatches. When a developer legitimately needs --no-verify, they should know the policy and understand why it exists. Don’t hide it or make them feel bad—make it explicit and rare.
-iNet