From efb99916e1cdfd2783dcfcf727a4abed3e49ca56 Mon Sep 17 00:00:00 2001 From: Puneet Dixit Date: Wed, 6 May 2026 11:22:32 +0530 Subject: [PATCH 1/7] fix(dev): run dev command through shell for operator support --- src/utils/shell.ts | 3 +++ tests/integration/framework-detection.test.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 62f8adbf95e..898bd3753eb 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -68,6 +68,9 @@ export const runCommand = ( const { cwd, env = {}, spinner } = options const commandProcess = execa.command(command, { preferLocal: true, + // Command strings in netlify.toml may use shell operators like `&&`. + // execa.command() does not interpret these unless shell mode is enabled. + shell: true, // we use reject=false to avoid rejecting synchronously when the command doesn't exist reject: false, env: { diff --git a/tests/integration/framework-detection.test.ts b/tests/integration/framework-detection.test.ts index fe32530e507..fa1e75338ce 100644 --- a/tests/integration/framework-detection.test.ts +++ b/tests/integration/framework-detection.test.ts @@ -130,6 +130,25 @@ describe.concurrent('frameworks/framework-detection', () => { }) }) + test('should support shell operators in `command`', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder + .withNetlifyToml({ config: { dev: { command: 'echo first && echo second', targetPort: 3000 } } }) + .build() + + try { + await withDevServer({ cwd: builder.directory }, async () => {}, true) + // a failure is expected since this command does not start a server + t.expect.unreachable() + } catch (err) { + t.expect(err).toHaveProperty('stdout') + const output = normalizeSnapshot((err as execa.ExecaReturnValue).stdout, { duration: true, filePath: true }) + t.expect(output).toMatch(/\nfirst\s*\r?\n\s*second\r?\n/i) + t.expect(output).not.toMatch(/\nfirst\s*&&\s*echo\s+second\r?\n/i) + } + }) + }) + test('should force a specific framework when configured', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.withNetlifyToml({ config: { dev: { framework: 'create-react-app' } } }).build() From 805a8d0c1f1d6115dbaa3df149335391f2670b79 Mon Sep 17 00:00:00 2001 From: puneetdixit200 <236133619+puneetdixit200@users.noreply.github.com> Date: Mon, 18 May 2026 18:59:02 +0530 Subject: [PATCH 2/7] Restore missing dev command detection --- src/utils/shell.ts | 35 ++++++++++++++----- tests/integration/framework-detection.test.ts | 4 +-- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 898bd3753eb..03b30676aa0 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -12,6 +12,22 @@ import { processOnExit } from './dev.js' const isErrnoException = (value: unknown): value is NodeJS.ErrnoException => value instanceof Error && Object.hasOwn(value, 'code') +type CommandResult = { + exitCode?: number + message?: string + shortMessage?: string + stderr?: string + stdout?: string +} + +const isCommandResult = (value: unknown): value is CommandResult => typeof value === 'object' && value !== null + +const getCommandName = (command: string) => { + const match = /^(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command.trim()) + + return match?.[1] ?? match?.[2] ?? match?.[3] ?? command +} + const createStripAnsiControlCharsStream = (): Transform => new Transform({ transform(chunk, _encoding, callback) { @@ -68,8 +84,6 @@ export const runCommand = ( const { cwd, env = {}, spinner } = options const commandProcess = execa.command(command, { preferLocal: true, - // Command strings in netlify.toml may use shell operators like `&&`. - // execa.command() does not interpret these unless shell mode is enabled. shell: true, // we use reject=false to avoid rejecting synchronously when the command doesn't exist reject: false, @@ -114,7 +128,7 @@ export const runCommand = ( // eslint-disable-next-line @typescript-eslint/no-floating-promises commandProcess.then(async () => { const result = await commandProcess - const [commandWithoutArgs] = command.split(' ') + const commandWithoutArgs = getCommandName(command) if (result.failed && isNonExistingCommandError({ command: commandWithoutArgs, error: result })) { log( `${NETLIFYDEVERR} Failed running command: ${command}. Please verify ${chalk.magenta( @@ -150,10 +164,13 @@ const isNonExistingCommandError = ({ command, error: commandError }: { command: return false } - // this only works on English versions of Windows - return ( - commandError instanceof Error && - typeof commandError.message === 'string' && - commandError.message.includes('is not recognized as an internal or external command') - ) + if (!isCommandResult(commandError)) { + return false + } + + const output = [commandError.message, commandError.shortMessage, commandError.stderr, commandError.stdout] + .filter((value): value is string => typeof value === 'string') + .join('\n') + + return commandError.exitCode === 127 || output.includes('is not recognized as an internal or external command') } diff --git a/tests/integration/framework-detection.test.ts b/tests/integration/framework-detection.test.ts index fa1e75338ce..4d55eba5ff5 100644 --- a/tests/integration/framework-detection.test.ts +++ b/tests/integration/framework-detection.test.ts @@ -143,8 +143,8 @@ describe.concurrent('frameworks/framework-detection', () => { } catch (err) { t.expect(err).toHaveProperty('stdout') const output = normalizeSnapshot((err as execa.ExecaReturnValue).stdout, { duration: true, filePath: true }) - t.expect(output).toMatch(/\nfirst\s*\r?\n\s*second\r?\n/i) - t.expect(output).not.toMatch(/\nfirst\s*&&\s*echo\s+second\r?\n/i) + t.expect(output).toMatch(/\nfirst[^\S\r\n]*\r?\n[^\S\r\n]*second\r?\n/i) + t.expect(output).not.toMatch(/\nfirst[^\S\r\n]*&&[^\S\r\n]*echo[^\S\r\n]+second\r?\n/i) } }) }) From 96d2c28be3a2ec3cc53b7c79f90b40a677dc9289 Mon Sep 17 00:00:00 2001 From: puneetdixit200 <236133619+puneetdixit200@users.noreply.github.com> Date: Tue, 19 May 2026 11:50:47 +0530 Subject: [PATCH 3/7] fix(dev): tighten command result guard --- src/utils/shell.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 03b30676aa0..4d0e119eae4 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -20,7 +20,14 @@ type CommandResult = { stdout?: string } -const isCommandResult = (value: unknown): value is CommandResult => typeof value === 'object' && value !== null +const isCommandResult = (value: unknown): value is CommandResult => + typeof value === 'object' && + value !== null && + (typeof (value as CommandResult).exitCode === 'number' || + typeof (value as CommandResult).message === 'string' || + typeof (value as CommandResult).shortMessage === 'string' || + typeof (value as CommandResult).stderr === 'string' || + typeof (value as CommandResult).stdout === 'string') const getCommandName = (command: string) => { const match = /^(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command.trim()) From 55dd6797ebc9136a86af3ef467b6fbe7941206e2 Mon Sep 17 00:00:00 2001 From: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com> Date: Wed, 20 May 2026 11:17:17 +0530 Subject: [PATCH 4/7] fix: avoid misleading missing command hints --- src/utils/shell.ts | 9 +++- tests/integration/framework-detection.test.ts | 43 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 4d0e119eae4..b91c61f353a 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -35,6 +35,9 @@ const getCommandName = (command: string) => { return match?.[1] ?? match?.[2] ?? match?.[3] ?? command } +const canReportMissingCommandName = (command: string) => + !/(?:&&|\|\||[|;<>])/.test(command) && !/^\s*[\w.-]+=/.test(command) + const createStripAnsiControlCharsStream = (): Transform => new Transform({ transform(chunk, _encoding, callback) { @@ -136,7 +139,11 @@ export const runCommand = ( commandProcess.then(async () => { const result = await commandProcess const commandWithoutArgs = getCommandName(command) - if (result.failed && isNonExistingCommandError({ command: commandWithoutArgs, error: result })) { + if ( + result.failed && + canReportMissingCommandName(command) && + isNonExistingCommandError({ command: commandWithoutArgs, error: result }) + ) { log( `${NETLIFYDEVERR} Failed running command: ${command}. Please verify ${chalk.magenta( `'${commandWithoutArgs}'`, diff --git a/tests/integration/framework-detection.test.ts b/tests/integration/framework-detection.test.ts index 4d55eba5ff5..6349fc726d4 100644 --- a/tests/integration/framework-detection.test.ts +++ b/tests/integration/framework-detection.test.ts @@ -1,5 +1,6 @@ import execa from 'execa' import fetch from 'node-fetch' +import process from 'node:process' import { describe, test } from 'vitest' import { cliPath } from './utils/cli-path.js' @@ -149,6 +150,48 @@ describe.concurrent('frameworks/framework-detection', () => { }) }) + test('should use generic command failure when a compound command fails', async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder + .withNetlifyToml({ config: { dev: { command: 'echo before && oops-i-did-it-again', targetPort: 3000 } } }) + .build() + + try { + await withDevServer({ cwd: builder.directory }, async () => {}, true) + // a failure is expected since the second command does not exist + t.expect.unreachable() + } catch (err) { + t.expect(err).toHaveProperty('stdout') + const output = normalizeSnapshot((err as execa.ExecaReturnValue).stdout, { duration: true, filePath: true }) + t.expect(output).toContain('before') + t.expect(output).toContain('Command failed with exit code *') + t.expect(output).not.toContain("Please verify 'echo' exists") + } + }) + }) + + test.skipIf(process.platform === 'win32')( + 'should use generic command failure when an env-assigned command fails', + async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder + .withNetlifyToml({ config: { dev: { command: 'FOO=1 oops-i-did-it-again', targetPort: 3000 } } }) + .build() + + try { + await withDevServer({ cwd: builder.directory }, async () => {}, true) + // a failure is expected since the command after the assignment does not exist + t.expect.unreachable() + } catch (err) { + t.expect(err).toHaveProperty('stdout') + const output = normalizeSnapshot((err as execa.ExecaReturnValue).stdout, { duration: true, filePath: true }) + t.expect(output).toContain('Command failed with exit code *') + t.expect(output).not.toContain("Please verify 'FOO=1' exists") + } + }) + }, + ) + test('should force a specific framework when configured', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.withNetlifyToml({ config: { dev: { framework: 'create-react-app' } } }).build() From 4f17001bc92793d16d771c681a342a1d9a569e97 Mon Sep 17 00:00:00 2001 From: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com> Date: Fri, 22 May 2026 14:57:36 +0530 Subject: [PATCH 5/7] fix(dev): avoid missing command hint for exit 127 --- src/utils/shell.ts | 14 ++++++++++++- tests/integration/framework-detection.test.ts | 20 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/utils/shell.ts b/src/utils/shell.ts index b91c61f353a..e278ec63c3f 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -38,6 +38,18 @@ const getCommandName = (command: string) => { const canReportMissingCommandName = (command: string) => !/(?:&&|\|\||[|;<>])/.test(command) && !/^\s*[\w.-]+=/.test(command) +const isMissingCommandMessage = ({ command, output }: { command: string; output: string }) => + output.split(/\r?\n/).some((line) => { + const normalizedLine = line.toLowerCase() + const normalizedCommand = command.toLowerCase() + + return ( + normalizedLine.includes(normalizedCommand) && + (normalizedLine.includes('not found') || + normalizedLine.includes('is not recognized as an internal or external command')) + ) + }) + const createStripAnsiControlCharsStream = (): Transform => new Transform({ transform(chunk, _encoding, callback) { @@ -186,5 +198,5 @@ const isNonExistingCommandError = ({ command, error: commandError }: { command: .filter((value): value is string => typeof value === 'string') .join('\n') - return commandError.exitCode === 127 || output.includes('is not recognized as an internal or external command') + return isMissingCommandMessage({ command, output }) } diff --git a/tests/integration/framework-detection.test.ts b/tests/integration/framework-detection.test.ts index 6349fc726d4..0f22267cbdd 100644 --- a/tests/integration/framework-detection.test.ts +++ b/tests/integration/framework-detection.test.ts @@ -192,6 +192,26 @@ describe.concurrent('frameworks/framework-detection', () => { }, ) + test.skipIf(process.platform === 'win32')( + 'should use generic command failure when an existing command exits with 127', + async (t) => { + await withSiteBuilder(t, async (builder) => { + await builder.withNetlifyToml({ config: { dev: { command: 'bash -c "exit 127"', targetPort: 3000 } } }).build() + + try { + await withDevServer({ cwd: builder.directory }, async () => {}, true) + // a failure is expected since this command intentionally exits with 127 + t.expect.unreachable() + } catch (err) { + t.expect(err).toHaveProperty('stdout') + const output = normalizeSnapshot((err as execa.ExecaReturnValue).stdout, { duration: true, filePath: true }) + t.expect(output).toContain('Command failed with exit code *') + t.expect(output).not.toContain("Please verify 'bash' exists") + } + }) + }, + ) + test('should force a specific framework when configured', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.withNetlifyToml({ config: { dev: { framework: 'create-react-app' } } }).build() From 024d61d294bf7d11af8f4397122a9131ba59fc2f Mon Sep 17 00:00:00 2001 From: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com> Date: Wed, 27 May 2026 01:17:52 +0530 Subject: [PATCH 6/7] fix(dev): limit shell mode to compound commands --- src/utils/shell.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utils/shell.ts b/src/utils/shell.ts index e278ec63c3f..c0794adcb21 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -38,6 +38,9 @@ const getCommandName = (command: string) => { const canReportMissingCommandName = (command: string) => !/(?:&&|\|\||[|;<>])/.test(command) && !/^\s*[\w.-]+=/.test(command) +const shouldUseShell = (command: string) => + /(?:&&|\|\||[|;<>])/.test(command) || /^\s*[\w.-]+=(?:"[^"]*"|'[^']*'|\S+)\s+\S/.test(command) + const isMissingCommandMessage = ({ command, output }: { command: string; output: string }) => output.split(/\r?\n/).some((line) => { const normalizedLine = line.toLowerCase() @@ -106,7 +109,7 @@ export const runCommand = ( const { cwd, env = {}, spinner } = options const commandProcess = execa.command(command, { preferLocal: true, - shell: true, + shell: shouldUseShell(command), // we use reject=false to avoid rejecting synchronously when the command doesn't exist reject: false, env: { From 0f83dddf5f4841a56a3a13b7c5b7986cc6d5418c Mon Sep 17 00:00:00 2001 From: puneetdixit200 Date: Wed, 27 May 2026 03:24:49 +0530 Subject: [PATCH 7/7] test: cover shell command helpers --- src/utils/shell.ts | 8 +-- tests/integration/framework-detection.test.ts | 63 ------------------- tests/unit/utils/shell.test.ts | 54 ++++++++++++++++ 3 files changed, 58 insertions(+), 67 deletions(-) create mode 100644 tests/unit/utils/shell.test.ts diff --git a/src/utils/shell.ts b/src/utils/shell.ts index c0794adcb21..b526bbf9d6f 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -29,16 +29,16 @@ const isCommandResult = (value: unknown): value is CommandResult => typeof (value as CommandResult).stderr === 'string' || typeof (value as CommandResult).stdout === 'string') -const getCommandName = (command: string) => { +export const getCommandName = (command: string) => { const match = /^(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command.trim()) return match?.[1] ?? match?.[2] ?? match?.[3] ?? command } -const canReportMissingCommandName = (command: string) => +export const canReportMissingCommandName = (command: string) => !/(?:&&|\|\||[|;<>])/.test(command) && !/^\s*[\w.-]+=/.test(command) -const shouldUseShell = (command: string) => +export const shouldUseShell = (command: string) => /(?:&&|\|\||[|;<>])/.test(command) || /^\s*[\w.-]+=(?:"[^"]*"|'[^']*'|\S+)\s+\S/.test(command) const isMissingCommandMessage = ({ command, output }: { command: string; output: string }) => @@ -181,7 +181,7 @@ export const runCommand = ( return commandProcess } -const isNonExistingCommandError = ({ command, error: commandError }: { command: string; error: unknown }) => { +export const isNonExistingCommandError = ({ command, error: commandError }: { command: string; error: unknown }) => { // `ENOENT` is only returned for non Windows systems // See https://github.com/sindresorhus/execa/pull/447 if (isErrnoException(commandError) && commandError.code === 'ENOENT') { diff --git a/tests/integration/framework-detection.test.ts b/tests/integration/framework-detection.test.ts index 0f22267cbdd..4d55eba5ff5 100644 --- a/tests/integration/framework-detection.test.ts +++ b/tests/integration/framework-detection.test.ts @@ -1,6 +1,5 @@ import execa from 'execa' import fetch from 'node-fetch' -import process from 'node:process' import { describe, test } from 'vitest' import { cliPath } from './utils/cli-path.js' @@ -150,68 +149,6 @@ describe.concurrent('frameworks/framework-detection', () => { }) }) - test('should use generic command failure when a compound command fails', async (t) => { - await withSiteBuilder(t, async (builder) => { - await builder - .withNetlifyToml({ config: { dev: { command: 'echo before && oops-i-did-it-again', targetPort: 3000 } } }) - .build() - - try { - await withDevServer({ cwd: builder.directory }, async () => {}, true) - // a failure is expected since the second command does not exist - t.expect.unreachable() - } catch (err) { - t.expect(err).toHaveProperty('stdout') - const output = normalizeSnapshot((err as execa.ExecaReturnValue).stdout, { duration: true, filePath: true }) - t.expect(output).toContain('before') - t.expect(output).toContain('Command failed with exit code *') - t.expect(output).not.toContain("Please verify 'echo' exists") - } - }) - }) - - test.skipIf(process.platform === 'win32')( - 'should use generic command failure when an env-assigned command fails', - async (t) => { - await withSiteBuilder(t, async (builder) => { - await builder - .withNetlifyToml({ config: { dev: { command: 'FOO=1 oops-i-did-it-again', targetPort: 3000 } } }) - .build() - - try { - await withDevServer({ cwd: builder.directory }, async () => {}, true) - // a failure is expected since the command after the assignment does not exist - t.expect.unreachable() - } catch (err) { - t.expect(err).toHaveProperty('stdout') - const output = normalizeSnapshot((err as execa.ExecaReturnValue).stdout, { duration: true, filePath: true }) - t.expect(output).toContain('Command failed with exit code *') - t.expect(output).not.toContain("Please verify 'FOO=1' exists") - } - }) - }, - ) - - test.skipIf(process.platform === 'win32')( - 'should use generic command failure when an existing command exits with 127', - async (t) => { - await withSiteBuilder(t, async (builder) => { - await builder.withNetlifyToml({ config: { dev: { command: 'bash -c "exit 127"', targetPort: 3000 } } }).build() - - try { - await withDevServer({ cwd: builder.directory }, async () => {}, true) - // a failure is expected since this command intentionally exits with 127 - t.expect.unreachable() - } catch (err) { - t.expect(err).toHaveProperty('stdout') - const output = normalizeSnapshot((err as execa.ExecaReturnValue).stdout, { duration: true, filePath: true }) - t.expect(output).toContain('Command failed with exit code *') - t.expect(output).not.toContain("Please verify 'bash' exists") - } - }) - }, - ) - test('should force a specific framework when configured', async (t) => { await withSiteBuilder(t, async (builder) => { await builder.withNetlifyToml({ config: { dev: { framework: 'create-react-app' } } }).build() diff --git a/tests/unit/utils/shell.test.ts b/tests/unit/utils/shell.test.ts new file mode 100644 index 00000000000..ba44276b20c --- /dev/null +++ b/tests/unit/utils/shell.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from 'vitest' + +import { + canReportMissingCommandName, + getCommandName, + isNonExistingCommandError, + shouldUseShell, +} from '../../../src/utils/shell.js' + +describe('shell command helpers', () => { + test('uses a shell only when command syntax requires it', () => { + expect(shouldUseShell('npm run dev')).toBe(false) + expect(shouldUseShell('echo first && echo second')).toBe(true) + expect(shouldUseShell('npm run build || npm run fallback')).toBe(true) + expect(shouldUseShell('FOO=1 npm run dev')).toBe(true) + }) + + test('extracts quoted and unquoted command names', () => { + expect(getCommandName('npm run dev')).toBe('npm') + expect(getCommandName('"my command" --flag')).toBe('my command') + expect(getCommandName("'my command' --flag")).toBe('my command') + }) + + test('does not report a single missing command for compound shell syntax', () => { + expect(canReportMissingCommandName('npm run dev')).toBe(true) + expect(canReportMissingCommandName('echo before && missing-command')).toBe(false) + expect(canReportMissingCommandName('FOO=1 missing-command')).toBe(false) + }) + + test('detects missing command output without treating package managers as missing commands', () => { + expect( + isNonExistingCommandError({ + command: 'missing-command', + error: { stderr: 'sh: missing-command: command not found', exitCode: 127 }, + }), + ).toBe(true) + + expect( + isNonExistingCommandError({ + command: 'npm', + error: { stderr: 'sh: npm: command not found', exitCode: 127 }, + }), + ).toBe(false) + }) + + test('does not classify an existing command exit 127 as a missing command', () => { + expect( + isNonExistingCommandError({ + command: 'bash', + error: { shortMessage: 'Command failed with exit code 127: bash -c "exit 127"', exitCode: 127 }, + }), + ).toBe(false) + }) +})