From 99aa85dde9f740a56e6cc37717bb0febaf6b402c Mon Sep 17 00:00:00 2001 From: L4XB Date: Thu, 22 Oct 2026 21:15:25 +0200 Subject: [PATCH] fix(hooks): let the dev-server block see a tmux launch past tmux options The block allowed a dev server only when the segment began with `tmux new`, `new-session`, `new-window` or `split-window`. It therefore blocked dev servers that do start in a tmux pane, the way its own message asks: `tmux -L work new-session ...` and `tmux -f conf new-session ...` (tmux options before the command), `/opt/homebrew/bin/tmux new-session ...` and `env TERM=... tmux new-session ...`, the aliases `neww` and `splitw`, and `tmux send-keys -t dev 'npm run dev' Enter` or `respawn-pane` for a session that exists. Read the tmux command from the parsed segment instead, after the leading command word and tmux's own options, and allow the commands that run their shell command in a pane. `tmux run-shell` and `tmux -c` run it without one and stay blocked. --- scripts/hooks/pre-bash-dev-server-block.js | 52 ++++++++++++++++--- tests/hooks/pre-bash-dev-server-block.test.js | 28 ++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 06eb64020..5dcaddb56 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -104,7 +104,7 @@ function normalizeCommandWord(token) { return base.replace(/\.(cmd|exe|bat)$/i, ''); } -function getLeadingCommandWord(segment) { +function getLeadingCommand(segment) { let index = 0; let activeWrapper = null; let skipNextValue = false; @@ -145,7 +145,7 @@ function getLeadingCommandWord(segment) { continue; } - return normalizedToken; + return { word: normalizedToken, end: index }; } return null; @@ -160,7 +160,18 @@ process.stdin.on('data', chunk => { } }); -const TMUX_LAUNCHER = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/; +// tmux commands, and their aliases, that run their shell command in a tmux pane, +// where its output stays readable. run-shell and `tmux -c` run it without one. +const TMUX_PANE_COMMANDS = new Set([ + 'new-session', 'new', + 'new-window', 'neww', + 'split-window', 'splitw', + 'respawn-pane', 'respawnp', + 'respawn-window', 'respawnw', + 'send-keys', 'send' +]); +// Options that come before the tmux command and take a value. +const TMUX_OPTIONS_WITH_VALUE = new Set(['-f', '-L', '-S', '-T']); // Trailing (?![\w-]) rather than \b: \b treats a hyphen as a word boundary, so // `dev\b` matches the `dev` prefix of distinct scripts like `dev-setup` / // `dev-docs` / `dev-build` and wrongly blocks them. The lookahead still matches @@ -199,10 +210,39 @@ function collectCheckSegments(cmd) { return segments; } +/** + * Return true when the tmux invocation that starts at `index` runs its command + * in a pane. The tmux command follows tmux's own options (`-L work`, `-f conf`), + * and the executable may come through a path or an env prefix, so this reads + * the parsed tokens instead of the start of the segment. + */ +function isTmuxPaneLaunch(segment, index) { + while (index < segment.length) { + const parsed = readToken(segment, index); + if (!parsed) return false; + index = parsed.end; + + const token = parsed.token; + if (!token) continue; + if (token === '-c') return false; + if (TMUX_OPTIONS_WITH_VALUE.has(token)) { + const value = readToken(segment, index); + if (!value) return false; + index = value.end; + continue; + } + if (isOptionToken(token)) continue; + return TMUX_PANE_COMMANDS.has(token); + } + + return false; +} + function isBlockedDevSegment(segment) { - const commandWord = getLeadingCommandWord(segment); - if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) return false; - return DEV_PATTERN.test(segment) && !TMUX_LAUNCHER.test(segment); + const command = getLeadingCommand(segment); + if (!command || !DEV_COMMAND_WORDS.has(command.word)) return false; + if (!DEV_PATTERN.test(segment)) return false; + return !(command.word === 'tmux' && isTmuxPaneLaunch(segment, command.end)); } process.stdin.on('end', () => { diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 89524b1e0..695a16846 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -68,6 +68,13 @@ function runTests() { const result = runScript('npm run dev:ssr'); assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); }) ? passed++ : failed++); + + (test('still blocks tmux run-shell and tmux -c — no pane keeps the log (exit 2)', () => { + for (const command of ['tmux run-shell "npm run dev"', 'tmux -L work run-shell "npm run dev"', 'tmux -c "npm run dev"']) { + const result = runScript(command); + assert.strictEqual(result.code, 2, `Expected exit code 2 for ${command}, got ${result.code}`); + } + }) ? passed++ : failed++); } else { console.log(' (skipping blocking tests on Windows)\n'); } @@ -79,6 +86,27 @@ function runTests() { assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); }) ? passed++ : failed++); + (test('allows a tmux launch after tmux options, a path, an env prefix or a command alias (exit code 0)', () => { + for (const command of [ + 'tmux -L work new-session -d -s dev "npm run dev"', + 'tmux -f ~/.tmux.dev.conf new-session -d "npm run dev"', + '/opt/homebrew/bin/tmux new-session -d -s dev "npm run dev"', + 'env TERM=xterm-256color tmux new-session -d "npm run dev"', + 'tmux neww "npm run dev"', + 'tmux splitw -h "pnpm dev"' + ]) { + const result = runScript(command); + assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); + } + }) ? passed++ : failed++); + + (test('allows npm run dev sent to or respawned in a tmux pane (exit code 0)', () => { + for (const command of ['tmux send-keys -t dev "npm run dev" Enter', 'tmux respawn-pane -k -t dev "npm run dev"']) { + const result = runScript(command); + assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`); + } + }) ? passed++ : failed++); + (test('allows npm install (exit code 0)', () => { const result = runScript('npm install'); assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);