From 177dd36e2338175c4be6d8f68b1e1a8c1e2b7bb0 Mon Sep 17 00:00:00 2001 From: zzzhizhi <77013105+zzzhizhia@users.noreply.github.com> Date: Sun, 8 Mar 2026 06:47:49 +0800 Subject: [PATCH] fix(hooks): allow tmux-wrapped dev server commands (#321) * fix(hooks): fix shell splitter redirection/escape bugs, extract shared module - Fix single & incorrectly splitting redirection operators (&>, >&, 2>&1) - Fix escaped quotes (\", \') not being handled inside quoted strings - Extract splitShellSegments into shared scripts/lib/shell-split.js to eliminate duplication between hooks.json, before-shell-execution.js, and pre-bash-dev-server-block.js - Add comprehensive tests for shell splitting edge cases * fix(hooks): handle backslash escapes outside quotes in shell splitter Escaped operators like \&& and \; outside quotes were still being treated as separators. Add escape handling for unquoted context. --- .cursor/hooks/before-shell-execution.js | 99 ++++++------------ scripts/hooks/pre-bash-dev-server-block.js | 35 +------ scripts/lib/shell-split.js | 86 ++++++++++++++++ tests/lib/shell-split.test.js | 114 +++++++++++++++++++++ 4 files changed, 235 insertions(+), 99 deletions(-) create mode 100644 scripts/lib/shell-split.js create mode 100644 tests/lib/shell-split.test.js diff --git a/.cursor/hooks/before-shell-execution.js b/.cursor/hooks/before-shell-execution.js index 24b8af8c..fbcf8e0a 100644 --- a/.cursor/hooks/before-shell-execution.js +++ b/.cursor/hooks/before-shell-execution.js @@ -1,72 +1,41 @@ #!/usr/bin/env node const { readStdin, hookEnabled } = require('./adapter'); +const { splitShellSegments } = require('../../scripts/lib/shell-split'); -function splitShellSegments(command) { - const segments = []; - let current = ''; - let quote = null; +readStdin() + .then(raw => { + try { + const input = JSON.parse(raw || '{}'); + const cmd = String(input.command || input.args?.command || ''); - for (let i = 0; i < command.length; i++) { - const ch = command[i]; - if (quote) { - if (ch === quote) quote = null; - current += ch; - continue; - } - - if (ch === '"' || ch === "'") { - quote = ch; - current += ch; - continue; - } - - const next = command[i + 1] || ''; - if (ch === ';' || (ch === '&' && next === '&') || (ch === '|' && next === '|') || (ch === '&' && next !== '&')) { - if (current.trim()) segments.push(current.trim()); - current = ''; - if ((ch === '&' && next === '&') || (ch === '|' && next === '|')) i++; - continue; - } - - current += ch; - } - - if (current.trim()) segments.push(current.trim()); - return segments; -} - -readStdin().then(raw => { - try { - const input = JSON.parse(raw || '{}'); - const cmd = String(input.command || input.args?.command || ''); - - if (hookEnabled('pre:bash:dev-server-block', ['standard', 'strict']) && process.platform !== 'win32') { - const segments = splitShellSegments(cmd); - const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/; - const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev)\b/; - const hasBlockedDev = segments.some(segment => devPattern.test(segment) && !tmuxLauncher.test(segment)); - if (hasBlockedDev) { - console.error('[ECC] BLOCKED: Dev server must run in tmux for log access'); - console.error('[ECC] Use: tmux new-session -d -s dev "npm run dev"'); - process.exit(2); + if (hookEnabled('pre:bash:dev-server-block', ['standard', 'strict']) && process.platform !== 'win32') { + const segments = splitShellSegments(cmd); + const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/; + const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev)\b/; + const hasBlockedDev = segments.some(segment => devPattern.test(segment) && !tmuxLauncher.test(segment)); + if (hasBlockedDev) { + console.error('[ECC] BLOCKED: Dev server must run in tmux for log access'); + console.error('[ECC] Use: tmux new-session -d -s dev "npm run dev"'); + process.exit(2); + } } + + if ( + hookEnabled('pre:bash:tmux-reminder', ['strict']) && + process.platform !== 'win32' && + !process.env.TMUX && + /(npm (install|test)|pnpm (install|test)|yarn (install|test)?|bun (install|test)|cargo build|make\b|docker\b|pytest|vitest|playwright)/.test(cmd) + ) { + console.error('[ECC] Consider running in tmux for session persistence'); + } + + if (hookEnabled('pre:bash:git-push-reminder', ['strict']) && /\bgit\s+push\b/.test(cmd)) { + console.error('[ECC] Review changes before push: git diff origin/main...HEAD'); + } + } catch { + // noop } - if ( - hookEnabled('pre:bash:tmux-reminder', ['strict']) && - process.platform !== 'win32' && - !process.env.TMUX && - /(npm (install|test)|pnpm (install|test)|yarn (install|test)?|bun (install|test)|cargo build|make\b|docker\b|pytest|vitest|playwright)/.test(cmd) - ) { - console.error('[ECC] Consider running in tmux for session persistence'); - } - - if (hookEnabled('pre:bash:git-push-reminder', ['strict']) && /\bgit\s+push\b/.test(cmd)) { - console.error('[ECC] Review changes before push: git diff origin/main...HEAD'); - } - } catch { - // noop - } - - process.stdout.write(raw); -}).catch(() => process.exit(0)); + process.stdout.write(raw); + }) + .catch(() => process.exit(0)); diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 0f728f00..26b2a555 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -2,40 +2,7 @@ 'use strict'; const MAX_STDIN = 1024 * 1024; - -function splitShellSegments(command) { - const segments = []; - let current = ''; - let quote = null; - - for (let i = 0; i < command.length; i++) { - const ch = command[i]; - if (quote) { - if (ch === quote) quote = null; - current += ch; - continue; - } - - if (ch === '"' || ch === "'") { - quote = ch; - current += ch; - continue; - } - - const next = command[i + 1] || ''; - if (ch === ';' || (ch === '&' && next === '&') || (ch === '|' && next === '|') || (ch === '&' && next !== '&')) { - if (current.trim()) segments.push(current.trim()); - current = ''; - if ((ch === '&' && next === '&') || (ch === '|' && next === '|')) i++; - continue; - } - - current += ch; - } - - if (current.trim()) segments.push(current.trim()); - return segments; -} +const { splitShellSegments } = require('../lib/shell-split'); let raw = ''; process.stdin.setEncoding('utf8'); diff --git a/scripts/lib/shell-split.js b/scripts/lib/shell-split.js new file mode 100644 index 00000000..0d096237 --- /dev/null +++ b/scripts/lib/shell-split.js @@ -0,0 +1,86 @@ +'use strict'; + +/** + * Split a shell command into segments by operators (&&, ||, ;, &) + * while respecting quoting (single/double) and escaped characters. + * Redirection operators (&>, >&, 2>&1) are NOT treated as separators. + */ +function splitShellSegments(command) { + const segments = []; + let current = ''; + let quote = null; + + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + + // Inside quotes: handle escapes and closing quote + if (quote) { + if (ch === '\\' && i + 1 < command.length) { + current += ch + command[i + 1]; + i++; + continue; + } + if (ch === quote) quote = null; + current += ch; + continue; + } + + // Backslash escape outside quotes + if (ch === '\\' && i + 1 < command.length) { + current += ch + command[i + 1]; + i++; + continue; + } + + // Opening quote + if (ch === '"' || ch === "'") { + quote = ch; + current += ch; + continue; + } + + const next = command[i + 1] || ''; + const prev = i > 0 ? command[i - 1] : ''; + + // && operator + if (ch === '&' && next === '&') { + if (current.trim()) segments.push(current.trim()); + current = ''; + i++; + continue; + } + + // || operator + if (ch === '|' && next === '|') { + if (current.trim()) segments.push(current.trim()); + current = ''; + i++; + continue; + } + + // ; separator + if (ch === ';') { + if (current.trim()) segments.push(current.trim()); + current = ''; + continue; + } + + // Single & — but skip redirection patterns (&>, >&, digit>&) + if (ch === '&' && next !== '&') { + if (next === '>' || prev === '>') { + current += ch; + continue; + } + if (current.trim()) segments.push(current.trim()); + current = ''; + continue; + } + + current += ch; + } + + if (current.trim()) segments.push(current.trim()); + return segments; +} + +module.exports = { splitShellSegments }; diff --git a/tests/lib/shell-split.test.js b/tests/lib/shell-split.test.js new file mode 100644 index 00000000..1ec1cdb2 --- /dev/null +++ b/tests/lib/shell-split.test.js @@ -0,0 +1,114 @@ +'use strict'; +const assert = require('assert'); +const { splitShellSegments } = require('../../scripts/lib/shell-split'); + +console.log('=== Testing shell-split.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +// Basic operators +console.log('Basic operators:'); +test('&& splits into two segments', () => { + assert.deepStrictEqual(splitShellSegments('echo hi && echo bye'), ['echo hi', 'echo bye']); +}); +test('|| splits into two segments', () => { + assert.deepStrictEqual(splitShellSegments('echo hi || echo bye'), ['echo hi', 'echo bye']); +}); +test('; splits into two segments', () => { + assert.deepStrictEqual(splitShellSegments('echo hi; echo bye'), ['echo hi', 'echo bye']); +}); +test('single & splits (background)', () => { + assert.deepStrictEqual(splitShellSegments('sleep 1 & echo hi'), ['sleep 1', 'echo hi']); +}); + +// Redirection operators should NOT split +console.log('\nRedirection operators (should NOT split):'); +test('2>&1 stays as one segment', () => { + const segs = splitShellSegments('cmd 2>&1 | grep error'); + assert.strictEqual(segs.length, 1); +}); +test('&> stays as one segment', () => { + const segs = splitShellSegments('cmd &> /dev/null'); + assert.strictEqual(segs.length, 1); +}); +test('>& stays as one segment', () => { + const segs = splitShellSegments('cmd >& /dev/null'); + assert.strictEqual(segs.length, 1); +}); + +// Quoting +console.log('\nQuoting:'); +test('double-quoted && not split', () => { + const segs = splitShellSegments('tmux new -d "cd /app && echo hi"'); + assert.strictEqual(segs.length, 1); +}); +test('single-quoted && not split', () => { + const segs = splitShellSegments("tmux new -d 'cd /app && echo hi'"); + assert.strictEqual(segs.length, 1); +}); +test('double-quoted ; not split', () => { + const segs = splitShellSegments('echo "hello; world"'); + assert.strictEqual(segs.length, 1); +}); + +// Escaped quotes +console.log('\nEscaped quotes:'); +test('escaped double quote inside double quotes', () => { + const segs = splitShellSegments('echo "hello \\"world\\"" && echo bye'); + assert.strictEqual(segs.length, 2); +}); +test('escaped single quote inside single quotes', () => { + const segs = splitShellSegments("echo 'hello \\'world\\'' && echo bye"); + assert.strictEqual(segs.length, 2); +}); + +// Escaped operators outside quotes +console.log('\nEscaped operators outside quotes:'); +test('escaped && outside quotes not split', () => { + const segs = splitShellSegments('tmux new-session -d bash -lc cd /app \\&\\& npm run dev'); + assert.strictEqual(segs.length, 1); +}); +test('escaped ; outside quotes not split', () => { + const segs = splitShellSegments('echo hello \\; echo bye'); + assert.strictEqual(segs.length, 1); +}); + +// Complex real-world cases +console.log('\nReal-world cases:'); +test('tmux new-session with quoted compound command', () => { + const segs = splitShellSegments('tmux new-session -d -s dev "cd /app && npm run dev"'); + assert.strictEqual(segs.length, 1); + assert.ok(segs[0].includes('tmux')); + assert.ok(segs[0].includes('npm run dev')); +}); +test('chained: tmux ls then bare dev', () => { + const segs = splitShellSegments('tmux ls; npm run dev'); + assert.strictEqual(segs.length, 2); + assert.strictEqual(segs[1], 'npm run dev'); +}); +test('background dev server', () => { + const segs = splitShellSegments('npm run dev & echo started'); + assert.strictEqual(segs.length, 2); + assert.strictEqual(segs[0], 'npm run dev'); +}); +test('empty string returns empty array', () => { + assert.deepStrictEqual(splitShellSegments(''), []); +}); +test('single command no operators', () => { + assert.deepStrictEqual(splitShellSegments('npm run dev'), ['npm run dev']); +}); + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +if (failed > 0) process.exit(1);