diff --git a/scripts/hooks/post-edit-format.js b/scripts/hooks/post-edit-format.js index 2f1d0334b..d848eb83c 100644 --- a/scripts/hooks/post-edit-format.js +++ b/scripts/hooks/post-edit-format.js @@ -13,6 +13,7 @@ const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +const { getPackageManager } = require('../lib/package-manager'); const MAX_STDIN = 1024 * 1024; // 1MB limit let data = ''; @@ -60,13 +61,42 @@ function detectFormatter(projectRoot) { return null; } -function getFormatterCommand(formatter, filePath) { - const npxBin = process.platform === 'win32' ? 'npx.cmd' : 'npx'; +function getRunnerBin(bin) { + if (process.platform !== 'win32') return bin; + + if (bin === 'npx') return 'npx.cmd'; + if (bin === 'pnpm') return 'pnpm.cmd'; + if (bin === 'yarn') return 'yarn.cmd'; + if (bin === 'bunx') return 'bunx.cmd'; + + return bin; +} + +function getFormatterRunner(projectRoot) { + const pm = getPackageManager({ projectDir: projectRoot }); + const execCmd = pm?.config?.execCmd || 'npx'; + const [bin = 'npx', ...prefix] = execCmd.split(/\s+/).filter(Boolean); + + return { + bin: getRunnerBin(bin), + prefix + }; +} + +function getFormatterCommand(formatter, filePath, projectRoot) { + const runner = getFormatterRunner(projectRoot); + if (formatter === 'biome') { - return { bin: npxBin, args: ['@biomejs/biome', 'format', '--write', filePath] }; + return { + bin: runner.bin, + args: [...runner.prefix, '@biomejs/biome', 'format', '--write', filePath] + }; } if (formatter === 'prettier') { - return { bin: npxBin, args: ['prettier', '--write', filePath] }; + return { + bin: runner.bin, + args: [...runner.prefix, 'prettier', '--write', filePath] + }; } return null; } @@ -80,7 +110,7 @@ process.stdin.on('end', () => { try { const projectRoot = findProjectRoot(path.dirname(path.resolve(filePath))); const formatter = detectFormatter(projectRoot); - const cmd = getFormatterCommand(formatter, filePath); + const cmd = getFormatterCommand(formatter, filePath, projectRoot); if (cmd) { execFileSync(cmd.bin, cmd.args, { diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 2c8732042..75fd40a52 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -75,6 +75,20 @@ function cleanupTestDir(testDir) { fs.rmSync(testDir, { recursive: true, force: true }); } +function createCommandShim(binDir, name, logFile) { + const shimPath = path.join(binDir, name); + const script = `#!/bin/sh +{ + printf '%s\n' "$(basename "$0")" + for arg in "$@"; do + printf '%s\n' "$arg" + done +} > ${JSON.stringify(logFile)} +`; + fs.writeFileSync(shimPath, script, { mode: 0o755 }); + return shimPath; +} + // Test suite async function runTests() { console.log('\n=== Testing Hook Scripts ===\n'); @@ -701,6 +715,64 @@ async function runTests() { assert.ok(result.stdout.includes('tool_input'), 'Should pass through original data'); })) passed++; else failed++; + if (await asyncTest('uses CLAUDE_PACKAGE_MANAGER runner for formatter fallback', async () => { + const testDir = createTestDir(); + const binDir = path.join(testDir, 'bin'); + const logFile = path.join(testDir, 'pnpm.log'); + fs.mkdirSync(binDir, { recursive: true }); + createCommandShim(binDir, 'pnpm', logFile); + + const testFile = path.join(testDir, 'src', 'example.ts'); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({ name: 'pm-env-test' })); + fs.writeFileSync(path.join(testDir, '.prettierrc'), '{}'); + fs.writeFileSync(testFile, 'const answer=42;\n'); + + try { + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson, { + CLAUDE_PACKAGE_MANAGER: 'pnpm', + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}` + }); + + assert.strictEqual(result.code, 0, 'Should exit 0 with pnpm fallback'); + const logLines = fs.readFileSync(logFile, 'utf8').trim().split('\n'); + assert.deepStrictEqual(logLines, ['pnpm', 'dlx', 'prettier', '--write', testFile]); + } finally { + cleanupTestDir(testDir); + } + })) passed++; else failed++; + + if (await asyncTest('uses project package manager config for formatter fallback', async () => { + const testDir = createTestDir(); + const binDir = path.join(testDir, 'bin'); + const logFile = path.join(testDir, 'bunx.log'); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(path.join(testDir, '.claude'), { recursive: true }); + createCommandShim(binDir, 'bunx', logFile); + + const testFile = path.join(testDir, 'src', 'example.ts'); + fs.mkdirSync(path.dirname(testFile), { recursive: true }); + fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({ name: 'pm-project-test' })); + fs.writeFileSync(path.join(testDir, 'biome.json'), '{}'); + fs.writeFileSync(path.join(testDir, '.claude', 'package-manager.json'), JSON.stringify({ packageManager: 'bun' })); + fs.writeFileSync(testFile, 'const answer=42;\n'); + + try { + const stdinJson = JSON.stringify({ tool_input: { file_path: testFile } }); + const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson, { + CLAUDE_PACKAGE_MANAGER: '', + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}` + }); + + assert.strictEqual(result.code, 0, 'Should exit 0 with project-config fallback'); + const logLines = fs.readFileSync(logFile, 'utf8').trim().split('\n'); + assert.deepStrictEqual(logLines, ['bunx', '@biomejs/biome', 'format', '--write', testFile]); + } finally { + cleanupTestDir(testDir); + } + })) passed++; else failed++; + // post-edit-typecheck.js tests console.log('\npost-edit-typecheck.js:');