diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 06eb64020..c5c336e68 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -56,7 +56,9 @@ function readToken(input, startIndex) { continue; } - if (ch === '\\' && quote === '"' && index + 1 < input.length) { + // Inside double quotes, bash drops a backslash only before $ ` " \ or a + // newline, so `"echo ready\;"` keeps its `\;` for tmux. + if (ch === '\\' && quote === '"' && index + 1 < input.length && '$`"\\\n'.includes(input[index + 1])) { token += input[index + 1]; index += 2; continue; @@ -104,7 +106,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 +147,7 @@ function getLeadingCommandWord(segment) { continue; } - return normalizedToken; + return { word: normalizedToken, end: index }; } return null; @@ -160,7 +162,19 @@ 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' +]); +// tmux's own options that take a value (`-L work`, `-Lwork`, or the last of a +// cluster such as `-vL work`). +const TMUX_OPTION_VALUE_LETTERS = 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 +213,51 @@ function collectCheckSegments(cmd) { return segments; } +/** + * Return true when the tmux invocation that starts at `index` runs the dev + * server in a pane. The tmux commands follow 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. A sequence + * (`new-session -d \; run-shell ...`) is exempt only when every command in it + * that runs the dev server is a pane command. + */ +function isTmuxPaneLaunch(segment, index) { + const tokens = []; + for (let parsed = readToken(segment, index); parsed; parsed = readToken(segment, parsed.end)) { + if (parsed.token) tokens.push(parsed.token); + } + + let next = 0; + for (; next < tokens.length && isOptionToken(tokens[next]); next++) { + const letters = tokens[next].slice(1); + for (let i = 0; i < letters.length; i++) { + // -c runs its shell command without a pane. + if (letters[i] === 'c') return false; + if (TMUX_OPTION_VALUE_LETTERS.has(letters[i])) { + if (i === letters.length - 1) next++; + break; + } + } + } + + // tmux ends a command at a `;` argument and at a `;` that ends an argument + // (`'npm run dev;'`); an escaped `\;` at the end stays literal. + const commands = [[]]; + for (const token of tokens.slice(next)) { + const ends = token.endsWith(';') && !token.endsWith('\\;'); + const value = ends ? token.slice(0, -1) : token; + if (value) commands[commands.length - 1].push(value); + if (ends) commands.push([]); + } + const devCommands = commands.filter(command => DEV_PATTERN.test(command.join(' '))); + return devCommands.length > 0 && devCommands.every(command => TMUX_PANE_COMMANDS.has(command[0])); +} + 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..a5d47c8e2 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -68,6 +68,25 @@ 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"', + "tmux -c'npm run dev'", + // In -vL the L takes `new-session` as its socket name, so run-shell is the command. + 'tmux -vL new-session run-shell "npm run dev"', + // A pane command earlier in a sequence does not cover a later run-shell. + 'tmux new-session -d \\; run-shell "npm run dev"', + 'tmux -L work new-session -d \\; run-shell "npm run dev"', + // A `;` that ends an argument separates tmux commands too. + "tmux -L work new-session -d 'npm run dev;' run-shell '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 +98,34 @@ 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"', + 'tmux new-session -d -s dev \\; send-keys -t dev "npm run dev" Enter', + "tmux new-session -d 'echo start;' send-keys -t dev 'npm run dev' Enter", + // In double quotes bash keeps the backslash before ;, so tmux reads a literal ; + 'tmux send-keys -t dev "echo ready\\;" "npm run dev" Enter' + ]) { + 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}`);