diff --git a/packages/cli-platform-android/src/commands/buildAndroid/index.ts b/packages/cli-platform-android/src/commands/buildAndroid/index.ts index 1441978ef..4205ca761 100644 --- a/packages/cli-platform-android/src/commands/buildAndroid/index.ts +++ b/packages/cli-platform-android/src/commands/buildAndroid/index.ts @@ -2,6 +2,7 @@ import { CLIError, logger, printRunDoctorTip, + tokenize, } from '@react-native-community/cli-tools'; import {Config} from '@react-native-community/cli-types'; import execa from 'execa'; @@ -115,7 +116,7 @@ export const options = [ { name: '--extra-params ', description: 'Custom params passed to gradle build command', - parse: (val: string) => val.split(' '), + parse: tokenize, }, { name: '-i --interactive', diff --git a/packages/cli-platform-apple/src/commands/buildCommand/buildOptions.ts b/packages/cli-platform-apple/src/commands/buildCommand/buildOptions.ts index 303431e4e..89cf63ad2 100644 --- a/packages/cli-platform-apple/src/commands/buildCommand/buildOptions.ts +++ b/packages/cli-platform-apple/src/commands/buildCommand/buildOptions.ts @@ -1,3 +1,4 @@ +import {tokenize} from '@react-native-community/cli-tools'; import {BuilderCommand} from '../../types'; import {getPlatformInfo} from '../runCommand/getPlatformInfo'; @@ -49,7 +50,7 @@ export const getBuildOptions = ({platformName}: BuilderCommand) => { { name: '--extra-params ', description: 'Custom params that will be passed to xcodebuild command.', - parse: (val: string) => val.split(' '), + parse: tokenize, }, { name: '--target ', diff --git a/packages/cli-platform-apple/src/commands/buildCommand/buildProject.ts b/packages/cli-platform-apple/src/commands/buildCommand/buildProject.ts index 5f6f2b547..89e0611e8 100644 --- a/packages/cli-platform-apple/src/commands/buildCommand/buildProject.ts +++ b/packages/cli-platform-apple/src/commands/buildCommand/buildProject.ts @@ -86,10 +86,13 @@ export function buildProject( } const loader = getLoader(); + // Wrap arguments containing whitespace in quotes so the logged command stays + // copy-pasteable (e.g. a `-destination` value like `iOS Simulator`). + const printableArgs = xcodebuildArgs + .map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)) + .join(' '); logger.info( - `Building ${pico.dim( - `(using "xcodebuild ${xcodebuildArgs.join(' ')}")`, - )}`, + `Building ${pico.dim(`(using "xcodebuild ${printableArgs}")`)}`, ); let xcodebuildOutputFormatter: ChildProcess | any; if (!args.verbose) { diff --git a/packages/cli-tools/src/__tests__/tokenize.test.ts b/packages/cli-tools/src/__tests__/tokenize.test.ts new file mode 100644 index 000000000..a11ebee4b --- /dev/null +++ b/packages/cli-tools/src/__tests__/tokenize.test.ts @@ -0,0 +1,96 @@ +import tokenize from '../tokenize'; + +describe('tokenize', () => { + it('splits space-separated xcodebuild arguments', () => { + expect(tokenize('-scheme MyApp -configuration Release')).toEqual([ + '-scheme', + 'MyApp', + '-configuration', + 'Release', + ]); + }); + + it('splits space-separated gradle properties', () => { + expect( + tokenize('-PnewArchEnabled=true -PreactNativeArchitectures=arm64-v8a'), + ).toEqual([ + '-PnewArchEnabled=true', + '-PreactNativeArchitectures=arm64-v8a', + ]); + }); + + it('keeps a double-quoted xcodebuild destination with spaces in one token', () => { + expect(tokenize('-destination "generic/platform=iOS Simulator"')).toEqual([ + '-destination', + 'generic/platform=iOS Simulator', + ]); + }); + + it('keeps a single-quoted destination specifier with spaces in one token', () => { + expect( + tokenize("-destination 'platform=iOS Simulator,name=iPhone 15'"), + ).toEqual(['-destination', 'platform=iOS Simulator,name=iPhone 15']); + }); + + it('strips the surrounding quotes from a quoted path containing a space', () => { + expect(tokenize('-project "My App.xcodeproj"')).toEqual([ + '-project', + 'My App.xcodeproj', + ]); + }); + + it('joins an unquoted build-setting name with its quoted, space-containing value', () => { + expect(tokenize('EXCLUDED_ARCHS="arm64 i386"')).toEqual([ + 'EXCLUDED_ARCHS=arm64 i386', + ]); + }); + + it('collapses extra whitespace (spaces and tabs) between arguments', () => { + expect(tokenize('-scheme MyApp \t -quiet')).toEqual([ + '-scheme', + 'MyApp', + '-quiet', + ]); + }); + + it('passes xcodebuild build-setting syntax such as $(inherited) through verbatim', () => { + expect( + tokenize( + 'OTHER_LDFLAGS=$(inherited) GCC_PREPROCESSOR_DEFINITIONS=DEBUG=1', + ), + ).toEqual([ + 'OTHER_LDFLAGS=$(inherited)', + 'GCC_PREPROCESSOR_DEFINITIONS=DEBUG=1', + ]); + }); + + it('keeps backslashes literal so Windows gradle paths survive', () => { + expect( + tokenize('-Pandroid.injected.signing.store.file=C:\\keys\\app.jks'), + ).toEqual(['-Pandroid.injected.signing.store.file=C:\\keys\\app.jks']); + }); + + it('returns an empty array for empty input', () => { + expect(tokenize('')).toEqual([]); + }); + + it('returns an empty array for whitespace-only input', () => { + expect(tokenize(' \t ')).toEqual([]); + }); + + it('drops a stray empty quoted argument', () => { + expect(tokenize('-quiet ""')).toEqual(['-quiet']); + }); + + it('throws on an unterminated double quote', () => { + expect(() => tokenize('-destination "platform=iOS')).toThrowError( + 'Unterminated " quote in: -destination "platform=iOS', + ); + }); + + it('throws on an unterminated single quote', () => { + expect(() => tokenize("-destination 'platform=iOS")).toThrowError( + "Unterminated ' quote in: -destination 'platform=iOS", + ); + }); +}); diff --git a/packages/cli-tools/src/index.ts b/packages/cli-tools/src/index.ts index 19132c2aa..b89415bae 100644 --- a/packages/cli-tools/src/index.ts +++ b/packages/cli-tools/src/index.ts @@ -16,5 +16,6 @@ export * from './port'; export {default as cacheManager} from './cacheManager'; export {default as runSudo} from './runSudo'; export {default as unixifyPaths} from './unixifyPaths'; +export {default as tokenize} from './tokenize'; export * from './errors'; diff --git a/packages/cli-tools/src/tokenize.ts b/packages/cli-tools/src/tokenize.ts new file mode 100644 index 000000000..00a8d41af --- /dev/null +++ b/packages/cli-tools/src/tokenize.ts @@ -0,0 +1,70 @@ +import {CLIError} from './errors'; + +/** + * Splits a command-line string into arguments, honoring single and double quotes + * so values containing spaces stay in a single token. + * + * It only understands quoting — nothing else is interpreted. Environment + * variables (`$FOO`), command substitution (`$(...)`), globs (`*`), operators + * (`;`, `|`) and backslashes are all passed through verbatim, so values like + * `OTHER_LDFLAGS=$(inherited)` or the Windows path `-Pdir=C:\Users\foo` reach the + * underlying tool unchanged. + * + * - Unquoted whitespace separates tokens. + * - `"` and `'` quote; the quote characters are stripped from the result. + * - Adjacent quoted and unquoted text join into one token, e.g. + * `OTHER_SWIFT_FLAGS="-D DEBUG"` -> `OTHER_SWIFT_FLAGS=-D DEBUG`. + * - Empty or whitespace-only input returns `[]`. + * - An unterminated quote throws a {@link CLIError}. + */ +export default function tokenize(input: string) { + const tokens: string[] = []; + // `null` means we're between tokens; a string (even '') means a token is open. + let token: string | null = null; + let openQuote: string | null = null; + + for (const char of input) { + // Inside quotes every character is literal until the matching quote closes. + if (openQuote) { + if (char === openQuote) { + openQuote = null; + } else { + token += char; + } + + continue; + } + + // A quote opens a quoted section, starting a token if one isn't open yet. + if (char === '"' || char === "'") { + openQuote = char; + token = token ?? ''; + + continue; + } + + // Unquoted whitespace ends the current token, if there is one. + if (/\s/.test(char)) { + if (token) { + tokens.push(token); + } + + token = null; + + continue; + } + + // Any other character extends the current token. + token = (token ?? '') + char; + } + + if (openQuote) { + throw new CLIError(`Unterminated ${openQuote} quote in: ${input}`); + } + + if (token) { + tokens.push(token); + } + + return tokens; +}