diff --git a/eslint.config.mjs b/eslint.config.mjs index e90917023..63ba991dd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,6 +6,7 @@ import pluginReact from "eslint-plugin-react"; export default [ {files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"]}, + {ignores: ["Examples/**"]}, {languageOptions: { globals: { ...globals.node, ...globals.mocha, diff --git a/package.json b/package.json index 33d79ce17..c7fa17c93 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ ], "scripts": { "setup": "npm install --quiet --no-progress", + "setup-example-app": "ts-node --project scripts/setupExampleApp/tsconfig.json scripts/setupExampleApp/runSetupExampleApp.ts", "test": "npm run build:tests && npm run test:setup && npm run test:fast", "test:android": "npm run build:tests && npm run test:setup:android && npm run test:fast:android", "test:ios": "npm run build:tests && npm run test:setup:ios && npm run test:fast:ios", diff --git a/scripts/setupExampleApp/README.ko.md b/scripts/setupExampleApp/README.ko.md new file mode 100644 index 000000000..28aa1940d --- /dev/null +++ b/scripts/setupExampleApp/README.ko.md @@ -0,0 +1,120 @@ +# Setup Example App — 자동화 스크립트 + +`@bravemobile/react-native-code-push`가 사전 구성된 React Native 예제 앱을 자동으로 생성합니다. + +## 디렉토리 구조 + +``` +scripts/setupExampleApp/ +├── runSetupExampleApp.ts # 메인 진입점 (CLI) +├── syncLocalLibrary.ts # 로컬 code-push 빌드를 node_modules에 동기화 +├── templates/ +│ └── App.tsx.txt # CodePush 테스트 UI가 포함된 App.tsx 템플릿 +├── tsconfig.json # 이 스크립트 디렉토리용 TypeScript 설정 +└── README.ko.md # 이 파일 +``` + +## 사전 요구 사항 + +- Node.js (>= 18) +- npm +- Ruby + Bundler (iOS pod install용) +- Xcode (iOS용) + +## 사용법 + +레포지토리 루트에서 실행합니다: + +```bash +npm run setup-example-app -- -v +``` + +### CLI 옵션 + +| 플래그 | 설명 | 기본값 | +|---|---|---| +| `-v, --rn-version ` | React Native 버전 (예: `0.83.1`, `0.84.0-rc.5`) | **필수** | +| `-w, --working-dir ` | 앱이 생성될 디렉토리 | `./Examples` | +| `--skip-pod-install` | `bundle install`과 `pod install`을 건너뜀 | `false` | + +### 실행 예시 + +```bash +# RN 0.83.1 예제 앱 생성 +npm run setup-example-app -- -v 0.83.1 + +# pod install 없이 생성 (macOS가 아니거나 CI 환경에서 유용) +npm run setup-example-app -- -v 0.84.0-rc.5 --skip-pod-install +``` + +생성된 프로젝트는 `Examples/RN<버전>/` 경로에 위치합니다 (예: `Examples/RN0831/`). + +## 파이프라인 단계 + +아래 단계가 **순서대로** 실행됩니다. 하나라도 실패하면 이후 단계는 실행되지 않습니다. + +### 1. create-react-native-template + +`npx @react-native-community/cli init`으로 지정된 RN 버전의 빈 템플릿 앱을 생성합니다. 의존성 설치와 pod install은 이후 단계에서 수행하므로 여기서는 건너뜁니다 (`--skip-install`, `--install-pods false`). + +### 2. configure-ios-versioning + +`ios/<프로젝트명>.xcodeproj/project.pbxproj`와 `ios/Podfile`을 수정합니다: +- 모든 빌드 구성에서 `MARKETING_VERSION`을 `1.0.0`으로 통일합니다. +- `IPHONEOS_DEPLOYMENT_TARGET`을 `16.0`으로 설정합니다. +- Podfile의 `platform :ios`를 `'16.0'`으로 맞춥니다. + +### 3. configure-android-versioning + +`android/app/build.gradle`을 수정합니다: +- `versionName`을 `"1.0.0"`으로 통일합니다. +- 릴리스 빌드에서 ProGuard를 활성화합니다 (`enableProguardInReleaseBuilds = true`). + +### 4. configure-local-code-link + +`package.json`을 수정하여 로컬 라이브러리를 연결합니다: +- `@bravemobile/react-native-code-push`를 dependency로 추가합니다. +- `syncLocalLibrary.ts`를 가리키는 `sync-local-library` npm 스크립트를 추가합니다. +- `setup:pods` 편의 스크립트를 추가합니다 (`bundle install && cd ios && bundle exec pod install`). +- `postinstall` 훅에 `sync-local-library`를 등록하여, `npm install` 시마다 로컬 빌드가 자동 동기화되도록 합니다. +- 누락된 필수 dev dependencies를 설치합니다: `ts-node`, `axios`, `@types/node`, `@supabase/supabase-js`. + +### 5. create-code-push-config + +`Examples/CodePushDemoApp/code-push.config.example.supabase.ts` 템플릿 파일을 프로젝트 루트에 `code-push.config.ts`로 복사합니다. + +### 6. configure-ts-node + +`tsconfig.json`을 수정합니다: +- `include`에 `**/*.ts`, `**/*.tsx`, `code-push.config.ts`를 추가합니다. +- `ts-node` 섹션에 `module: "CommonJS"`, `types: ["node"]`를 설정하여 npm 스크립트에서 ts-node로 TypeScript 파일을 직접 실행할 수 있게 합니다. + +### 7. apply-app-template + +`templates/App.tsx.txt`를 읽어 `__IDENTIFIER__` 플레이스홀더를 프로젝트 이름(예: `RN0831`)으로 치환한 뒤, 프로젝트 루트에 `App.tsx`로 저장합니다. 이 템플릿에는 동기화, 메타데이터 확인, 앱 재시작 등의 CodePush 테스트 UI가 포함되어 있습니다. + +### 8. install-dependencies + +생성된 프로젝트에서 `npm install`을 실행합니다. 4단계에서 설정한 `postinstall` 훅에 의해 `sync-local-library`도 함께 실행되며, 이 과정에서 로컬 code-push 라이브러리가 패킹되어 `node_modules`에 복사됩니다. + +### 9. install-ios-pods + +`ios/` 디렉토리에서 `bundle install` 후 `bundle exec pod install`을 실행합니다. `--skip-pod-install` 옵션이 지정된 경우 이 단계는 건너뜁니다. + +### 10. initialize-code-push + +프로젝트 내에서 `npx code-push init`을 실행하여 iOS 및 Android 네이티브 프로젝트에 CodePush 설정을 자동으로 주입합니다. + +## 헬퍼 스크립트: syncLocalLibrary.ts + +이 스크립트는 생성된 앱의 `sync-local-library` npm 스크립트로 등록됩니다. `npm install` 시 `postinstall`을 통해 자동 실행되며, 수동으로도 실행할 수 있습니다: + +```bash +npm run sync-local-library +``` + +**동작 순서:** +1. 레포지토리 루트에서 `npm pack`을 실행하여 로컬 라이브러리의 `.tgz` tarball을 생성합니다. +2. tarball을 임시 디렉토리에 압축 해제합니다. +3. `node_modules/@bravemobile/react-native-code-push`의 내용을 추출된 패키지로 교체합니다. +4. 임시 파일과 로컬 npm 캐시를 정리합니다. diff --git a/scripts/setupExampleApp/README.md b/scripts/setupExampleApp/README.md new file mode 100644 index 000000000..9f4746d6c --- /dev/null +++ b/scripts/setupExampleApp/README.md @@ -0,0 +1,120 @@ +# Setup Example App — Automation Script + +Automates the creation of a React Native example app pre-configured with `@bravemobile/react-native-code-push`. + +## Directory Structure + +``` +scripts/setupExampleApp/ +├── runSetupExampleApp.ts # Main entry point (CLI) +├── syncLocalLibrary.ts # Syncs the local code-push build into node_modules +├── templates/ +│ └── App.tsx.txt # App.tsx template with CodePush test UI +├── tsconfig.json # TypeScript config for this script directory +└── README.md # This file +``` + +## Prerequisites + +- Node.js (>= 18) +- npm +- Ruby + Bundler (for iOS pod install) +- Xcode (for iOS) + +## Usage + +Run from the repository root: + +```bash +npm run setup-example-app -- -v +``` + +### CLI Options + +| Flag | Description | Default | +|---|---|---| +| `-v, --rn-version ` | React Native version (e.g. `0.83.1`, `0.84.0-rc.5`) | **Required** | +| `-w, --working-dir ` | Directory where the app will be created | `./Examples` | +| `--skip-pod-install` | Skip `bundle install` and `pod install` | `false` | + +### Example + +```bash +# Create an example app for RN 0.83.1 +npm run setup-example-app -- -v 0.83.1 + +# Create without pod install (useful on non-macOS or CI) +npm run setup-example-app -- -v 0.84.0-rc.5 --skip-pod-install +``` + +The generated project will be placed at `Examples/RN/` (e.g. `Examples/RN0831/`). + +## Pipeline Steps + +The script runs the following steps **sequentially**. If any step fails, the remaining steps are skipped. + +### 1. create-react-native-template + +Runs `npx @react-native-community/cli init` to scaffold a blank React Native app at the target version. Dependency installation and pod install are deferred to later steps (`--skip-install`, `--install-pods false`). + +### 2. configure-ios-versioning + +Edits `ios/.xcodeproj/project.pbxproj` and `ios/Podfile`: +- Sets `MARKETING_VERSION` to `1.0.0` across all build configurations. +- Sets `IPHONEOS_DEPLOYMENT_TARGET` to `16.0`. +- Sets the Podfile `platform :ios` to `'16.0'`. + +### 3. configure-android-versioning + +Edits `android/app/build.gradle`: +- Sets `versionName` to `"1.0.0"`. +- Enables ProGuard for release builds (`enableProguardInReleaseBuilds = true`). + +### 4. configure-local-code-link + +Modifies `package.json` to wire up the local library: +- Adds `@bravemobile/react-native-code-push` as a dependency. +- Adds an npm `sync-local-library` script pointing to `syncLocalLibrary.ts`. +- Adds a `setup:pods` convenience script (`bundle install && cd ios && bundle exec pod install`). +- Registers `sync-local-library` as a `postinstall` hook so the local build is synced on every `npm install`. +- Installs required dev dependencies if missing: `ts-node`, `axios`, `@types/node`, `@supabase/supabase-js`. + +### 5. create-code-push-config + +Copies the config template from `Examples/CodePushDemoApp/code-push.config.example.supabase.ts` into the project root as `code-push.config.ts`. + +### 6. configure-ts-node + +Updates `tsconfig.json`: +- Ensures `include` covers `**/*.ts`, `**/*.tsx`, and `code-push.config.ts`. +- Adds a `ts-node` section with `module: "CommonJS"` and `types: ["node"]` so that npm scripts can execute TypeScript files directly via ts-node. + +### 7. apply-app-template + +Reads `templates/App.tsx.txt`, replaces the `__IDENTIFIER__` placeholder with the project name (e.g. `RN0831`), and writes the result as `App.tsx` in the project root. The template includes a CodePush test UI with sync, metadata, and restart controls. + +### 8. install-dependencies + +Runs `npm install` inside the generated project. Because a `postinstall` hook was configured in step 4, this also triggers `sync-local-library`, which packs and copies the local code-push library into `node_modules`. + +### 9. install-ios-pods + +Runs `bundle install` followed by `bundle exec pod install` inside the `ios/` directory. Skipped entirely when `--skip-pod-install` is specified. + +### 10. initialize-code-push + +Runs `npx code-push init` inside the project to inject CodePush configuration into the iOS and Android native projects. + +## Helper Script: syncLocalLibrary.ts + +This script is registered as the `sync-local-library` npm script in the generated app. It is invoked automatically on `npm install` (via `postinstall`) and can also be run manually: + +```bash +npm run sync-local-library +``` + +**What it does:** +1. Runs `npm pack` at the repository root to produce a `.tgz` tarball of the local library. +2. Extracts the tarball into a temp directory. +3. Replaces the contents of `node_modules/@bravemobile/react-native-code-push` with the extracted package. +4. Cleans up all temp files and the local npm cache. diff --git a/scripts/setupExampleApp/runSetupExampleApp.ts b/scripts/setupExampleApp/runSetupExampleApp.ts new file mode 100644 index 000000000..cefa026b0 --- /dev/null +++ b/scripts/setupExampleApp/runSetupExampleApp.ts @@ -0,0 +1,501 @@ +import { Command } from "commander"; +import fs from "fs"; +import path from "path"; +import { spawn } from "child_process"; +import semver from "semver"; +import ts from "typescript"; + +interface SetupCliOptions { + rnVersion: string; + workingDir: string; + skipPodInstall?: boolean; +} + +interface SetupContext { + rnVersion: string; + projectName: string; + workingDirectory: string; + projectPath: string; + skipPodInstall: boolean; +} + +type SetupStep = { + name: string; + description: string; + run: (context: SetupContext) => Promise; +}; + +const NPX_BINARY = "npx"; +const NPM_BINARY = "npm"; + +const program = new Command() + .name("setup-automation") + .description("React Native CodePush test app setup automation") + .requiredOption("-v, --rn-version ", "React Native version to test (e.g. 0.83.1)") + .option( + "-w, --working-dir ", + "Directory where the template app will be created", + path.resolve(process.cwd(), "Examples") + ) + .option( + "--skip-pod-install", + "Skip bundle install and bundle exec pod install during template postinstall", + false + ); + +const setupSteps: SetupStep[] = [ + { + name: "create-react-native-template", + description: "Create RN template app", + run: createReactNativeTemplateApp + }, + { + name: "configure-ios-versioning", + description: "Configure iOS versioning and minimum OS", + run: configureIosVersioning + }, + { + name: "configure-android-versioning", + description: "Configure Android version information", + run: configureAndroidVersioning + }, + { + name: "configure-local-code-link", + description: "Configure local library link", + run: configureLocalCodeLink + }, + { + name: "create-code-push-config", + description: "Apply code-push config template", + run: createCodePushConfigFile + }, + { + name: "configure-ts-node", + description: "Configure ts-node runtime options", + run: configureTsNodeOptions + }, + { + name: "apply-app-template", + description: "Replace App.tsx with test template", + run: applyAppTemplate + }, + { + name: "install-dependencies", + description: "Run npm install inside template app", + run: installDependencies + }, + { + name: "install-ios-pods", + description: "Install iOS pods", + run: installIosPods + }, + { + name: "initialize-code-push", + description: "Initialize code-push in native projects", + run: initializeCodePush + } +]; + +async function main() { + const options = program.parse(process.argv).opts(); + + try { + const normalizedVersion = normalizeVersion(options.rnVersion); + const workingDir = path.resolve(options.workingDir); + const projectName = buildProjectName(normalizedVersion); + const projectPath = path.join(workingDir, projectName); + + const context: SetupContext = { + rnVersion: normalizedVersion, + projectName, + workingDirectory: workingDir, + projectPath, + skipPodInstall: options.skipPodInstall ?? false + }; + + await runSetupExampleApp(context); + console.log(`\n✅ Template app created successfully: ${context.projectPath}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\n❌ Setup automation failed: ${message}`); + process.exitCode = 1; + } +} + +async function runSetupExampleApp(context: SetupContext) { + for (const step of setupSteps) { + console.log(`\n[${step.name}] ${step.description}`); + await step.run(context); + } +} + +function normalizeVersion(input: string): string { + const parsed = + semver.valid(input) ?? + semver.valid(semver.coerce(input) ?? undefined); + + if (!parsed) { + throw new Error(`Invalid React Native version: ${input}`); + } + + return parsed; +} + +function buildProjectName(version: string): string { + const versionFragment = version + .replace(/[.-]/g, "") + .replace(/[a-z]/g, (char) => char.toUpperCase()); + return `RN${versionFragment}`; +} + +async function createReactNativeTemplateApp(context: SetupContext): Promise { + ensureDirectory(context.workingDirectory); + + if (fs.existsSync(context.projectPath)) { + throw new Error(`Target directory already exists: ${context.projectPath}`); + } + + const initArgs = [ + "@react-native-community/cli@latest", + "init", + context.projectName, + "--version", + context.rnVersion, + "--skip-install", + "--install-pods", + "false", + "--skip-git-init" + ]; + + console.log( + `[command] npx ${initArgs.join(" ")} (cwd: ${context.workingDirectory})` + ); + + await executeCommand(NPX_BINARY, initArgs, context.workingDirectory); +} + +function ensureDirectory(targetDir: string) { + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } +} + +async function configureIosVersioning(context: SetupContext): Promise { + const pbxprojPath = path.join( + context.projectPath, + "ios", + `${context.projectName}.xcodeproj`, + "project.pbxproj" + ); + const podfilePath = path.join(context.projectPath, "ios", "Podfile"); + updateTextFile(pbxprojPath, (content) => { + let nextContent = replaceAllOrThrow( + content, + /MARKETING_VERSION = [^;]+;/g, + "MARKETING_VERSION = 1.0.0;", + "MARKETING_VERSION" + ); + nextContent = replaceAllOrThrow( + nextContent, + /IPHONEOS_DEPLOYMENT_TARGET = [^;]+;/g, + "IPHONEOS_DEPLOYMENT_TARGET = 16.0;", + "IPHONEOS_DEPLOYMENT_TARGET" + ); + return nextContent; + }); + + updateTextFile(podfilePath, (content) => + replaceAllOrThrow( + content, + /platform :ios,.*\n/, + "platform :ios, '16.0'\n", + "Podfile platform" + ) + ); +} + +async function configureAndroidVersioning(context: SetupContext): Promise { + const buildGradlePath = path.join( + context.projectPath, + "android", + "app", + "build.gradle" + ); + + updateTextFile(buildGradlePath, (content) => { + let next = replaceAllOrThrow( + content, + /versionName\s+"[^"]+"/g, + "versionName \"1.0.0\"", + "versionName" + ); + next = replaceAllOrThrow( + next, + /def\s+enableProguardInReleaseBuilds\s*=\s*false/g, + "def enableProguardInReleaseBuilds = true", + "enableProguardInReleaseBuilds flag" + ); + return next; + }); +} + +async function configureLocalCodeLink(context: SetupContext): Promise { + applyLocalPackageDependency(context); + await ensureRequiredDevDependencies(context); +} + +const REQUIRED_DEV_DEPENDENCIES: Array<{name: string; version?: string}> = [ + {name: "ts-node"}, + {name: "axios"}, + {name: "@types/node", version: "^22"}, + {name: "@supabase/supabase-js"} +]; + +interface TemplatePackageJson { + dependencies?: Record; + devDependencies?: Record; + scripts?: Record; + [key: string]: unknown; +} + +const APP_TEMPLATE_IDENTIFIER_PLACEHOLDER = "__IDENTIFIER__"; +const TEMPLATE_SYNC_SCRIPT_NAME = "sync-local-library"; +const TEMPLATE_POD_INSTALL_SCRIPT_NAME = "setup:pods"; + +function applyLocalPackageDependency(context: SetupContext) { + const packageJsonPath = path.join(context.projectPath, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Cannot find package.json: ${packageJsonPath}`); + } + + const originalContent = fs.readFileSync(packageJsonPath, "utf8"); + const packageJson = JSON.parse(originalContent) as TemplatePackageJson; + + packageJson.dependencies = packageJson.dependencies ?? {}; + packageJson.dependencies["@bravemobile/react-native-code-push"] = "latest"; + ensureLocalCodePushSyncScript(packageJson, context); + + const serialized = `${JSON.stringify(packageJson, null, 2)}\n`; + if (serialized !== originalContent) { + fs.writeFileSync(packageJsonPath, serialized, "utf8"); + } +} + +function ensureLocalCodePushSyncScript( + packageJson: TemplatePackageJson, + context: SetupContext +) { + const scripts = packageJson.scripts ?? {}; + const syncScriptPath = path.resolve(__dirname, "./syncLocalLibrary.ts"); + const relativeScriptPath = path.relative(context.projectPath, syncScriptPath); + const normalizedPath = relativeScriptPath.split(path.sep).join(path.posix.sep); + const syncScriptCommand = `ts-node --project tsconfig.json ${JSON.stringify( + normalizedPath + )}`; + + scripts[TEMPLATE_SYNC_SCRIPT_NAME] = syncScriptCommand; + scripts[TEMPLATE_POD_INSTALL_SCRIPT_NAME] = + "bundle install && cd ios && bundle exec pod install"; + + const postInstallCommand = `npm run ${TEMPLATE_SYNC_SCRIPT_NAME}`; + if (!scripts.postinstall) { + scripts.postinstall = postInstallCommand; + } else if (!scripts.postinstall.includes(postInstallCommand)) { + scripts.postinstall = `${scripts.postinstall} && ${postInstallCommand}`; + } + + packageJson.scripts = scripts; +} + +async function ensureRequiredDevDependencies(context: SetupContext): Promise { + const packageJsonPath = path.join(context.projectPath, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Cannot find package.json: ${packageJsonPath}`); + } + + const packageJson = JSON.parse( + fs.readFileSync(packageJsonPath, "utf8") + ) as TemplatePackageJson; + const existing = { + ...(packageJson.dependencies ?? {}), + ...(packageJson.devDependencies ?? {}) + }; + + const missing = REQUIRED_DEV_DEPENDENCIES.filter( + (pkg) => existing[pkg.name] === undefined + ); + if (missing.length === 0) { + return; + } + + const installArgs = [ + "install", + "--save-dev", + "--quiet", + "--no-progress", + "--ignore-scripts", + ...missing.map((pkg) => (pkg.version ? `${pkg.name}@${pkg.version}` : pkg.name)) + ]; + console.log( + `[command] ${NPM_BINARY} ${installArgs.join(" ")} (cwd: ${context.projectPath})` + ); + await executeCommand(NPM_BINARY, installArgs, context.projectPath); +} + +async function createCodePushConfigFile(context: SetupContext): Promise { + const templatePath = path.resolve( + __dirname, + "../../Examples/CodePushDemoApp/code-push.config.example.supabase.ts" + ); + const destinationPath = path.join(context.projectPath, "code-push.config.ts"); + + if (!fs.existsSync(templatePath)) { + throw new Error(`code-push config template file does not exist: ${templatePath}`); + } + + fs.copyFileSync(templatePath, destinationPath); +} + +async function configureTsNodeOptions(context: SetupContext): Promise { + const tsconfigPath = path.join(context.projectPath, "tsconfig.json"); + if (!fs.existsSync(tsconfigPath)) { + throw new Error(`Cannot find tsconfig.json: ${tsconfigPath}`); + } + + const originalContent = fs.readFileSync(tsconfigPath, "utf8"); + const parsed = ts.parseConfigFileTextToJson(tsconfigPath, originalContent); + if (parsed.error || !parsed.config) { + const message = parsed.error + ? ts.flattenDiagnosticMessageText(parsed.error.messageText, "\n") + : "Failed to read tsconfig.json for an unknown reason."; + throw new Error(`Failed to parse tsconfig.json: ${message}`); + } + + const tsconfig = parsed.config as { + include?: string[]; + ["ts-node"]?: { + compilerOptions?: { module?: string; types?: string[] }; + }; + [key: string]: unknown; + }; + + const includeEntries = tsconfig.include ?? []; + const requiredIncludes = ["**/*.ts", "**/*.tsx", "code-push.config.ts"]; + for (const entry of requiredIncludes) { + if (!includeEntries.includes(entry)) { + includeEntries.push(entry); + } + } + tsconfig.include = includeEntries; + + tsconfig["ts-node"] = { + compilerOptions: { + module: "CommonJS", + types: ["node"] + } + }; + + const serialized = `${JSON.stringify(tsconfig, null, 2)}\n`; + if (serialized !== originalContent) { + fs.writeFileSync(tsconfigPath, serialized, "utf8"); + } +} + +async function applyAppTemplate(context: SetupContext): Promise { + const templatePath = path.resolve(__dirname, "./templates/App.tsx.txt"); + const destinationPath = path.join(context.projectPath, "App.tsx"); + + if (!fs.existsSync(templatePath)) { + throw new Error(`App template file does not exist: ${templatePath}`); + } + + const template = fs.readFileSync(templatePath, "utf8"); + const replaced = template + .split(APP_TEMPLATE_IDENTIFIER_PLACEHOLDER) + .join(context.projectName); + fs.writeFileSync(destinationPath, replaced, "utf8"); +} + +async function installDependencies(context: SetupContext): Promise { + const installArgs = ["install", "--quiet", "--no-progress"]; + console.log( + `[command] ${NPM_BINARY} ${installArgs.join(" ")} (cwd: ${context.projectPath})` + ); + await executeCommand(NPM_BINARY, installArgs, context.projectPath); +} + +async function installIosPods(context: SetupContext): Promise { + if (context.skipPodInstall) { + console.log("[skip] --skip-pod-install enabled"); + return; + } + + const args = ["run", TEMPLATE_POD_INSTALL_SCRIPT_NAME]; + console.log( + `[command] ${NPM_BINARY} ${args.join(" ")} (cwd: ${context.projectPath})` + ); + await executeCommand(NPM_BINARY, args, context.projectPath); +} + +async function initializeCodePush(context: SetupContext): Promise { + const args = ["code-push", "init"]; + console.log( + `[command] npx ${args.join(" ")} (cwd: ${context.projectPath})` + ); + await executeCommand(NPX_BINARY, args, context.projectPath); +} + +function updateTextFile( + filePath: string, + mutator: (original: string) => string +) { + if (!fs.existsSync(filePath)) { + throw new Error(`Cannot find file: ${filePath}`); + } + const original = fs.readFileSync(filePath, "utf8"); + const mutated = mutator(original); + if (original !== mutated) { + fs.writeFileSync(filePath, mutated); + } +} + +function replaceAllOrThrow( + input: string, + matcher: RegExp, + replacement: string, + label: string +): string { + const replaced = input.replace(matcher, (match) => { + void match; + return replacement; + }); + + if (replaced === input) { + throw new Error(`No matches found for ${label} update pattern.`); + } + + return replaced; +} + +function executeCommand(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + stdio: "inherit" + }); + + child.on("error", (error) => { + reject(error); + }); + + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`${command} command failed (exit code: ${code})`)); + } + }); + }); +} + +void main(); diff --git a/scripts/setupExampleApp/syncLocalLibrary.ts b/scripts/setupExampleApp/syncLocalLibrary.ts new file mode 100644 index 000000000..ea618658d --- /dev/null +++ b/scripts/setupExampleApp/syncLocalLibrary.ts @@ -0,0 +1,105 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { spawn } from "child_process"; + +const PACKAGE_NAME = "@bravemobile/react-native-code-push"; +const REPO_ROOT = path.resolve(__dirname, "../.."); +const TEMPLATE_ROOT = process.cwd(); +const LOCAL_NPM_CACHE = path.join(REPO_ROOT, ".npm-cache"); + +ensureDirectory(LOCAL_NPM_CACHE); + +async function main() { + try { + await syncLocalLibrary(); + console.log(`✅ Synced ${PACKAGE_NAME} from ${REPO_ROOT}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`❌ Failed to sync local library: ${message}`); + process.exitCode = 1; + } finally { + fs.rmSync(LOCAL_NPM_CACHE, { recursive: true, force: true }); + } +} + +async function syncLocalLibrary(): Promise { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "code-push-sync-")); + try { + await runCommand( + "npm", + ["pack", "--pack-destination", tempDir], + REPO_ROOT, + { npm_config_cache: LOCAL_NPM_CACHE } + ); + const tarball = findTarball(tempDir); + const extractDir = path.join(tempDir, "extract"); + fs.mkdirSync(extractDir, { recursive: true }); + await runCommand("tar", ["-xzf", tarball, "-C", extractDir], REPO_ROOT); + const packageSource = path.join(extractDir, "package"); + if (!fs.existsSync(packageSource)) { + throw new Error("Failed to extract npm pack output."); + } + + const targetDir = getNodeModulesPath(); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(targetDir), { recursive: true }); + fs.cpSync(packageSource, targetDir, { recursive: true }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function getNodeModulesPath(): string { + const segments = PACKAGE_NAME.split("/"); + return path.join(TEMPLATE_ROOT, "node_modules", ...segments); +} + +function findTarball(tempDir: string): string { + const files = fs.readdirSync(tempDir).filter((file) => file.endsWith(".tgz")); + if (files.length === 0) { + throw new Error("npm pack did not produce a tarball."); + } + if (files.length > 1) { + throw new Error("Multiple tarballs found. Clean temp directory and retry."); + } + return path.join(tempDir, files[0]); +} + +function runCommand( + command: string, + args: string[], + cwd: string, + extraEnv?: Record +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + stdio: "inherit", + env: { + ...process.env, + ...extraEnv + } + }); + + child.on("error", (error) => { + reject(error); + }); + + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`${command} command failed (exit code: ${code})`)); + } + }); + }); +} + +function ensureDirectory(targetDir: string) { + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } +} + +void main(); diff --git a/scripts/setupExampleApp/templates/App.tsx.txt b/scripts/setupExampleApp/templates/App.tsx.txt new file mode 100644 index 000000000..2edb38548 --- /dev/null +++ b/scripts/setupExampleApp/templates/App.tsx.txt @@ -0,0 +1,188 @@ +import React, { useCallback, useState } from 'react'; +import { + Alert, + Button, + Platform, + ScrollView, + StatusBar, + Text, + TextInput, + View, +} from 'react-native'; +import CodePush, { + ReleaseHistoryInterface, + UpdateCheckRequest, +} from '@bravemobile/react-native-code-push'; +import axios from 'axios'; +import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; + +// Set this to true before run `npx code-push release` to release a new bundle +const IS_RELEASING_BUNDLE = false; + +const REACT_NATIVE_VERSION = (() => { + const { major, minor, patch, prerelease } = Platform.constants.reactNativeVersion; + return `${major}.${minor}.${patch}` + (prerelease ? `-${prerelease}` : ''); +})(); + +function App() { + const { top } = useSafeAreaInsets(); + const [syncResult, setSyncResult] = useState(''); + const [progress, setProgress] = useState(0); + const [runningMetadata, setRunningMetadata] = useState(''); + const [pendingMetadata, setPendingMetadata] = useState(''); + const [latestMetadata, setLatestMetadata] = useState(''); + + const handleSync = useCallback(() => { + CodePush.sync( + { updateDialog: true }, + status => { + setSyncResult(findKeyByValue(CodePush.SyncStatus, status) ?? ''); + }, + ({ receivedBytes, totalBytes }) => { + setProgress(Math.round((receivedBytes / totalBytes) * 100)); + }, + mismatch => { + Alert.alert('CodePush mismatch', JSON.stringify(mismatch, null, 2)); + }, + ).catch(error => { + console.error(error); + Alert.alert('Sync failed', error.message ?? 'Unknown error'); + }); + }, []); + + const handleMetadata = useCallback(async () => { + const [running, pending, latest] = await Promise.all([ + CodePush.getUpdateMetadata(CodePush.UpdateState.RUNNING), + CodePush.getUpdateMetadata(CodePush.UpdateState.PENDING), + CodePush.getUpdateMetadata(CodePush.UpdateState.LATEST), + ]); + setRunningMetadata(JSON.stringify(running, null, 2)); + setPendingMetadata(JSON.stringify(pending, null, 2)); + setLatestMetadata(JSON.stringify(latest, null, 2)); + }, []); + + return ( + + + {`React Native ${REACT_NATIVE_VERSION}`} + + {IS_RELEASING_BUNDLE && + {'UPDATED!'} + } + + + +