diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 62f8adbf95e..b526bbf9d6f 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -12,6 +12,47 @@ 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 && + (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') + +export const getCommandName = (command: string) => { + const match = /^(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(command.trim()) + + return match?.[1] ?? match?.[2] ?? match?.[3] ?? command +} + +export const canReportMissingCommandName = (command: string) => + !/(?:&&|\|\||[|;<>])/.test(command) && !/^\s*[\w.-]+=/.test(command) + +export 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() + 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) { @@ -68,6 +109,7 @@ export const runCommand = ( const { cwd, env = {}, spinner } = options const commandProcess = execa.command(command, { preferLocal: true, + shell: shouldUseShell(command), // we use reject=false to avoid rejecting synchronously when the command doesn't exist reject: false, env: { @@ -111,8 +153,12 @@ export const runCommand = ( // eslint-disable-next-line @typescript-eslint/no-floating-promises commandProcess.then(async () => { const result = await commandProcess - const [commandWithoutArgs] = command.split(' ') - if (result.failed && isNonExistingCommandError({ command: commandWithoutArgs, error: result })) { + const commandWithoutArgs = getCommandName(command) + if ( + result.failed && + canReportMissingCommandName(command) && + isNonExistingCommandError({ command: commandWithoutArgs, error: result }) + ) { log( `${NETLIFYDEVERR} Failed running command: ${command}. Please verify ${chalk.magenta( `'${commandWithoutArgs}'`, @@ -135,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') { @@ -147,10 +193,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 isMissingCommandMessage({ command, output }) } diff --git a/tests/integration/framework-detection.test.ts b/tests/integration/framework-detection.test.ts index fe32530e507..4d55eba5ff5 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]*\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) + } + }) + }) + 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) + }) +})