diff --git a/.github/actions/ios-build-xcode/action.yml b/.github/actions/ios-build-xcode/action.yml index a754c2c6..96e8705c 100644 --- a/.github/actions/ios-build-xcode/action.yml +++ b/.github/actions/ios-build-xcode/action.yml @@ -6,7 +6,7 @@ inputs: required: true scheme: description: Xcode scheme to build - required: true + required: false mode: description: Build configuration (Debug|Release) required: true @@ -20,6 +20,20 @@ runs: cd "${{ inputs.ios-dir }}" set -o pipefail + + WORKSPACE_NAME="$(find . -maxdepth 1 -name '*.xcworkspace' ! -name 'Pods.xcworkspace' -exec basename {} .xcworkspace \; | head -1)" + SCHEME_NAME="${{ inputs.scheme }}" + + if [ -z "$WORKSPACE_NAME" ]; then + echo "No Xcode workspace found in ${{ inputs.ios-dir }}" + exit 1 + fi + + if [ -z "$SCHEME_NAME" ]; then + SCHEME_NAME="$WORKSPACE_NAME" + fi + + echo "Building workspace ${WORKSPACE_NAME}.xcworkspace with scheme ${SCHEME_NAME}" max_retries=3 attempt=1 @@ -31,8 +45,8 @@ runs: if xcodebuild \ CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++ \ -derivedDataPath build -UseModernBuildSystem=YES \ - -workspace "${SCHEME}.xcworkspace" \ - -scheme "${SCHEME}" \ + -workspace "${WORKSPACE_NAME}.xcworkspace" \ + -scheme "${SCHEME_NAME}" \ -sdk iphonesimulator \ -configuration "${{ inputs.mode }}" \ -destination 'platform=iOS Simulator,name=iPhone 16' \ @@ -50,5 +64,3 @@ runs: attempt=$((attempt + 1)) echo "Retrying xcodebuild..." done - env: - SCHEME: ${{ inputs.scheme }} diff --git a/.github/workflows/ci-packages.yml b/.github/workflows/ci-packages.yml index 7df99ec4..14406cb5 100644 --- a/.github/workflows/ci-packages.yml +++ b/.github/workflows/ci-packages.yml @@ -23,7 +23,7 @@ on: - 'assets/template/**' workflow_dispatch: schedule: - - cron: '0 0 * * *' # Every day at midnight + - cron: '0 0 * * *' permissions: contents: write @@ -37,8 +37,6 @@ jobs: lint-and-build: name: Lint and Build CLI runs-on: ubuntu-latest - outputs: - bun-cache-key: ${{ runner.os }}-bun steps: - name: Checkout Repo uses: actions/checkout@v6 @@ -49,7 +47,6 @@ jobs: bun-version: latest - name: Cache bun dependencies - id: bun-cache uses: actions/cache@v5 with: path: ~/.bun/install/cache @@ -71,14 +68,145 @@ jobs: node lib/cli/index.js --help node lib/cli/index.js create --help + define-matrix: + name: Define Package Matrices + runs-on: ubuntu-latest + outputs: + generation: ${{ steps.set-matrix.outputs.generation }} + ios-build: ${{ steps.set-matrix.outputs.ios_build }} + android-build: ${{ steps.set-matrix.outputs.android_build }} + ios-e2e: ${{ steps.set-matrix.outputs.ios_e2e }} + android-e2e: ${{ steps.set-matrix.outputs.android_e2e }} + steps: + - name: Build workflow matrices + id: set-matrix + shell: bash + run: | + node <<'NODE' >> "$GITHUB_OUTPUT" + const pms = ['bun', 'yarn'] + const scenarios = [ + { + package_type: 'module', + scenario: 'swift-kotlin', + platforms: 'ios,android', + langs: 'swift,kotlin', + runs_ios: true, + runs_android: true, + }, + { + package_type: 'module', + scenario: 'cpp', + platforms: 'ios,android', + langs: 'c++', + runs_ios: true, + runs_android: true, + }, + { + package_type: 'module', + scenario: 'swift', + platforms: 'ios', + langs: 'swift', + runs_ios: true, + runs_android: false, + }, + { + package_type: 'module', + scenario: 'kotlin', + platforms: 'android', + langs: 'kotlin', + runs_ios: false, + runs_android: true, + }, + { + package_type: 'module', + scenario: 'cpp-ios', + platforms: 'ios', + langs: 'c++', + runs_ios: true, + runs_android: false, + }, + { + package_type: 'module', + scenario: 'cpp-android', + platforms: 'android', + langs: 'c++', + runs_ios: false, + runs_android: true, + }, + { + package_type: 'view', + scenario: 'swift-kotlin', + platforms: 'ios,android', + langs: 'swift,kotlin', + runs_ios: true, + runs_android: true, + }, + { + package_type: 'view', + scenario: 'swift', + platforms: 'ios', + langs: 'swift', + runs_ios: true, + runs_android: false, + }, + { + package_type: 'view', + scenario: 'kotlin', + platforms: 'android', + langs: 'kotlin', + runs_ios: false, + runs_android: true, + }, + ] + + const enrich = (item, extra = {}) => ({ + ...item, + ...extra, + package_dir: `react-native-test-${item.package_type}-${item.scenario}`, + }) + + const generation = pms.flatMap(pm => + scenarios.map(item => enrich(item, { pm })) + ) + const iosBuild = pms.flatMap(pm => + scenarios + .filter(item => item.runs_ios) + .flatMap(item => + ['Debug', 'Release'].map(mode => + enrich(item, { pm, mode }) + ) + ) + ) + const androidBuild = pms.flatMap(pm => + scenarios + .filter(item => item.runs_android) + .flatMap(item => + ['Debug', 'Release'].map(mode => + enrich(item, { pm, mode }) + ) + ) + ) + const iosE2E = scenarios + .filter(item => item.runs_ios) + .map(item => enrich(item, { pm: 'bun', mode: 'Release' })) + const androidE2E = scenarios + .filter(item => item.runs_android) + .map(item => enrich(item, { pm: 'bun', mode: 'Release' })) + + console.log(`generation=${JSON.stringify({ include: generation })}`) + console.log(`ios_build=${JSON.stringify({ include: iosBuild })}`) + console.log(`android_build=${JSON.stringify({ include: androidBuild })}`) + console.log(`ios_e2e=${JSON.stringify({ include: iosE2E })}`) + console.log(`android_e2e=${JSON.stringify({ include: androidE2E })}`) + NODE + generate-packages: - name: Generate with ${{ matrix.pm }} - ${{ matrix.package-type }} - needs: lint-and-build + name: Generate ${{ matrix.package_type }} - ${{ matrix.scenario }} - ${{ matrix.pm }} + needs: [lint-and-build, define-matrix] runs-on: macos-latest strategy: - matrix: - pm: ['bun', 'yarn'] - package-type: ['module', 'view'] + fail-fast: false + matrix: ${{ fromJson(needs.define-matrix.outputs.generation) }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: @@ -89,14 +217,8 @@ jobs: git config --global user.name "GitHub Actions Bot" git config --global user.email "actions@github.com" - - name: Setup pnpm - if: matrix.pm == 'pnpm' - uses: pnpm/action-setup@v4 - with: - version: 10 - - name: Setup Node.js - if: matrix.pm == 'yarn' || matrix.pm == 'pnpm' + if: matrix.pm == 'yarn' uses: actions/setup-node@v6 with: node-version: 22.x @@ -107,91 +229,119 @@ jobs: bun-version: latest - name: Cache dependencies (bun) - if: matrix.pm == 'bun' uses: actions/cache@v5 with: path: ~/.bun/install/cache - key: ${{ needs.lint-and-build.outputs.bun-cache-key }}-${{ hashFiles('**/bun.lockb') }} + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} restore-keys: | ${{ runner.os }}-bun- - - name: Install npm dependencies (bun) + - name: Install repository dependencies run: bun install - name: Build CLI and Link Locally run: | bun run build - ${{matrix.pm}} link - - - name: Generate ${{ matrix.package-type }} with ${{ matrix.pm }} - if: matrix.pm != 'pnpm' - run: ${{ matrix.pm }} create nitro-module test-${{ matrix.package-type }} --skip-install --ci --package-type ${{ matrix.package-type }} + ${{ matrix.pm }} link - - name: Generate ${{ matrix.package-type }} with ${{ matrix.pm }} (pnpm) - if: matrix.pm == 'pnpm' - run: create-nitro-module test-${{ matrix.package-type }} --skip-install --ci --package-type ${{ matrix.package-type }} + - name: Generate package + run: | + ${{ matrix.pm }} create nitro-module test-${{ matrix.package_type }}-${{ matrix.scenario }} \ + --skip-install \ + --ci \ + --package-type ${{ matrix.package_type }} \ + --platforms ${{ matrix.platforms }} \ + --langs ${{ matrix.langs }} - name: Verify generated package structure + shell: bash run: | - PACKAGE_DIR="react-native-test-${{ matrix.package-type }}" + PACKAGE_DIR="${{ matrix.package_dir }}" if [ ! -d "$PACKAGE_DIR" ]; then - echo "❌ Package directory not found: $PACKAGE_DIR" + echo "Package directory not found: $PACKAGE_DIR" ls -la exit 1 fi - echo "✅ Package directory created: $PACKAGE_DIR" + cd "$PACKAGE_DIR" - REQUIRED_FILES=( + + REQUIRED_PATHS=( "package.json" "README.md" "src/" - "android/" - "ios/" "example/" ) - for file in "${REQUIRED_FILES[@]}"; do - if [ ! -e "$file" ]; then - echo "❌ Missing required file/directory: $file" + + if [ "${{ matrix.runs_android }}" = "true" ]; then + REQUIRED_PATHS+=("android/") + fi + + if [ "${{ matrix.runs_ios }}" = "true" ]; then + REQUIRED_PATHS+=("ios/") + fi + + for path in "${REQUIRED_PATHS[@]}"; do + if [ ! -e "$path" ]; then + echo "Missing required file/directory: $path" ls -la exit 1 fi - echo "✅ Found: $file" + echo "Found: $path" done + if [ "${{ matrix.runs_android }}" != "true" ] && [ -e "android" ]; then + echo "Unexpected android directory for scenario ${{ matrix.scenario }}" + exit 1 + fi + + if [ "${{ matrix.runs_ios }}" != "true" ] && [ -e "ios" ]; then + echo "Unexpected ios directory for scenario ${{ matrix.scenario }}" + exit 1 + fi + + if [ "${{ matrix.runs_ios }}" = "true" ] && ! find . -maxdepth 1 -name "*.podspec" | grep -q .; then + echo "Expected podspec for iOS-enabled scenario" + exit 1 + fi + + if [ "${{ matrix.runs_ios }}" != "true" ] && find . -maxdepth 1 -name "*.podspec" | grep -q .; then + echo "Unexpected podspec for Android-only scenario" + exit 1 + fi + - name: Test package.json content + shell: bash run: | - cd "react-native-test-${{ matrix.package-type }}" - if ! grep -q "react-native-test-${{ matrix.package-type }}" package.json; then - echo "❌ Package name not correct in package.json" && cat package.json && exit 1 + cd "${{ matrix.package_dir }}" + if ! grep -q "react-native-test-${{ matrix.package_type }}-${{ matrix.scenario }}" package.json; then + echo "Package name not correct in package.json" + cat package.json + exit 1 fi if ! grep -q '"scripts"' package.json; then - echo "❌ Scripts section missing from package.json" && exit 1 + echo "Scripts section missing from package.json" + exit 1 fi - echo "✅ package.json structure validated" + echo "package.json structure validated" - name: Upload generated package uses: actions/upload-artifact@v7 with: - name: test-${{ matrix.package-type }}-${{ matrix.pm }} - path: react-native-test-${{ matrix.package-type }} + name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} + path: ${{ matrix.package_dir }} include-hidden-files: true if-no-files-found: error retention-days: 7 - # Package manager build tests (iOS) test-ios-build: - name: Test iOS Build - ${{ matrix.pm }} - ${{ matrix.package-type }} (${{ matrix.mode }}) - needs: generate-packages + name: Test iOS Build - ${{ matrix.pm }} - ${{ matrix.package_type }} - ${{ matrix.scenario }} (${{ matrix.mode }}) + needs: [generate-packages, define-matrix] runs-on: macOS-latest strategy: fail-fast: false - matrix: - pm: ['bun', 'yarn'] - package-type: ['module', 'view'] - mode: ['Debug', 'Release'] + matrix: ${{ fromJson(needs.define-matrix.outputs.ios-build) }} env: - WORKING_DIR: ${{ github.workspace }}/react-native-test-${{ matrix.package-type }} - SCHEME: ${{ matrix.package-type == 'module' && 'TestModuleExample' || 'TestViewExample' }} + WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} steps: - name: Checkout repository uses: actions/checkout@v6 @@ -202,7 +352,7 @@ jobs: - name: Download generated package uses: actions/download-artifact@v8 with: - name: test-${{ matrix.package-type }}-${{ matrix.pm }} + name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} path: ${{ env.WORKING_DIR }} - name: List package structure @@ -269,22 +419,17 @@ jobs: uses: ./.github/actions/ios-build-xcode with: ios-dir: ${{ env.WORKING_DIR }}/example/ios - scheme: ${{ env.SCHEME }} mode: ${{ matrix.mode }} - # Package manager build tests (Android) test-android-build: - name: Test Android Build - ${{ matrix.pm }} - ${{ matrix.package-type }} (${{ matrix.mode }}) - needs: generate-packages + name: Test Android Build - ${{ matrix.pm }} - ${{ matrix.package_type }} - ${{ matrix.scenario }} (${{ matrix.mode }}) + needs: [generate-packages, define-matrix] runs-on: ubuntu-latest strategy: fail-fast: false - matrix: - pm: ['bun', 'yarn'] - package-type: ['module', 'view'] - mode: ['Debug', 'Release'] + matrix: ${{ fromJson(needs.define-matrix.outputs.android-build) }} env: - WORKING_DIR: ${{ github.workspace }}/react-native-test-${{ matrix.package-type }} + WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} steps: - name: Checkout repository uses: actions/checkout@v6 @@ -295,7 +440,7 @@ jobs: - name: Download generated package uses: actions/download-artifact@v8 with: - name: test-${{ matrix.package-type }}-${{ matrix.pm }} + name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} path: ${{ env.WORKING_DIR }} - name: List package structure @@ -349,24 +494,22 @@ jobs: key: ${{ runner.os }}-gradle-${{ hashFiles(format('{0}/example/android/**/*.gradle*', env.WORKING_DIR)) }} restore-keys: | ${{ runner.os }}-gradle- + - name: Build Android project uses: ./.github/actions/android-gradle-build with: android-dir: ${{ env.WORKING_DIR }}/example/android mode: ${{ matrix.mode }} - # E2E tests (Android) e2e-android: - name: Android E2E Test - needs: generate-packages + name: Android E2E - ${{ matrix.package_type }} - ${{ matrix.scenario }} + needs: [test-android-build, define-matrix] runs-on: ubuntu-latest strategy: - matrix: - package-type: ['module', 'view'] - mode: ['Release'] - pm: ['bun'] + fail-fast: false + matrix: ${{ fromJson(needs.define-matrix.outputs.android-e2e) }} env: - WORKING_DIR: ${{ github.workspace }}/react-native-test-${{ matrix.package-type }} + WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} steps: - name: Checkout repository uses: actions/checkout@v6 @@ -384,7 +527,7 @@ jobs: - name: Download generated package uses: actions/download-artifact@v8 with: - name: test-${{ matrix.package-type }}-${{ matrix.pm }} + name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} path: ${{ env.WORKING_DIR }} - name: List package structure @@ -440,22 +583,19 @@ jobs: disable-animations: true script: | adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done' - ${{ matrix.pm }} android:e2e ${{ env.WORKING_DIR }}/example ${{ matrix.package-type }} + ${{ matrix.pm }} android:e2e ${{ env.WORKING_DIR }}/example ${{ matrix.package_type }} - # E2E tests (iOS) e2e-ios: - name: iOS E2E Test - needs: generate-packages + name: iOS E2E - ${{ matrix.package_type }} - ${{ matrix.scenario }} + needs: [test-ios-build, define-matrix] runs-on: macOS-15 strategy: - matrix: - package-type: ['module', 'view'] - mode: ['Release'] - pm: ['bun'] + fail-fast: false + matrix: ${{ fromJson(needs.define-matrix.outputs.ios-e2e) }} env: MAESTRO_DRIVER_STARTUP_TIMEOUT: 300_000 MAESTRO_CLI_ANALYSIS_NOTIFICATION_DISABLED: true - WORKING_DIR: ${{ github.workspace }}/react-native-test-${{ matrix.package-type }} + WORKING_DIR: ${{ github.workspace }}/${{ matrix.package_dir }} steps: - name: Checkout repository uses: actions/checkout@v6 @@ -471,7 +611,7 @@ jobs: - name: Download generated module uses: actions/download-artifact@v8 with: - name: test-${{ matrix.package-type }}-${{ matrix.pm }} + name: test-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ matrix.pm }} path: ${{ env.WORKING_DIR }} - name: List package structure @@ -525,9 +665,9 @@ jobs: uses: actions/cache@v5 with: path: ~/Library/Caches/ccache - key: ${{ runner.os }}-ccache-${{ matrix.package-type }}-${{ github.sha }} + key: ${{ runner.os }}-ccache-${{ matrix.package_type }}-${{ matrix.scenario }}-${{ github.sha }} restore-keys: | - ${{ runner.os }}-ccache-${{ matrix.package-type }}- + ${{ runner.os }}-ccache-${{ matrix.package_type }}-${{ matrix.scenario }}- ${{ runner.os }}-ccache- - name: Configure ccache @@ -543,7 +683,7 @@ jobs: env: USE_CCACHE: 1 CCACHE_DIR: ~/Library/Caches/ccache - run: ${{ matrix.pm }} ios:e2e ${{ env.WORKING_DIR }}/example ${{ matrix.package-type }} + run: ${{ matrix.pm }} ios:e2e ${{ env.WORKING_DIR }}/example ${{ matrix.package_type }} - name: Print ccache statistics if: always() @@ -553,6 +693,6 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: maestro-artifacts-ios-${{ matrix.package-type }} + name: maestro-artifacts-ios-${{ matrix.package_type }}-${{ matrix.scenario }} path: e2e-artifacts include-hidden-files: true diff --git a/docs/docs/commands.md b/docs/docs/commands.md index 853e7b27..738a9573 100644 --- a/docs/docs/commands.md +++ b/docs/docs/commands.md @@ -21,9 +21,13 @@ Arguments: Options: -v, --version output the version number + -t, --package-type type of the package to create + --platforms comma-separated platforms to target + --langs comma-separated languages to generate -d, --module-dir directory to create the module in -e, --skip-example skip example app generation -i, --skip-install skip installing dependencies + --ci run in CI mode -h, --help display help for command ``` diff --git a/e2e-tests/view.e2e.yaml b/e2e-tests/view.e2e.yaml index b04c66ac..7c083a93 100644 --- a/e2e-tests/view.e2e.yaml +++ b/e2e-tests/view.e2e.yaml @@ -2,4 +2,4 @@ appId: ${APP_ID} --- - launchApp - assertVisible: - id: 'test-view' + id: ${MODULE_ID} diff --git a/scripts/e2e-maestro.sh b/scripts/e2e-maestro.sh index 73578980..0fa39461 100755 --- a/scripts/e2e-maestro.sh +++ b/scripts/e2e-maestro.sh @@ -28,21 +28,41 @@ esac APP_ID="" SCHEME="" -if [ "$PACKAGE_TYPE" == "module" ]; then - APP_ID="com.testmoduleexample" - SCHEME="TestModuleExample" -elif [ "$PACKAGE_TYPE" == "view" ]; then - APP_ID="com.testviewexample" - SCHEME="TestViewExample" -else +if [ "$PACKAGE_TYPE" != "module" ] && [ "$PACKAGE_TYPE" != "view" ]; then echo "Error! You must pass either 'module' or 'view'" echo "" exit 1 fi +PACKAGE_ROOT_NAME="$(basename "$(cd "$EXAMPLE_DIR/.." && pwd)")" +PACKAGE_NAME="${PACKAGE_ROOT_NAME#react-native-}" +APP_ID="com.${PACKAGE_NAME//-/}example" + +to_pascal_case() { + local input="$1" + local result="" + local word="" + + input="${input//-/ }" + input="${input//_/ }" + + for word in $input; do + local lower_word="${word,,}" + result+="${lower_word^}" + done + + printf '%s' "$result" +} + +SCHEME="$(to_pascal_case "$PACKAGE_NAME")Example" if [ "$PLATFORM" == "ios" ]; then - cd $EXAMPLE_DIR/ios + cd "$EXAMPLE_DIR/ios" + + WORKSPACE_NAME="$(find . -maxdepth 1 -name "*.xcworkspace" ! -name "Pods.xcworkspace" -exec basename {} .xcworkspace \; | head -1)" + if [ -n "$WORKSPACE_NAME" ]; then + SCHEME="$WORKSPACE_NAME" + fi # Get iPhone 16 simulator ID dynamically iphone16Id=$(xcrun simctl list devices | grep "iPhone 16 (" | grep -E '\(Booted\)|\(Shutdown\)' | head -1 | grep -E -o '\([0-9A-F-]{36}\)' | tr -d '()') @@ -111,7 +131,7 @@ if [ "$PLATFORM" == "ios" ]; then # Return to project root cd "$SCRIPT_DIR" else - cd $EXAMPLE_DIR/android + cd "$EXAMPLE_DIR/android" chmod +x ./gradlew # Build with optimizations and pretty output @@ -151,9 +171,10 @@ fi # Create output directory for videos mkdir -p e2e-artifacts -recordCmd="maestro record \"$test_file\" -e APP_ID=$APP_ID --local" +recordCmd="maestro record \"$test_file\" -e APP_ID=$APP_ID -e MODULE_ID=$PACKAGE_NAME --local" echo "🎯 Recording test video: $recordCmd" echo "📱 APP_ID: $APP_ID" +echo "🆔 MODULE_ID: $PACKAGE_NAME" if ! eval "$recordCmd --debug-output e2e-artifacts/$PACKAGE_TYPE"; then @@ -167,4 +188,4 @@ if ! eval "$recordCmd --debug-output e2e-artifacts/$PACKAGE_TYPE"; then exit 1 fi fi -fi \ No newline at end of file +fi diff --git a/src/cli/create.ts b/src/cli/create.ts index 07f7c06e..f6c6a264 100644 --- a/src/cli/create.ts +++ b/src/cli/create.ts @@ -21,12 +21,124 @@ import { validatePackageName, } from '../utils' +const SUPPORTED_PLATFORM_VALUES = Object.values(SupportedPlatform) +const SUPPORTED_LANG_VALUES: SupportedLang[] = [ + SupportedLang.SWIFT, + SupportedLang.KOTLIN, + SupportedLang.CPP, +] + +const parseListOption = (value?: string) => + value + ?.split(',') + .map(item => item.trim().toLowerCase()) + .filter(Boolean) ?? [] + +const getAllowedLanguageSelections = ( + platforms: SupportedPlatform[], + packageType: Nitro +) => { + const availableLanguages = Array.from( + new Set(platforms.flatMap(platform => PLATFORM_LANGUAGE_MAP[platform])) + ).filter(lang => packageType !== Nitro.View || lang !== SupportedLang.CPP) + + if ( + platforms.includes(SupportedPlatform.IOS) && + platforms.includes(SupportedPlatform.ANDROID) + ) { + return [ + [SupportedLang.SWIFT, SupportedLang.KOTLIN], + ...(packageType === Nitro.Module ? [[SupportedLang.CPP]] : []), + ] + } + + return availableLanguages.map(lang => [lang]) +} + +const hasMatchingSelection = ( + selectedLangs: SupportedLang[], + allowedSelection: SupportedLang[] +) => + selectedLangs.length === allowedSelection.length && + selectedLangs.every(lang => allowedSelection.includes(lang)) + +const formatLanguageSelection = (langs: SupportedLang[]) => + langs.map(lang => (lang === SupportedLang.CPP ? 'c++' : lang)).join(', ') + +const parsePlatformsOption = (value?: string) => { + const platforms = Array.from( + new Set( + parseListOption(value).map(platform => { + if ( + !SUPPORTED_PLATFORM_VALUES.includes( + platform as SupportedPlatform + ) + ) { + throw new Error( + `Invalid platform "${platform}". Supported platforms: ${SUPPORTED_PLATFORM_VALUES.join(', ')}` + ) + } + + return platform as SupportedPlatform + }) + ) + ) + + return platforms.length > 0 + ? platforms + : [SupportedPlatform.IOS, SupportedPlatform.ANDROID] +} + +const parseLangsOption = ( + value: string | undefined, + platforms: SupportedPlatform[], + packageType: Nitro +) => { + const allowedSelections = getAllowedLanguageSelections( + platforms, + packageType + ) + + if (!value) { + return packageType === Nitro.View + ? resolveViewLanguages(platforms) + : allowedSelections[0] + } + + const langs = Array.from( + new Set( + parseListOption(value).map(lang => { + if (!SUPPORTED_LANG_VALUES.includes(lang as SupportedLang)) { + throw new Error( + `Invalid language "${lang}". Supported languages: ${SUPPORTED_LANG_VALUES.join(', ')}` + ) + } + + return lang as SupportedLang + }) + ) + ) + + const isAllowedSelection = allowedSelections.some(selection => + hasMatchingSelection(langs, selection) + ) + + if (!isAllowedSelection) { + throw new Error( + `Invalid language selection for ${platforms.join(', ')} (${packageType}). Allowed selections: ${allowedSelections.map(formatLanguageSelection).join(' | ')}` + ) + } + + return langs +} + export const createModule = async ( packageName: string, options: CreateModuleOptions ) => { let packageType = Nitro.Module let moduleFactory: NitroModuleFactory | null = null + let spinnerStarted = false const spinner = p.spinner() try { if (options.moduleDir) { @@ -98,6 +210,7 @@ export const createModule = async ( spinner.start( messages.creating.replace('{packageType}', capitalize(packageType)) ) + spinnerStarted = true await moduleFactory.createNitroModule() @@ -123,12 +236,20 @@ export const createModule = async ( ) rmSync(modulePath, { recursive: true, force: true }) } - spinner.stop( - kleur.red( - `Failed to create Nitro ${packageType}: ${(error as Error).message}` - ), - 1 - ) + if (spinnerStarted) { + spinner.stop( + kleur.red( + `Failed to create Nitro ${packageType}: ${(error as Error).message}` + ) + ) + } else { + console.log( + kleur.red( + `Failed to create Nitro ${packageType}: ${(error as Error).message}` + ) + ) + } + process.exit(1) } } @@ -136,34 +257,32 @@ const selectLanguages = async ( platforms: SupportedPlatform[], packageType: Nitro ) => { - const availableLanguages = Array.from( - new Set(platforms.flatMap(platform => PLATFORM_LANGUAGE_MAP[platform])) - ).filter(lang => packageType !== Nitro.View || lang !== SupportedLang.CPP) + const options = getAllowedLanguageSelections(platforms, packageType).map( + langs => { + if ( + langs.includes(SupportedLang.SWIFT) && + langs.includes(SupportedLang.KOTLIN) + ) { + return { + label: 'Swift & Kotlin', + value: langs, + hint: `Use Swift and Kotlin to build your Nitro ${packageType.toLowerCase()} for iOS and Android`, + } + } - const options = - platforms.includes(SupportedPlatform.IOS) && - platforms.includes(SupportedPlatform.ANDROID) - ? [ - { - label: 'Swift & Kotlin', - value: [SupportedLang.SWIFT, SupportedLang.KOTLIN], - hint: `Use Swift and Kotlin to build your Nitro ${packageType.toLowerCase()} for iOS and Android`, - }, - ...(packageType === Nitro.Module - ? [ - { - label: 'C++', - value: [SupportedLang.CPP], - hint: 'Use C++ to share code between iOS and Android', - }, - ] - : []), - ] - : availableLanguages.map(lang => ({ - label: capitalize(lang), - value: [lang], - hint: `Use ${lang === SupportedLang.CPP ? 'C++' : capitalize(lang)} to build your Nitro ${packageType.toLowerCase()} for ${platforms.join(' and ')}`, - })) + const [lang] = langs + return { + label: capitalize(lang === SupportedLang.CPP ? 'c++' : lang), + value: langs, + hint: + lang === SupportedLang.CPP && + platforms.includes(SupportedPlatform.IOS) && + platforms.includes(SupportedPlatform.ANDROID) + ? 'Use C++ to share code between iOS and Android' + : `Use ${lang === SupportedLang.CPP ? 'C++' : capitalize(lang)} to build your Nitro ${packageType.toLowerCase()} for ${platforms.join(' and ')}`, + } + } + ) const selectedLangs = await p.select({ message: kleur.cyan('Which language(s) would you like to use?'), @@ -191,17 +310,15 @@ const getUserAnswers = async ( options?: CreateModuleOptions ): Promise => { if (options?.ci) { - const platforms = [SupportedPlatform.IOS, SupportedPlatform.ANDROID] + const platforms = parsePlatformsOption(options.platforms) const packageType = options?.packageType || Nitro.Module + return { packageName: name, description: `${kleur.yellow(`react-native-${name}`)} is a react native package built with Nitro`, platforms, packageType, - langs: - packageType === Nitro.View - ? resolveViewLanguages(platforms) - : [SupportedLang.SWIFT, SupportedLang.KOTLIN], + langs: parseLangsOption(options.langs, platforms, packageType), pm: usedPm || 'pnpm', } } diff --git a/src/cli/index.ts b/src/cli/index.ts index b39522a2..6afdb16a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,8 @@ program .version(`CLI Version: ${packageJson.version}`, '-v, --version') .argument('[name]', 'name of the module to create') .option('-t, --package-type ', 'type of the package to create') + .option('--platforms ', 'comma-separated platforms to target') + .option('--langs ', 'comma-separated languages to generate') .option( '-d, --module-dir ', 'directory to create the module in' diff --git a/src/types.ts b/src/types.ts index d60ef284..8ce3eeb1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,7 +29,9 @@ export type PlatformLang = { export type CreateModuleOptions = { ci?: boolean + langs?: string moduleDir?: string + platforms?: string skipExample?: boolean skipInstall?: boolean packageType?: Nitro @@ -57,7 +59,7 @@ export type GenerateModuleConfig = { packageType: Nitro packageName: string finalPackageName: string -} & Omit +} & Omit export interface FileGenerator { /**