From 9092124376eb84cbb3cec2e263c26af8214debf4 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Thu, 12 Mar 2026 17:20:50 +0100 Subject: [PATCH 1/5] fix(init): harden npm project creation flow --- packages/init/src/tasks/createNewProject.ts | 40 +++- packages/init/tsconfig.build.json | 8 + packages/init/tsconfig.json | 4 - pnpm-lock.yaml | 12 ++ tests/init/package.json | 19 ++ tests/init/src/createNewProject.test.ts | 193 ++++++++++++++++++++ tests/init/tsconfig.json | 12 ++ tests/init/vitest.config.ts | 8 + 8 files changed, 288 insertions(+), 8 deletions(-) create mode 100644 packages/init/tsconfig.build.json create mode 100644 tests/init/package.json create mode 100644 tests/init/src/createNewProject.test.ts create mode 100644 tests/init/tsconfig.json create mode 100644 tests/init/vitest.config.ts diff --git a/packages/init/src/tasks/createNewProject.ts b/packages/init/src/tasks/createNewProject.ts index a70e6bc33..d89a42bdc 100644 --- a/packages/init/src/tasks/createNewProject.ts +++ b/packages/init/src/tasks/createNewProject.ts @@ -5,6 +5,37 @@ import type { PackageManager } from '../types/pm.js'; import { RepackInitError } from '../utils/error.js'; import spinner from '../utils/spinner.js'; +function getCreateCommand(packageManager: PackageManager) { + if (packageManager.name === 'npm') { + // Use `npm exec` with the package's explicit bin name. Nested `npx` + // invocations can fail when repack-init itself is launched through `npx`. + return { + command: packageManager.runCommand, + args: [ + 'exec', + '--yes', + '--package', + '@react-native-community/cli@latest', + '--', + 'rnc-cli', + ], + shell: false, + }; + } + + return { + command: packageManager.dlxCommand, + args: ['@react-native-community/cli@latest'], + shell: true, + }; +} + +function getReactNativeVersion() { + const version = versionsJson['react-native']; + + return /^\d+\.\d+$/.test(version) ? `${version}.0` : version; +} + export default async function createNewProject( cwd: string, projectName: string, @@ -12,14 +43,15 @@ export default async function createNewProject( override: boolean ) { try { + const createCommand = getCreateCommand(packageManager); const args = [ - '@react-native-community/cli@latest', + ...createCommand.args, 'init', projectName, '--directory', path.join(cwd, projectName), '--version', - versionsJson['react-native'], + getReactNativeVersion(), '--skip-install', '--skip-git-init', ]; @@ -32,9 +64,9 @@ export default async function createNewProject( 'Creating new project from the React Native Community Template' ); - return await execa(packageManager.dlxCommand, args, { + return await execa(createCommand.command, args, { stdio: 'ignore', - shell: true, + shell: createCommand.shell, }); } catch { throw new RepackInitError( diff --git a/packages/init/tsconfig.build.json b/packages/init/tsconfig.build.json new file mode 100644 index 000000000..e63fac8ca --- /dev/null +++ b/packages/init/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/init/tsconfig.json b/packages/init/tsconfig.json index df59da578..9e25e6ece 100644 --- a/packages/init/tsconfig.json +++ b/packages/init/tsconfig.json @@ -1,8 +1,4 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist" - }, "include": ["src/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1c31c55c..17869543d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -729,6 +729,18 @@ importers: specifier: 'catalog:' version: 5.105.3 + tests/init: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.31 + typescript: + specifier: 'catalog:' + version: 5.8.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.28.2)(terser@5.31.3)(yaml@2.8.2) + tests/integration: devDependencies: '@callstack/repack': diff --git a/tests/init/package.json b/tests/init/package.json new file mode 100644 index 000000000..fa76d8a8f --- /dev/null +++ b/tests/init/package.json @@ -0,0 +1,19 @@ +{ + "name": "init-test", + "version": "0.0.1", + "description": "Tests for @callstack/repack-init", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "pnpm --filter @callstack/repack-init build && vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/tests/init/src/createNewProject.test.ts b/tests/init/src/createNewProject.test.ts new file mode 100644 index 000000000..1a38227cb --- /dev/null +++ b/tests/init/src/createNewProject.test.ts @@ -0,0 +1,193 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { afterEach, describe, expect, it } from 'vitest'; + +const expectPath = '/usr/bin/expect'; +const repoRootDir = path.resolve(import.meta.dirname, '../../..'); +const initBinPath = path.join(repoRootDir, 'packages/init/dist/bin.js'); +const tempDirs: string[] = []; + +const projectPbxprojFixture = `// !$*UTF8*$! +{ + archiveVersion = 1; + classes = {}; + objectVersion = 56; + objects = { + +/* Begin PBXShellScriptBuildPhase section */ + 1234567890ABCDEF12345678 /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo hi\\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + }; + rootObject = 1234567890ABCDEF12345678; +} +`; + +function createTempDir(prefix: string) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(tempDir); + return tempDir; +} + +function writeExecutable(filePath: string, content: string) { + fs.writeFileSync(filePath, content); + fs.chmodSync(filePath, 0o755); +} + +function createFetchStub(tempDir: string) { + const fetchStubPath = path.join(tempDir, 'fetch-stub.mjs'); + + fs.writeFileSync( + fetchStubPath, + `globalThis.fetch = async () => ({ + text: async () => "export default {\\n entry = 'index.js',\\n};\\n", +}); +` + ); + + return fetchStubPath; +} + +function createFakeNpm(binDir: string, argsFile: string) { + const fakeNpmPath = path.join(binDir, 'npm'); + + writeExecutable( + fakeNpmPath, + `#!/bin/sh +printf '%s\n' "$@" > "${argsFile}" +directory="" +previous="" + +for arg in "$@"; do + if [ "$previous" = "--directory" ]; then + directory="$arg" + fi + previous="$arg" +done + +if [ -n "$directory" ]; then + mkdir -p "$directory/ios/RepackApp.xcodeproj" + cat > "$directory/package.json" <<'EOF' +{ + "name": "RepackApp", + "version": "0.0.1", + "private": true, + "dependencies": { + "react-native": "0.81.0" + } +} +EOF + cat > "$directory/ios/RepackApp.xcodeproj/project.pbxproj" <<'EOF' +${projectPbxprojFixture} +EOF +fi + +exit 0 +` + ); +} + +function createFakeGit(binDir: string) { + const fakeGitPath = path.join(binDir, 'git'); + + writeExecutable( + fakeGitPath, + `#!/bin/sh +exit 1 +` + ); +} + +function runBuiltInitWithFakeNpm() { + const tempDir = createTempDir('repack-init-test-'); + const fakeBinDir = createTempDir('repack-init-bin-'); + const argsFile = path.join(fakeBinDir, 'npm-args.txt'); + const fetchStubPath = createFetchStub(tempDir); + + createFakeNpm(fakeBinDir, argsFile); + createFakeGit(fakeBinDir); + + const result = spawnSync( + expectPath, + [ + '-c', + `set timeout 30 +spawn node ${initBinPath} --bundler rspack --verbose +expect {Detected npm as package manager running the script} +after 1000 +send "\\r" +expect eof +catch wait result +set exit_code [lindex $result 3] +exit $exit_code +`, + ], + { + cwd: tempDir, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${fakeBinDir}:${process.env.PATH ?? ''}`, + PWD: tempDir, + npm_config_user_agent: 'npm/10.9.0 node/v22.0.0 darwin x64', + NODE_OPTIONS: `--import=${fetchStubPath}`, + FORCE_COLOR: '0', + }, + } + ); + + const debugOutput = [result.stdout, result.stderr].filter(Boolean).join('\n'); + + expect(result.status, debugOutput).toBe(0); + expect(fs.existsSync(argsFile)).toBe(true); + + return { + args: fs.readFileSync(argsFile, 'utf8').trim().split('\n'), + projectRootDir: path.join(tempDir, 'RepackApp'), + }; +} + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +describe('built repack-init CLI', () => { + it('uses npm exec with rnc-cli and a fully-qualified react-native version', () => { + const { args, projectRootDir } = runBuiltInitWithFakeNpm(); + const versionFlagIndex = args.indexOf('--version'); + + expect(args.slice(0, 6)).toEqual([ + 'exec', + '--yes', + '--package', + '@react-native-community/cli@latest', + '--', + 'rnc-cli', + ]); + expect(versionFlagIndex).not.toBe(-1); + expect(args[versionFlagIndex + 1]).toMatch(/^\d+\.\d+\.\d+(?:-.+)?$/); + expect(fs.existsSync(path.join(projectRootDir, 'rspack.config.mjs'))).toBe( + true + ); + expect( + fs.existsSync(path.join(projectRootDir, 'react-native.config.js')) + ).toBe(true); + }); +}); diff --git a/tests/init/tsconfig.json b/tests/init/tsconfig.json new file mode 100644 index 000000000..bec28a273 --- /dev/null +++ b/tests/init/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src", "vitest.config.ts"] +} diff --git a/tests/init/vitest.config.ts b/tests/init/vitest.config.ts new file mode 100644 index 000000000..946f50d48 --- /dev/null +++ b/tests/init/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 30_000, + }, +}); From 443884901eb98942ddcf418a93b63c0e24864291 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Thu, 12 Mar 2026 17:21:21 +0100 Subject: [PATCH 2/5] style(init-test): apply biome formatting --- tests/init/src/createNewProject.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/init/src/createNewProject.test.ts b/tests/init/src/createNewProject.test.ts index 1a38227cb..a650421e8 100644 --- a/tests/init/src/createNewProject.test.ts +++ b/tests/init/src/createNewProject.test.ts @@ -1,7 +1,7 @@ +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; import { afterEach, describe, expect, it } from 'vitest'; const expectPath = '/usr/bin/expect'; From 10d06efa0137ddac42ee02d565a78965b26d9214 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Thu, 12 Mar 2026 17:25:04 +0100 Subject: [PATCH 3/5] fix: dont build package when testing --- tests/init/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/init/package.json b/tests/init/package.json index fa76d8a8f..52ff83489 100644 --- a/tests/init/package.json +++ b/tests/init/package.json @@ -8,7 +8,7 @@ "node": ">=22" }, "scripts": { - "test": "pnpm --filter @callstack/repack-init build && vitest run", + "test": "vitest run", "test:watch": "vitest" }, "devDependencies": { From 159905f879f520e5a2fe8873bad961071286216a Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Thu, 12 Mar 2026 17:31:12 +0100 Subject: [PATCH 4/5] chore(init): remove CLI smoke test workspace --- pnpm-lock.yaml | 12 -- tests/init/package.json | 19 --- tests/init/src/createNewProject.test.ts | 193 ------------------------ tests/init/tsconfig.json | 12 -- tests/init/vitest.config.ts | 8 - 5 files changed, 244 deletions(-) delete mode 100644 tests/init/package.json delete mode 100644 tests/init/src/createNewProject.test.ts delete mode 100644 tests/init/tsconfig.json delete mode 100644 tests/init/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17869543d..c1c31c55c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -729,18 +729,6 @@ importers: specifier: 'catalog:' version: 5.105.3 - tests/init: - devDependencies: - '@types/node': - specifier: 'catalog:' - version: 20.19.31 - typescript: - specifier: 'catalog:' - version: 5.8.3 - vitest: - specifier: 'catalog:' - version: 4.0.18(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.28.2)(terser@5.31.3)(yaml@2.8.2) - tests/integration: devDependencies: '@callstack/repack': diff --git a/tests/init/package.json b/tests/init/package.json deleted file mode 100644 index 52ff83489..000000000 --- a/tests/init/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "init-test", - "version": "0.0.1", - "description": "Tests for @callstack/repack-init", - "private": true, - "type": "module", - "engines": { - "node": ">=22" - }, - "scripts": { - "test": "vitest run", - "test:watch": "vitest" - }, - "devDependencies": { - "@types/node": "catalog:", - "typescript": "catalog:", - "vitest": "catalog:" - } -} diff --git a/tests/init/src/createNewProject.test.ts b/tests/init/src/createNewProject.test.ts deleted file mode 100644 index a650421e8..000000000 --- a/tests/init/src/createNewProject.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; - -const expectPath = '/usr/bin/expect'; -const repoRootDir = path.resolve(import.meta.dirname, '../../..'); -const initBinPath = path.join(repoRootDir, 'packages/init/dist/bin.js'); -const tempDirs: string[] = []; - -const projectPbxprojFixture = `// !$*UTF8*$! -{ - archiveVersion = 1; - classes = {}; - objectVersion = 56; - objects = { - -/* Begin PBXShellScriptBuildPhase section */ - 1234567890ABCDEF12345678 /* Bundle React Native code and images */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Bundle React Native code and images"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo hi\\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - }; - rootObject = 1234567890ABCDEF12345678; -} -`; - -function createTempDir(prefix: string) { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(tempDir); - return tempDir; -} - -function writeExecutable(filePath: string, content: string) { - fs.writeFileSync(filePath, content); - fs.chmodSync(filePath, 0o755); -} - -function createFetchStub(tempDir: string) { - const fetchStubPath = path.join(tempDir, 'fetch-stub.mjs'); - - fs.writeFileSync( - fetchStubPath, - `globalThis.fetch = async () => ({ - text: async () => "export default {\\n entry = 'index.js',\\n};\\n", -}); -` - ); - - return fetchStubPath; -} - -function createFakeNpm(binDir: string, argsFile: string) { - const fakeNpmPath = path.join(binDir, 'npm'); - - writeExecutable( - fakeNpmPath, - `#!/bin/sh -printf '%s\n' "$@" > "${argsFile}" -directory="" -previous="" - -for arg in "$@"; do - if [ "$previous" = "--directory" ]; then - directory="$arg" - fi - previous="$arg" -done - -if [ -n "$directory" ]; then - mkdir -p "$directory/ios/RepackApp.xcodeproj" - cat > "$directory/package.json" <<'EOF' -{ - "name": "RepackApp", - "version": "0.0.1", - "private": true, - "dependencies": { - "react-native": "0.81.0" - } -} -EOF - cat > "$directory/ios/RepackApp.xcodeproj/project.pbxproj" <<'EOF' -${projectPbxprojFixture} -EOF -fi - -exit 0 -` - ); -} - -function createFakeGit(binDir: string) { - const fakeGitPath = path.join(binDir, 'git'); - - writeExecutable( - fakeGitPath, - `#!/bin/sh -exit 1 -` - ); -} - -function runBuiltInitWithFakeNpm() { - const tempDir = createTempDir('repack-init-test-'); - const fakeBinDir = createTempDir('repack-init-bin-'); - const argsFile = path.join(fakeBinDir, 'npm-args.txt'); - const fetchStubPath = createFetchStub(tempDir); - - createFakeNpm(fakeBinDir, argsFile); - createFakeGit(fakeBinDir); - - const result = spawnSync( - expectPath, - [ - '-c', - `set timeout 30 -spawn node ${initBinPath} --bundler rspack --verbose -expect {Detected npm as package manager running the script} -after 1000 -send "\\r" -expect eof -catch wait result -set exit_code [lindex $result 3] -exit $exit_code -`, - ], - { - cwd: tempDir, - encoding: 'utf8', - env: { - ...process.env, - PATH: `${fakeBinDir}:${process.env.PATH ?? ''}`, - PWD: tempDir, - npm_config_user_agent: 'npm/10.9.0 node/v22.0.0 darwin x64', - NODE_OPTIONS: `--import=${fetchStubPath}`, - FORCE_COLOR: '0', - }, - } - ); - - const debugOutput = [result.stdout, result.stderr].filter(Boolean).join('\n'); - - expect(result.status, debugOutput).toBe(0); - expect(fs.existsSync(argsFile)).toBe(true); - - return { - args: fs.readFileSync(argsFile, 'utf8').trim().split('\n'), - projectRootDir: path.join(tempDir, 'RepackApp'), - }; -} - -afterEach(() => { - for (const tempDir of tempDirs.splice(0)) { - fs.rmSync(tempDir, { recursive: true, force: true }); - } -}); - -describe('built repack-init CLI', () => { - it('uses npm exec with rnc-cli and a fully-qualified react-native version', () => { - const { args, projectRootDir } = runBuiltInitWithFakeNpm(); - const versionFlagIndex = args.indexOf('--version'); - - expect(args.slice(0, 6)).toEqual([ - 'exec', - '--yes', - '--package', - '@react-native-community/cli@latest', - '--', - 'rnc-cli', - ]); - expect(versionFlagIndex).not.toBe(-1); - expect(args[versionFlagIndex + 1]).toMatch(/^\d+\.\d+\.\d+(?:-.+)?$/); - expect(fs.existsSync(path.join(projectRootDir, 'rspack.config.mjs'))).toBe( - true - ); - expect( - fs.existsSync(path.join(projectRootDir, 'react-native.config.js')) - ).toBe(true); - }); -}); diff --git a/tests/init/tsconfig.json b/tests/init/tsconfig.json deleted file mode 100644 index bec28a273..000000000 --- a/tests/init/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "noEmit": true, - "types": ["node", "vitest/globals"] - }, - "include": ["src", "vitest.config.ts"] -} diff --git a/tests/init/vitest.config.ts b/tests/init/vitest.config.ts deleted file mode 100644 index 946f50d48..000000000 --- a/tests/init/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'node', - testTimeout: 30_000, - }, -}); From 1a450a2acd65ec2883886b6ba78aed4eb53e8853 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Thu, 12 Mar 2026 17:32:24 +0100 Subject: [PATCH 5/5] chore(changeset): add repack-init release note --- .changeset/fix-repack-init-npm-flow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-repack-init-npm-flow.md diff --git a/.changeset/fix-repack-init-npm-flow.md b/.changeset/fix-repack-init-npm-flow.md new file mode 100644 index 000000000..1055b2119 --- /dev/null +++ b/.changeset/fix-repack-init-npm-flow.md @@ -0,0 +1,5 @@ +--- +"@callstack/repack-init": patch +--- + +Fix npm-based project creation by using `npm exec ... -- rnc-cli` instead of a nested `npx` invocation, and pass a fully qualified React Native patch version to the React Native CLI.