From 0652262090045da43bddd1ffee670abb2cdaad64 Mon Sep 17 00:00:00 2001 From: floydkim Date: Wed, 31 Dec 2025 19:52:45 +0900 Subject: [PATCH 01/21] chore: add RN setup automation script --- package.json | 1 + scripts/setup/runSetup.ts | 149 ++++++++++++++++++++++++++++++++++++ scripts/setup/tsconfig.json | 17 ++++ 3 files changed, 167 insertions(+) create mode 100644 scripts/setup/runSetup.ts create mode 100644 scripts/setup/tsconfig.json diff --git a/package.json b/package.json index 33d79ce17..7d46e5561 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ ], "scripts": { "setup": "npm install --quiet --no-progress", + "setup:automation": "ts-node --project scripts/setup/tsconfig.json scripts/setup/runSetup.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/setup/runSetup.ts b/scripts/setup/runSetup.ts new file mode 100644 index 000000000..cabc7541c --- /dev/null +++ b/scripts/setup/runSetup.ts @@ -0,0 +1,149 @@ +import { Command } from 'commander'; +import fs from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; +import semver from 'semver'; + +interface SetupCliOptions { + rnVersion: string; + workingDir: string; +} + +interface SetupContext { + rnVersion: string; + projectName: string; + workingDirectory: string; + projectPath: string; +} + +type SetupStep = { + name: string; + description: string; + run: (context: SetupContext) => Promise; +}; + +const program = new Command() + .name('setup-automation') + .description('React Native CodePush 테스트 앱 셋업 자동화') + .requiredOption('-v, --rn-version ', '테스트할 React Native 버전 (예: 0.83.1)') + .option( + '-w, --working-dir ', + '템플릿 앱을 생성할 경로', + path.resolve(process.cwd(), 'Examples') + ); + +const setupSteps: SetupStep[] = [ + { + name: 'create-react-native-template', + description: 'RN 템플릿 앱 생성', + run: createReactNativeTemplateApp + } +]; + +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 + }; + + await runSetup(context); + console.log(`\n✅ 템플릿 앱 생성이 완료되었습니다: ${context.projectPath}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`\n❌ 셋업 자동화에 실패했습니다: ${message}`); + process.exitCode = 1; + } +} + +async function runSetup(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(`유효하지 않은 React Native 버전입니다: ${input}`); + } + + return parsed; +} + +function buildProjectName(version: string): string { + const versionFragment = version.replace(/\./g, ''); + return `RN${versionFragment}`; +} + +async function createReactNativeTemplateApp(context: SetupContext): Promise { + ensureDirectory(context.workingDirectory); + + if (fs.existsSync(context.projectPath)) { + throw new Error(`이미 동일한 폴더가 존재합니다: ${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(getNpxBinary(), initArgs, context.workingDirectory); +} + +function ensureDirectory(targetDir: string) { + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } +} + +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} 명령이 실패했습니다 (exit code: ${code})`)); + } + }); + }); +} + +function getNpxBinary(): string { + return process.platform === 'win32' ? 'npx.cmd' : 'npx'; +} + +void main(); diff --git a/scripts/setup/tsconfig.json b/scripts/setup/tsconfig.json new file mode 100644 index 000000000..686f91f2e --- /dev/null +++ b/scripts/setup/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "moduleResolution": "Node16", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": [ + "node" + ] + }, + "include": [ + "./**/*.ts" + ] +} From 977d662ac4d75e43cd298b0dc60529e4279ce634 Mon Sep 17 00:00:00 2001 From: floydkim Date: Wed, 31 Dec 2025 20:28:50 +0900 Subject: [PATCH 02/21] chore: extend setup automation --- scripts/setup/runSetup.ts | 169 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/scripts/setup/runSetup.ts b/scripts/setup/runSetup.ts index cabc7541c..b19904007 100644 --- a/scripts/setup/runSetup.ts +++ b/scripts/setup/runSetup.ts @@ -37,6 +37,31 @@ const setupSteps: SetupStep[] = [ name: 'create-react-native-template', description: 'RN 템플릿 앱 생성', run: createReactNativeTemplateApp + }, + { + name: 'configure-ios-versioning', + description: 'iOS 버전 및 최소 지원 버전 설정', + run: configureIosVersioning + }, + { + name: 'configure-android-versioning', + description: 'Android 버전 정보 설정', + run: configureAndroidVersioning + }, + { + name: 'configure-local-code-link', + description: '로컬 라이브러리 및 Metro 설정', + run: configureLocalCodeLink + }, + { + name: 'install-dependencies', + description: '템플릿 앱 npm install 실행', + run: installDependencies + }, + { + name: 'initialize-code-push', + description: 'code-push 초기 설정', + run: initializeCodePush } ]; @@ -121,6 +146,146 @@ function ensureDirectory(targetDir: string) { } } +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) => + replaceAllOrThrow( + content, + /versionName\s+"[^"]+"/g, + 'versionName "1.0.0"', + 'versionName' + ) + ); +} + +async function configureLocalCodeLink(context: SetupContext): Promise { + applyLocalPackageDependency(context); + copyMetroConfigTemplate(context); +} + +function applyLocalPackageDependency(context: SetupContext) { + const packageJsonPath = path.join(context.projectPath, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`package.json을 찾을 수 없습니다: ${packageJsonPath}`); + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + dependencies?: Record; + [key: string]: unknown; + }; + + packageJson.dependencies = packageJson.dependencies ?? {}; + packageJson.dependencies['@bravemobile/react-native-code-push'] = 'file:../..'; + + fs.writeFileSync( + packageJsonPath, + JSON.stringify(packageJson, null, 2) + '\n', + 'utf8' + ); +} + +function copyMetroConfigTemplate(context: SetupContext) { + const templatePath = path.resolve( + __dirname, + '../../Examples/CodePushDemoApp/metro.config.js' + ); + const destinationPath = path.join(context.projectPath, 'metro.config.js'); + + if (!fs.existsSync(templatePath)) { + throw new Error(`Metro 템플릿 파일이 없습니다: ${templatePath}`); + } + + fs.copyFileSync(templatePath, destinationPath); +} + +async function installDependencies(context: SetupContext): Promise { + const installArgs = ['install', '--quiet', '--no-progress']; + console.log( + `[command] ${getNpmBinary()} ${installArgs.join(' ')} (cwd: ${context.projectPath})` + ); + await executeCommand(getNpmBinary(), installArgs, context.projectPath); +} + +async function initializeCodePush(context: SetupContext): Promise { + const args = ['code-push', 'init']; + console.log( + `[command] npx ${args.join(' ')} (cwd: ${context.projectPath})` + ); + await executeCommand(getNpxBinary(), args, context.projectPath); +} + +function updateTextFile( + filePath: string, + mutator: (original: string) => string +) { + if (!fs.existsSync(filePath)) { + throw new Error(`파일을 찾을 수 없습니다: ${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(`${label} 업데이트 패턴과 일치하는 내용이 없습니다.`); + } + + return replaced; +} + function executeCommand(command: string, args: string[], cwd: string): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -146,4 +311,8 @@ function getNpxBinary(): string { return process.platform === 'win32' ? 'npx.cmd' : 'npx'; } +function getNpmBinary(): string { + return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +} + void main(); From 61645977cab4540755bd98252acbf38c316ee891 Mon Sep 17 00:00:00 2001 From: floydkim Date: Wed, 31 Dec 2025 20:35:02 +0900 Subject: [PATCH 03/21] chore: fix setup script lint --- scripts/setup/runSetup.ts | 124 +++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/scripts/setup/runSetup.ts b/scripts/setup/runSetup.ts index b19904007..c314cdf3b 100644 --- a/scripts/setup/runSetup.ts +++ b/scripts/setup/runSetup.ts @@ -1,8 +1,8 @@ -import { Command } from 'commander'; -import fs from 'fs'; -import path from 'path'; -import { spawn } from 'child_process'; -import semver from 'semver'; +import { Command } from "commander"; +import fs from "fs"; +import path from "path"; +import { spawn } from "child_process"; +import semver from "semver"; interface SetupCliOptions { rnVersion: string; @@ -23,44 +23,44 @@ type SetupStep = { }; const program = new Command() - .name('setup-automation') - .description('React Native CodePush 테스트 앱 셋업 자동화') - .requiredOption('-v, --rn-version ', '테스트할 React Native 버전 (예: 0.83.1)') + .name("setup-automation") + .description("React Native CodePush 테스트 앱 셋업 자동화") + .requiredOption("-v, --rn-version ", "테스트할 React Native 버전 (예: 0.83.1)") .option( - '-w, --working-dir ', - '템플릿 앱을 생성할 경로', - path.resolve(process.cwd(), 'Examples') + "-w, --working-dir ", + "템플릿 앱을 생성할 경로", + path.resolve(process.cwd(), "Examples") ); const setupSteps: SetupStep[] = [ { - name: 'create-react-native-template', - description: 'RN 템플릿 앱 생성', + name: "create-react-native-template", + description: "RN 템플릿 앱 생성", run: createReactNativeTemplateApp }, { - name: 'configure-ios-versioning', - description: 'iOS 버전 및 최소 지원 버전 설정', + name: "configure-ios-versioning", + description: "iOS 버전 및 최소 지원 버전 설정", run: configureIosVersioning }, { - name: 'configure-android-versioning', - description: 'Android 버전 정보 설정', + name: "configure-android-versioning", + description: "Android 버전 정보 설정", run: configureAndroidVersioning }, { - name: 'configure-local-code-link', - description: '로컬 라이브러리 및 Metro 설정', + name: "configure-local-code-link", + description: "로컬 라이브러리 및 Metro 설정", run: configureLocalCodeLink }, { - name: 'install-dependencies', - description: '템플릿 앱 npm install 실행', + name: "install-dependencies", + description: "템플릿 앱 npm install 실행", run: installDependencies }, { - name: 'initialize-code-push', - description: 'code-push 초기 설정', + name: "initialize-code-push", + description: "code-push 초기 설정", run: initializeCodePush } ]; @@ -110,7 +110,7 @@ function normalizeVersion(input: string): string { } function buildProjectName(version: string): string { - const versionFragment = version.replace(/\./g, ''); + const versionFragment = version.replace(/\./g, ""); return `RN${versionFragment}`; } @@ -122,19 +122,19 @@ async function createReactNativeTemplateApp(context: SetupContext): Promise { const pbxprojPath = path.join( context.projectPath, - 'ios', + "ios", `${context.projectName}.xcodeproj`, - 'project.pbxproj' + "project.pbxproj" ); - const podfilePath = path.join(context.projectPath, 'ios', 'Podfile'); + 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' + "MARKETING_VERSION = 1.0.0;", + "MARKETING_VERSION" ); nextContent = replaceAllOrThrow( nextContent, /IPHONEOS_DEPLOYMENT_TARGET = [^;]+;/g, - 'IPHONEOS_DEPLOYMENT_TARGET = 16.0;', - 'IPHONEOS_DEPLOYMENT_TARGET' + "IPHONEOS_DEPLOYMENT_TARGET = 16.0;", + "IPHONEOS_DEPLOYMENT_TARGET" ); return nextContent; }); @@ -175,7 +175,7 @@ async function configureIosVersioning(context: SetupContext): Promise { content, /platform :ios,.*\n/, "platform :ios, '16.0'\n", - 'Podfile platform' + "Podfile platform" ) ); } @@ -183,17 +183,17 @@ async function configureIosVersioning(context: SetupContext): Promise { async function configureAndroidVersioning(context: SetupContext): Promise { const buildGradlePath = path.join( context.projectPath, - 'android', - 'app', - 'build.gradle' + "android", + "app", + "build.gradle" ); updateTextFile(buildGradlePath, (content) => replaceAllOrThrow( content, /versionName\s+"[^"]+"/g, - 'versionName "1.0.0"', - 'versionName' + "versionName \"1.0.0\"", + "versionName" ) ); } @@ -204,32 +204,32 @@ async function configureLocalCodeLink(context: SetupContext): Promise { } function applyLocalPackageDependency(context: SetupContext) { - const packageJsonPath = path.join(context.projectPath, 'package.json'); + const packageJsonPath = path.join(context.projectPath, "package.json"); if (!fs.existsSync(packageJsonPath)) { throw new Error(`package.json을 찾을 수 없습니다: ${packageJsonPath}`); } - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { dependencies?: Record; [key: string]: unknown; }; packageJson.dependencies = packageJson.dependencies ?? {}; - packageJson.dependencies['@bravemobile/react-native-code-push'] = 'file:../..'; + packageJson.dependencies["@bravemobile/react-native-code-push"] = "file:../.."; fs.writeFileSync( packageJsonPath, - JSON.stringify(packageJson, null, 2) + '\n', - 'utf8' + JSON.stringify(packageJson, null, 2) + "\n", + "utf8" ); } function copyMetroConfigTemplate(context: SetupContext) { const templatePath = path.resolve( __dirname, - '../../Examples/CodePushDemoApp/metro.config.js' + "../../Examples/CodePushDemoApp/metro.config.js" ); - const destinationPath = path.join(context.projectPath, 'metro.config.js'); + const destinationPath = path.join(context.projectPath, "metro.config.js"); if (!fs.existsSync(templatePath)) { throw new Error(`Metro 템플릿 파일이 없습니다: ${templatePath}`); @@ -239,17 +239,17 @@ function copyMetroConfigTemplate(context: SetupContext) { } async function installDependencies(context: SetupContext): Promise { - const installArgs = ['install', '--quiet', '--no-progress']; + const installArgs = ["install", "--quiet", "--no-progress"]; console.log( - `[command] ${getNpmBinary()} ${installArgs.join(' ')} (cwd: ${context.projectPath})` + `[command] ${getNpmBinary()} ${installArgs.join(" ")} (cwd: ${context.projectPath})` ); await executeCommand(getNpmBinary(), installArgs, context.projectPath); } async function initializeCodePush(context: SetupContext): Promise { - const args = ['code-push', 'init']; + const args = ["code-push", "init"]; console.log( - `[command] npx ${args.join(' ')} (cwd: ${context.projectPath})` + `[command] npx ${args.join(" ")} (cwd: ${context.projectPath})` ); await executeCommand(getNpxBinary(), args, context.projectPath); } @@ -261,7 +261,7 @@ function updateTextFile( if (!fs.existsSync(filePath)) { throw new Error(`파일을 찾을 수 없습니다: ${filePath}`); } - const original = fs.readFileSync(filePath, 'utf8'); + const original = fs.readFileSync(filePath, "utf8"); const mutated = mutator(original); if (original !== mutated) { fs.writeFileSync(filePath, mutated); @@ -290,14 +290,14 @@ function executeCommand(command: string, args: string[], cwd: string): Promise { const child = spawn(command, args, { cwd, - stdio: 'inherit' + stdio: "inherit" }); - child.on('error', (error) => { + child.on("error", (error) => { reject(error); }); - child.on('close', (code) => { + child.on("close", (code) => { if (code === 0) { resolve(); } else { @@ -308,11 +308,11 @@ function executeCommand(command: string, args: string[], cwd: string): Promise Date: Wed, 31 Dec 2025 21:12:42 +0900 Subject: [PATCH 04/21] chore: enhance setup automation --- eslint.config.mjs | 1 + scripts/setup/runSetup.ts | 70 +++++++++++++++++++++++++++++++++++++++ tslint.json | 3 +- 3 files changed, 73 insertions(+), 1 deletion(-) 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/scripts/setup/runSetup.ts b/scripts/setup/runSetup.ts index c314cdf3b..6f29e8530 100644 --- a/scripts/setup/runSetup.ts +++ b/scripts/setup/runSetup.ts @@ -3,6 +3,7 @@ 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; @@ -53,6 +54,16 @@ const setupSteps: SetupStep[] = [ description: "로컬 라이브러리 및 Metro 설정", run: configureLocalCodeLink }, + { + name: "create-code-push-config", + description: "code-push 설정 템플릿 적용", + run: createCodePushConfigFile + }, + { + name: "configure-ts-node", + description: "ts-node 실행 환경 설정", + run: configureTsNodeOptions + }, { name: "install-dependencies", description: "템플릿 앱 npm install 실행", @@ -238,6 +249,65 @@ function copyMetroConfigTemplate(context: SetupContext) { fs.copyFileSync(templatePath, destinationPath); } +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 설정 템플릿 파일이 없습니다: ${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(`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") + : "알 수 없는 이유로 tsconfig.json을 읽지 못했습니다."; + throw new Error(`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 installDependencies(context: SetupContext): Promise { const installArgs = ["install", "--quiet", "--no-progress"]; console.log( diff --git a/tslint.json b/tslint.json index ac3e8ad70..1199db110 100644 --- a/tslint.json +++ b/tslint.json @@ -31,7 +31,8 @@ }, "linterOptions": { "exclude": [ - "./cli/**" + "./cli/**", + "./Examples/**/*" ] } } From aef49881e555e4e6e38105469fc161bbc433a628 Mon Sep 17 00:00:00 2001 From: floydkim Date: Wed, 31 Dec 2025 21:25:22 +0900 Subject: [PATCH 05/21] chore: install dev deps during setup --- scripts/setup/runSetup.ts | 61 ++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/scripts/setup/runSetup.ts b/scripts/setup/runSetup.ts index 6f29e8530..d6d7e23a6 100644 --- a/scripts/setup/runSetup.ts +++ b/scripts/setup/runSetup.ts @@ -212,6 +212,20 @@ async function configureAndroidVersioning(context: SetupContext): Promise async function configureLocalCodeLink(context: SetupContext): Promise { applyLocalPackageDependency(context); copyMetroConfigTemplate(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; + [key: string]: unknown; } function applyLocalPackageDependency(context: SetupContext) { @@ -220,19 +234,50 @@ function applyLocalPackageDependency(context: SetupContext) { throw new Error(`package.json을 찾을 수 없습니다: ${packageJsonPath}`); } - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { - dependencies?: Record; - [key: string]: unknown; - }; + const originalContent = fs.readFileSync(packageJsonPath, "utf8"); + const packageJson = JSON.parse(originalContent) as TemplatePackageJson; packageJson.dependencies = packageJson.dependencies ?? {}; packageJson.dependencies["@bravemobile/react-native-code-push"] = "file:../.."; - fs.writeFileSync( - packageJsonPath, - JSON.stringify(packageJson, null, 2) + "\n", - "utf8" + const serialized = `${JSON.stringify(packageJson, null, 2)}\n`; + if (serialized !== originalContent) { + fs.writeFileSync(packageJsonPath, serialized, "utf8"); + } +} + +async function ensureRequiredDevDependencies(context: SetupContext): Promise { + const packageJsonPath = path.join(context.projectPath, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`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", + ...missing.map((pkg) => (pkg.version ? `${pkg.name}@${pkg.version}` : pkg.name)) + ]; + console.log( + `[command] ${getNpmBinary()} ${installArgs.join(" ")} (cwd: ${context.projectPath})` ); + await executeCommand(getNpmBinary(), installArgs, context.projectPath); } function copyMetroConfigTemplate(context: SetupContext) { From 085cd297a651cf7e0dc593fd1ffa51aac53e2a93 Mon Sep 17 00:00:00 2001 From: floydkim Date: Wed, 31 Dec 2025 21:28:09 +0900 Subject: [PATCH 06/21] chore: translate setup logs to english --- scripts/setup/runSetup.ts | 50 +++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/scripts/setup/runSetup.ts b/scripts/setup/runSetup.ts index d6d7e23a6..1b2c0e2a9 100644 --- a/scripts/setup/runSetup.ts +++ b/scripts/setup/runSetup.ts @@ -25,53 +25,53 @@ type SetupStep = { const program = new Command() .name("setup-automation") - .description("React Native CodePush 테스트 앱 셋업 자동화") - .requiredOption("-v, --rn-version ", "테스트할 React Native 버전 (예: 0.83.1)") + .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") ); const setupSteps: SetupStep[] = [ { name: "create-react-native-template", - description: "RN 템플릿 앱 생성", + description: "Create RN template app", run: createReactNativeTemplateApp }, { name: "configure-ios-versioning", - description: "iOS 버전 및 최소 지원 버전 설정", + description: "Configure iOS versioning and minimum OS", run: configureIosVersioning }, { name: "configure-android-versioning", - description: "Android 버전 정보 설정", + description: "Configure Android version information", run: configureAndroidVersioning }, { name: "configure-local-code-link", - description: "로컬 라이브러리 및 Metro 설정", + description: "Configure local library link and Metro", run: configureLocalCodeLink }, { name: "create-code-push-config", - description: "code-push 설정 템플릿 적용", + description: "Apply code-push config template", run: createCodePushConfigFile }, { name: "configure-ts-node", - description: "ts-node 실행 환경 설정", + description: "Configure ts-node runtime options", run: configureTsNodeOptions }, { name: "install-dependencies", - description: "템플릿 앱 npm install 실행", + description: "Run npm install inside template app", run: installDependencies }, { name: "initialize-code-push", - description: "code-push 초기 설정", + description: "Initialize code-push in native projects", run: initializeCodePush } ]; @@ -93,10 +93,10 @@ async function main() { }; await runSetup(context); - console.log(`\n✅ 템플릿 앱 생성이 완료되었습니다: ${context.projectPath}`); + console.log(`\n✅ Template app created successfully: ${context.projectPath}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error(`\n❌ 셋업 자동화에 실패했습니다: ${message}`); + console.error(`\n❌ Setup automation failed: ${message}`); process.exitCode = 1; } } @@ -114,7 +114,7 @@ function normalizeVersion(input: string): string { semver.valid(semver.coerce(input) ?? undefined); if (!parsed) { - throw new Error(`유효하지 않은 React Native 버전입니다: ${input}`); + throw new Error(`Invalid React Native version: ${input}`); } return parsed; @@ -129,7 +129,7 @@ async function createReactNativeTemplateApp(context: SetupContext): Promise { const packageJsonPath = path.join(context.projectPath, "package.json"); if (!fs.existsSync(packageJsonPath)) { - throw new Error(`package.json을 찾을 수 없습니다: ${packageJsonPath}`); + throw new Error(`Cannot find package.json: ${packageJsonPath}`); } const packageJson = JSON.parse( @@ -288,7 +288,7 @@ function copyMetroConfigTemplate(context: SetupContext) { const destinationPath = path.join(context.projectPath, "metro.config.js"); if (!fs.existsSync(templatePath)) { - throw new Error(`Metro 템플릿 파일이 없습니다: ${templatePath}`); + throw new Error(`Metro template file does not exist: ${templatePath}`); } fs.copyFileSync(templatePath, destinationPath); @@ -302,7 +302,7 @@ async function createCodePushConfigFile(context: SetupContext): Promise { const destinationPath = path.join(context.projectPath, "code-push.config.ts"); if (!fs.existsSync(templatePath)) { - throw new Error(`code-push 설정 템플릿 파일이 없습니다: ${templatePath}`); + throw new Error(`code-push config template file does not exist: ${templatePath}`); } fs.copyFileSync(templatePath, destinationPath); @@ -311,7 +311,7 @@ async function createCodePushConfigFile(context: SetupContext): Promise { async function configureTsNodeOptions(context: SetupContext): Promise { const tsconfigPath = path.join(context.projectPath, "tsconfig.json"); if (!fs.existsSync(tsconfigPath)) { - throw new Error(`tsconfig.json을 찾을 수 없습니다: ${tsconfigPath}`); + throw new Error(`Cannot find tsconfig.json: ${tsconfigPath}`); } const originalContent = fs.readFileSync(tsconfigPath, "utf8"); @@ -319,8 +319,8 @@ async function configureTsNodeOptions(context: SetupContext): Promise { if (parsed.error || !parsed.config) { const message = parsed.error ? ts.flattenDiagnosticMessageText(parsed.error.messageText, "\n") - : "알 수 없는 이유로 tsconfig.json을 읽지 못했습니다."; - throw new Error(`tsconfig.json 파싱에 실패했습니다: ${message}`); + : "Failed to read tsconfig.json for an unknown reason."; + throw new Error(`Failed to parse tsconfig.json: ${message}`); } const tsconfig = parsed.config as { @@ -374,7 +374,7 @@ function updateTextFile( mutator: (original: string) => string ) { if (!fs.existsSync(filePath)) { - throw new Error(`파일을 찾을 수 없습니다: ${filePath}`); + throw new Error(`Cannot find file: ${filePath}`); } const original = fs.readFileSync(filePath, "utf8"); const mutated = mutator(original); @@ -395,7 +395,7 @@ function replaceAllOrThrow( }); if (replaced === input) { - throw new Error(`${label} 업데이트 패턴과 일치하는 내용이 없습니다.`); + throw new Error(`No matches found for ${label} update pattern.`); } return replaced; @@ -416,7 +416,7 @@ function executeCommand(command: string, args: string[], cwd: string): Promise Date: Fri, 2 Jan 2026 17:32:28 +0900 Subject: [PATCH 07/21] chore: simplify command binaries --- scripts/setup/runSetup.ts | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/scripts/setup/runSetup.ts b/scripts/setup/runSetup.ts index 1b2c0e2a9..d13738153 100644 --- a/scripts/setup/runSetup.ts +++ b/scripts/setup/runSetup.ts @@ -23,6 +23,9 @@ type SetupStep = { 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") @@ -148,7 +151,7 @@ async function createReactNativeTemplateApp(context: SetupContext): Promise (pkg.version ? `${pkg.name}@${pkg.version}` : pkg.name)) ]; console.log( - `[command] ${getNpmBinary()} ${installArgs.join(" ")} (cwd: ${context.projectPath})` + `[command] ${NPM_BINARY} ${installArgs.join(" ")} (cwd: ${context.projectPath})` ); - await executeCommand(getNpmBinary(), installArgs, context.projectPath); + await executeCommand(NPM_BINARY, installArgs, context.projectPath); } function copyMetroConfigTemplate(context: SetupContext) { @@ -356,9 +359,9 @@ async function configureTsNodeOptions(context: SetupContext): Promise { async function installDependencies(context: SetupContext): Promise { const installArgs = ["install", "--quiet", "--no-progress"]; console.log( - `[command] ${getNpmBinary()} ${installArgs.join(" ")} (cwd: ${context.projectPath})` + `[command] ${NPM_BINARY} ${installArgs.join(" ")} (cwd: ${context.projectPath})` ); - await executeCommand(getNpmBinary(), installArgs, context.projectPath); + await executeCommand(NPM_BINARY, installArgs, context.projectPath); } async function initializeCodePush(context: SetupContext): Promise { @@ -366,7 +369,7 @@ async function initializeCodePush(context: SetupContext): Promise { console.log( `[command] npx ${args.join(" ")} (cwd: ${context.projectPath})` ); - await executeCommand(getNpxBinary(), args, context.projectPath); + await executeCommand(NPX_BINARY, args, context.projectPath); } function updateTextFile( @@ -422,12 +425,4 @@ function executeCommand(command: string, args: string[], cwd: string): Promise Date: Sat, 3 Jan 2026 21:31:45 +0900 Subject: [PATCH 08/21] chore: apply app template from text --- scripts/setup/runSetup.ts | 22 ++++ scripts/setup/templates/App.tsx.txt | 182 ++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 scripts/setup/templates/App.tsx.txt diff --git a/scripts/setup/runSetup.ts b/scripts/setup/runSetup.ts index d13738153..33af3a3e7 100644 --- a/scripts/setup/runSetup.ts +++ b/scripts/setup/runSetup.ts @@ -67,6 +67,11 @@ const setupSteps: SetupStep[] = [ 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", @@ -231,6 +236,8 @@ interface TemplatePackageJson { [key: string]: unknown; } +const APP_TEMPLATE_IDENTIFIER_PLACEHOLDER = "__IDENTIFIER__"; + function applyLocalPackageDependency(context: SetupContext) { const packageJsonPath = path.join(context.projectPath, "package.json"); if (!fs.existsSync(packageJsonPath)) { @@ -356,6 +363,21 @@ async function configureTsNodeOptions(context: SetupContext): Promise { } } +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( diff --git a/scripts/setup/templates/App.tsx.txt b/scripts/setup/templates/App.tsx.txt new file mode 100644 index 000000000..9971a80fc --- /dev/null +++ b/scripts/setup/templates/App.tsx.txt @@ -0,0 +1,182 @@ +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 } = Platform.constants.reactNativeVersion; + return `${major}.${minor}.${patch}`; +})(); + +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!'} + } + + + +