From 71fa86aa4d0ecb35c533c61e448a340adc3864fe Mon Sep 17 00:00:00 2001 From: Revopush Date: Thu, 15 Oct 2026 20:34:28 +0300 Subject: [PATCH] Move platform helpers into platforms/android, ios and react-native Co-Authored-By: Claude Opus 5.5 --- script/command-executor.ts | 176 ++-------------- script/expo-utils.ts | 14 -- .../android/binary.ts} | 131 +----------- .../android/gradle.ts} | 12 +- script/platforms/ios/binary.ts | 120 +++++++++++ .../ios-utils.ts => platforms/ios/xcode.ts} | 32 ++- script/platforms/react-native/bundler.ts | 177 ++++++++++++++++ .../react-native/hermes.ts} | 119 ++++++++++- script/platforms/react-native/project.ts | 13 ++ script/platforms/react-native/source-maps.ts | 69 +++++++ script/react-native-utils.ts | 190 ------------------ script/types/cli.ts | 5 + script/utils/file-utils.ts | 8 + script/{ => utils}/hash-utils.ts | 2 +- script/{ => utils}/sign.ts | 2 +- script/utils/source-map-utils.ts | 37 ---- script/utils/validation-utils.ts | 4 + test/cli.ts | 3 +- test/{gradle-utils.ts => gradle.ts} | 6 +- test/hash-utils.ts | 2 +- test/helpers/bundler.ts | 5 +- test/{hermes-utils.ts => hermes.ts} | 11 +- test/react-native-utils.ts | 46 ----- test/release-size-flow.ts | 3 +- test/sign.ts | 4 +- test/{source-map-utils.ts => source-maps.ts} | 47 ++++- 26 files changed, 621 insertions(+), 617 deletions(-) delete mode 100644 script/expo-utils.ts rename script/{binary-utils.ts => platforms/android/binary.ts} (53%) rename script/{utils/gradle-utils.ts => platforms/android/gradle.ts} (97%) create mode 100644 script/platforms/ios/binary.ts rename script/{utils/ios-utils.ts => platforms/ios/xcode.ts} (83%) create mode 100644 script/platforms/react-native/bundler.ts rename script/{utils/hermes-utils.ts => platforms/react-native/hermes.ts} (54%) create mode 100644 script/platforms/react-native/project.ts create mode 100644 script/platforms/react-native/source-maps.ts delete mode 100644 script/react-native-utils.ts rename script/{ => utils}/hash-utils.ts (99%) rename script/{ => utils}/sign.ts (96%) delete mode 100644 script/utils/source-map-utils.ts rename test/{gradle-utils.ts => gradle.ts} (78%) rename test/{hermes-utils.ts => hermes.ts} (98%) delete mode 100644 test/react-native-utils.ts rename test/{source-map-utils.ts => source-maps.ts} (56%) diff --git a/script/command-executor.ts b/script/command-executor.ts index 56ef6c5..b9acca0 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -1,16 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { extractMetadataFromAndroid, extractMetadataFromIOS, getIosVersion, parseAabManifest } from "./binary-utils"; - -const childProcess = require("child_process"); import debugCommand from "./commands/debug"; import * as fs from "fs"; import * as chalk from "chalk"; import * as os from "os"; import * as path from "path"; import * as cli from "../script/types/cli"; -import sign from "./sign"; +import sign from "./utils/sign"; const ApkReader = require("@devicefarmer/adbkit-apkreader"); import { AccessKey, @@ -25,18 +22,14 @@ import { PackageWithMetrics, Session, } from "./types"; -import { - getMinifyParams, - getReactNativePackagePath, - getSourceMapPaths, - runHermesEmitBinaryCommand, - SourceMapPaths, - takeHermesBaseBytecode, -} from "./react-native-utils"; -import { isHermesEnabled } from "./utils/hermes-utils"; +import { extractMetadataFromAndroid, parseAabManifest } from "./platforms/android/binary"; +import { getAndroidVersionInfo } from "./platforms/android/gradle"; +import { extractMetadataFromIOS, getIosVersion } from "./platforms/ios/binary"; +import { getIosVersionInfo } from "./platforms/ios/xcode"; +import { BundleOptions, runExpoExportEmbedCommand, runReactNativeBundleCommand } from "./platforms/react-native/bundler"; +import { isHermesEnabled, runHermesEmitBinaryCommand, takeHermesBaseBytecode } from "./platforms/react-native/hermes"; +import { getSourceMapPaths, SourceMapPaths } from "./platforms/react-native/source-maps"; import { fileDoesNotExistOrIsDirectory, fileExists, isBinaryOrZip, extractArchive } from "./utils/file-utils"; -import { getAndroidVersionInfo } from "./utils/gradle-utils"; -import { getIosVersionInfo } from "./utils/ios-utils"; import { isCommandOptionSpecified, throwForInvalidEmail, @@ -54,7 +47,6 @@ import { import AccountManager = require("./management-sdk"); import { ReactNativePackageInfo } from "./types/rest-definitions"; -import { getExpoCliPath } from "./expo-utils"; import { deleteConnectionInfoCache, deserializeConnectionInfo, @@ -77,8 +69,6 @@ import { const opener = require("opener"); export let sdk: AccountManager; -export const spawn = childProcess.spawn; -export const execSync = childProcess.execSync; let connectionInfo: ILoginConnectionInfo; @@ -534,12 +524,10 @@ async function logout(_command: cli.ICommand): Promise { deleteConnectionInfoCache(); } -interface ProjectVersionInfo { - appVersion?: string; - buildNumber?: string; -} - -async function getReactNativeProjectVersionInfo(command: cli.IReleaseReactCommand, projectName: string): Promise { +async function getReactNativeProjectVersionInfo( + command: cli.IReleaseReactCommand, + projectName: string +): Promise { log(chalk.cyan(`Detecting ${command.platform} app version:\n`)); if (command.platform === "ios") { @@ -622,83 +610,6 @@ export const release = async (command: cli.IReleaseCommand): Promise => { return doRelease(command, updateMetadata); }; -export interface BundleOptions { - platform: string; - development: boolean; - outputFolder: string; - bundleName: string; - packagerSourceMap: string; - extraBundlerOptions: string[]; -} - -export const runExpoExportEmbedCommand = async ({ - platform, - development, - outputFolder, - bundleName, - packagerSourceMap, - extraBundlerOptions, -}: BundleOptions) => { - const expoBundleArgs: string[] = []; - const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; - - if (typeof envNodeArgs !== "undefined") { - expoBundleArgs.push(...envNodeArgs.trim().split(/\s+/)); - } - - const expoCliPath = getExpoCliPath(); - - expoBundleArgs.push( - expoCliPath, - "export:embed", - "--assets-dest", - outputFolder, - "--bundle-output", - path.join(outputFolder, bundleName), - "--dev", - String(development), - "--platform", - platform, - "--minify", - "false", - "--reset-cache" - ); - - expoBundleArgs.push("--sourcemap-output", packagerSourceMap); - - if (extraBundlerOptions.length > 0) { - expoBundleArgs.push(...extraBundlerOptions); - } - - log(chalk.cyan('Running "expo export:embed" command:\n')); - - const projectRoot = process.cwd(); - expoBundleArgs.push(projectRoot); - - log("expoBundleArgs raw:" + JSON.stringify(expoBundleArgs, null, 2)); - - const expoBundleProcess = spawn("node", expoBundleArgs); - log(`node ${expoBundleArgs.join(" ")}`); - - return new Promise((resolve, reject) => { - expoBundleProcess.stdout.on("data", (data: Buffer) => { - log(data.toString().trim()); - }); - - expoBundleProcess.stderr.on("data", (data: Buffer) => { - console.error(data.toString().trim()); - }); - - expoBundleProcess.on("close", (exitCode: number) => { - if (exitCode) { - reject(new Error(`"expo export:embed" command exited with code ${exitCode}.`)); - } - - resolve(); - }); - }); -}; - const REQUIRED_OUTPUT_DIR_NAME = "CodePush"; interface ReactReleaseInputs { @@ -1132,69 +1043,6 @@ async function rollback(command: cli.IRollbackCommand): Promise { log('Successfully performed a rollback on the "' + command.deploymentName + '" deployment of the "' + command.appName + '" app.'); } -export const runReactNativeBundleCommand = async ( - { platform, development, outputFolder, bundleName, packagerSourceMap, extraBundlerOptions }: BundleOptions, - entryFile: string, - isHermes: boolean -) => { - const reactNativeBundleArgs: string[] = []; - const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; - - if (typeof envNodeArgs !== "undefined") { - reactNativeBundleArgs.push(...envNodeArgs.trim().split(/\s+/)); - } - - const reactNativePackagePath = getReactNativePackagePath(); - const oldCliPath = path.join(reactNativePackagePath, "local-cli", "cli.js"); - const cliPath = fs.existsSync(oldCliPath) ? oldCliPath : path.join(reactNativePackagePath, "cli.js"); - - reactNativeBundleArgs.push( - cliPath, - "bundle", - "--assets-dest", - outputFolder, - "--bundle-output", - path.join(outputFolder, bundleName), - "--dev", - String(development), - "--entry-file", - entryFile, - "--platform", - platform, - "--reset-cache" - ); - - reactNativeBundleArgs.push("--sourcemap-output", packagerSourceMap); - - reactNativeBundleArgs.push(...getMinifyParams(platform, isHermes)); - - if (extraBundlerOptions.length > 0) { - reactNativeBundleArgs.push(...extraBundlerOptions); - } - - log(chalk.cyan('Running "react-native bundle" command:\n')); - const reactNativeBundleProcess = spawn("node", reactNativeBundleArgs); - log(`node ${reactNativeBundleArgs.join(" ")}`); - - return new Promise((resolve, reject) => { - reactNativeBundleProcess.stdout.on("data", (data: Buffer) => { - log(data.toString().trim()); - }); - - reactNativeBundleProcess.stderr.on("data", (data: Buffer) => { - console.error(data.toString().trim()); - }); - - reactNativeBundleProcess.on("close", (exitCode: number) => { - if (exitCode) { - reject(new Error(`"react-native bundle" command exited with code ${exitCode}.`)); - } - - resolve(); - }); - }); -}; - async function sessionList(command: cli.ISessionListCommand): Promise { throwForInvalidOutputFormat(command.format); diff --git a/script/expo-utils.ts b/script/expo-utils.ts deleted file mode 100644 index e545c84..0000000 --- a/script/expo-utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as childProcess from "child_process"; - -export function getExpoCliPath(): string { - const result = childProcess.spawnSync("node", ["--print", "require.resolve('@expo/cli')"]); - const cliPath = result.stdout.toString().trim(); - - if (result.status === 0 && cliPath) { - return cliPath; - } - - throw new Error( - 'Unable to resolve "@expo/cli". Please make sure it is installed in your project (e.g. "npm install --save-dev @expo/cli").' - ); -} diff --git a/script/binary-utils.ts b/script/platforms/android/binary.ts similarity index 53% rename from script/binary-utils.ts rename to script/platforms/android/binary.ts index a4bf936..2bf2464 100644 --- a/script/binary-utils.ts +++ b/script/platforms/android/binary.ts @@ -1,16 +1,14 @@ -import * as path from "path"; import * as fs from "fs"; -import * as crypto from "crypto"; -import * as chalk from "chalk"; -import { log } from "./ui/log"; -import * as os from "os"; -import * as yauzl from "yauzl"; -import { createZip, ZipEntry } from "./utils/file-utils"; import { readFile } from "node:fs/promises"; import { buffer as readStreamToBuffer } from "node:stream/consumers"; +import * as os from "os"; +import * as path from "path"; +import * as chalk from "chalk"; import * as protobuf from "protobufjs"; -import * as plist from "plist"; -import * as bplist from "bplist-parser"; +import * as yauzl from "yauzl"; + +import { log } from "../../ui/log"; +import { createZipArchive } from "../../utils/file-utils"; export async function extractMetadataFromAndroid(extractFolder, outputFolder) { const assetsFolder = path.join(extractFolder, "assets"); @@ -51,82 +49,6 @@ export async function extractMetadataFromAndroid(extractFolder, outputFolder) { return zipPath; } -export async function extractMetadataFromIOS(ipaPath: string, outputFolder: string) { - const { files, appPrefix, close } = await openIPA(ipaPath); - - const assetsPrefix = `${appPrefix}assets/`; - const bundlePath = `${appPrefix}main.jsbundle`; - - const fileHashes: { [key: string]: string } = {}; - let bundleBuffer: Buffer | null = null; - - try { - for (const entry of files) { - if (entry.path === bundlePath) { - bundleBuffer = await entry.buffer(); - } else if (entry.path.startsWith(assetsPrefix)) { - const relativePath = entry.path.slice(appPrefix.length); // e.g. assets/img/logo.png - const hash = sha256(await entry.buffer()); - fileHashes[`CodePush/${relativePath}`] = hash; - log(chalk.gray(` ${relativePath}:${hash.substring(0, 8)}...\n`)); - } - } - } finally { - close(); - } - - if (Object.keys(fileHashes).length === 0) { - log(chalk.yellow(`\nWarning: CodePush assets folder not found in IPA.\n`)); - } - - if (!bundleBuffer) { - throw new Error("main.jsbundle not found in IPA app folder."); - } - - log(chalk.cyan(`\nFound main.jsbundle, calculating hash:\n`)); - fileHashes["CodePush/main.jsbundle"] = sha256(bundleBuffer); - - // Write bundle to output folder (needed for the release package zip) - const outputCodePushFolder = path.join(outputFolder, "CodePush"); - fs.mkdirSync(outputCodePushFolder, { recursive: true }); - fs.writeFileSync(path.join(outputCodePushFolder, "main.jsbundle"), bundleBuffer); - - // Save packageManifest.json - const manifestPath = path.join(outputFolder, "packageManifest.json"); - fs.writeFileSync(manifestPath, JSON.stringify(fileHashes, null, 2)); - log(chalk.cyan(`\nSaved packageManifest.json with ${Object.keys(fileHashes).length} entries.\n`)); - - // Create zip archive with packageManifest.json and bundle file - const zipPath = path.join(os.tmpdir(), `CodePushBinary-${Date.now()}.zip`); - await createZipArchive(outputFolder, zipPath, ["packageManifest.json", "CodePush/main.jsbundle"]); - - return zipPath; -} - -function sha256(buffer: Buffer): string { - return crypto.createHash("sha256").update(buffer).digest("hex"); -} - -type IpaFile = { path: string; buffer: () => Promise }; - -// Reads an IPA via its central directory. Entries are read lazily, so only the files a caller -// touches are loaded — never the whole IPA. Caller must close(). -async function openIPA(ipaPath: string): Promise<{ files: IpaFile[]; appPrefix: string; close: () => void }> { - const zipFile = await yauzl.openPromise(ipaPath, { autoClose: false, lazyEntries: true }); - const files: IpaFile[] = []; - for await (const entry of zipFile.eachEntry()) { - if (entry.fileName.endsWith("/")) continue; - files.push({ path: entry.fileName, buffer: async () => readStreamToBuffer(await zipFile.openReadStreamPromise(entry)) }); - } - - const appPrefix = files.map((f) => f.path.match(/^(Payload\/[^/]+\.app)\//)?.[1]).find(Boolean); - if (!appPrefix) { - zipFile.close(); - throw new Error('Invalid IPA structure: no "Payload/*.app" folder found.'); - } - return { files, appPrefix: `${appPrefix}/`, close: () => zipFile.close() }; -} - type BinaryHashes = { [p: string]: string }; async function takeHashesFromMetadata(metadataPath: string): Promise { @@ -139,45 +61,6 @@ async function takeHashesFromMetadata(metadataPath: string): Promise item.split(":"))); } -function createZipArchive(sourceFolder: string, zipPath: string, filesToInclude: string[]): Promise { - const entries: ZipEntry[] = filesToInclude - .map((file): ZipEntry => [path.join(sourceFolder, file), file]) - .filter(([filePath]) => fs.existsSync(filePath)); - return createZip(zipPath, entries); -} - -function parsePlistBuffer(buf: Buffer): any { - if (buf.slice(0, 6).toString("ascii") === "bplist") { - const arr = bplist.parseBuffer(buf); - if (!arr?.length) throw new Error("Empty binary plist"); - return arr[0]; - } - - return plist.parse(buf.toString("utf8")); -} - -export async function getIosVersion(ipaPath: string) { - const { files, appPrefix, close } = await openIPA(ipaPath); - - try { - const plistEntry = files.find((f) => f.path === `${appPrefix}Info.plist`); - if (!plistEntry) { - throw new Error("Info.plist not found in IPA app folder."); - } - - const data = parsePlistBuffer(await plistEntry.buffer()); - - log(chalk.cyan(`App Version: ${data.CFBundleShortVersionString}, Build: ${data.CFBundleVersion}\n`)); - - return { - version: data.CFBundleShortVersionString, - build: data.CFBundleVersion, - }; - } finally { - close(); - } -} - // --------------------------------------------------------------------------- // Android App Bundle (.aab) // --------------------------------------------------------------------------- diff --git a/script/utils/gradle-utils.ts b/script/platforms/android/gradle.ts similarity index 97% rename from script/utils/gradle-utils.ts rename to script/platforms/android/gradle.ts index 62788bc..bd41bbf 100644 --- a/script/utils/gradle-utils.ts +++ b/script/platforms/android/gradle.ts @@ -8,19 +8,15 @@ import * as path from "path"; import { promisify } from "util"; import * as chalk from "chalk"; -import { fileExists } from "./file-utils"; -import { isValidVersion } from "../react-native-utils"; +import { ProjectVersionInfo } from "../../types/cli"; +import { fileExists } from "../../utils/file-utils"; +import { isValidVersion } from "../../utils/validation-utils"; const g2js = require("gradle-to-js/lib/parser"); const properties = require("properties"); const exec = promisify(childProcess.exec); -export interface AndroidVersionInfo { - appVersion?: string; - buildNumber?: string; -} - /** Raw values from the build script — literals or references to Gradle properties. */ interface GradleVersionFields { versionName: string | null; @@ -57,7 +53,7 @@ allprojects { // Every `gradleFile` parameter below is the app's build script or its directory; defaults to "android/app". -export async function getAndroidVersionInfo(gradleFile?: string | null): Promise { +export async function getAndroidVersionInfo(gradleFile?: string | null): Promise { const buildFile = findGradleBuildFile(gradleFile); if (!buildFile) { throw new Error(`Unable to find gradle file "${gradleFile ?? DEFAULT_APP_DIR}".`); diff --git a/script/platforms/ios/binary.ts b/script/platforms/ios/binary.ts new file mode 100644 index 0000000..6f1b23c --- /dev/null +++ b/script/platforms/ios/binary.ts @@ -0,0 +1,120 @@ +import * as crypto from "crypto"; +import * as fs from "fs"; +import { buffer as readStreamToBuffer } from "node:stream/consumers"; +import * as os from "os"; +import * as path from "path"; +import * as bplist from "bplist-parser"; +import * as chalk from "chalk"; +import * as plist from "plist"; +import * as yauzl from "yauzl"; + +import { log } from "../../ui/log"; +import { createZipArchive } from "../../utils/file-utils"; + +export async function extractMetadataFromIOS(ipaPath: string, outputFolder: string) { + const { files, appPrefix, close } = await openIPA(ipaPath); + + const assetsPrefix = `${appPrefix}assets/`; + const bundlePath = `${appPrefix}main.jsbundle`; + + const fileHashes: { [key: string]: string } = {}; + let bundleBuffer: Buffer | null = null; + + try { + for (const entry of files) { + if (entry.path === bundlePath) { + bundleBuffer = await entry.buffer(); + } else if (entry.path.startsWith(assetsPrefix)) { + const relativePath = entry.path.slice(appPrefix.length); // e.g. assets/img/logo.png + const hash = sha256(await entry.buffer()); + fileHashes[`CodePush/${relativePath}`] = hash; + log(chalk.gray(` ${relativePath}:${hash.substring(0, 8)}...\n`)); + } + } + } finally { + close(); + } + + if (Object.keys(fileHashes).length === 0) { + log(chalk.yellow(`\nWarning: CodePush assets folder not found in IPA.\n`)); + } + + if (!bundleBuffer) { + throw new Error("main.jsbundle not found in IPA app folder."); + } + + log(chalk.cyan(`\nFound main.jsbundle, calculating hash:\n`)); + fileHashes["CodePush/main.jsbundle"] = sha256(bundleBuffer); + + // Write bundle to output folder (needed for the release package zip) + const outputCodePushFolder = path.join(outputFolder, "CodePush"); + fs.mkdirSync(outputCodePushFolder, { recursive: true }); + fs.writeFileSync(path.join(outputCodePushFolder, "main.jsbundle"), bundleBuffer); + + // Save packageManifest.json + const manifestPath = path.join(outputFolder, "packageManifest.json"); + fs.writeFileSync(manifestPath, JSON.stringify(fileHashes, null, 2)); + log(chalk.cyan(`\nSaved packageManifest.json with ${Object.keys(fileHashes).length} entries.\n`)); + + // Create zip archive with packageManifest.json and bundle file + const zipPath = path.join(os.tmpdir(), `CodePushBinary-${Date.now()}.zip`); + await createZipArchive(outputFolder, zipPath, ["packageManifest.json", "CodePush/main.jsbundle"]); + + return zipPath; +} + +function sha256(buffer: Buffer): string { + return crypto.createHash("sha256").update(buffer).digest("hex"); +} + +type IpaFile = { path: string; buffer: () => Promise }; + +// Reads an IPA via its central directory. Entries are read lazily, so only the files a caller +// touches are loaded — never the whole IPA. Caller must close(). +async function openIPA(ipaPath: string): Promise<{ files: IpaFile[]; appPrefix: string; close: () => void }> { + const zipFile = await yauzl.openPromise(ipaPath, { autoClose: false, lazyEntries: true }); + const files: IpaFile[] = []; + for await (const entry of zipFile.eachEntry()) { + if (entry.fileName.endsWith("/")) continue; + files.push({ path: entry.fileName, buffer: async () => readStreamToBuffer(await zipFile.openReadStreamPromise(entry)) }); + } + + const appPrefix = files.map((f) => f.path.match(/^(Payload\/[^/]+\.app)\//)?.[1]).find(Boolean); + if (!appPrefix) { + zipFile.close(); + throw new Error('Invalid IPA structure: no "Payload/*.app" folder found.'); + } + return { files, appPrefix: `${appPrefix}/`, close: () => zipFile.close() }; +} + +function parsePlistBuffer(buf: Buffer): any { + if (buf.slice(0, 6).toString("ascii") === "bplist") { + const arr = bplist.parseBuffer(buf); + if (!arr?.length) throw new Error("Empty binary plist"); + return arr[0]; + } + + return plist.parse(buf.toString("utf8")); +} + +export async function getIosVersion(ipaPath: string) { + const { files, appPrefix, close } = await openIPA(ipaPath); + + try { + const plistEntry = files.find((f) => f.path === `${appPrefix}Info.plist`); + if (!plistEntry) { + throw new Error("Info.plist not found in IPA app folder."); + } + + const data = parsePlistBuffer(await plistEntry.buffer()); + + log(chalk.cyan(`App Version: ${data.CFBundleShortVersionString}, Build: ${data.CFBundleVersion}\n`)); + + return { + version: data.CFBundleShortVersionString, + build: data.CFBundleVersion, + }; + } finally { + close(); + } +} diff --git a/script/utils/ios-utils.ts b/script/platforms/ios/xcode.ts similarity index 83% rename from script/utils/ios-utils.ts rename to script/platforms/ios/xcode.ts index 6625264..0bc620e 100644 --- a/script/utils/ios-utils.ts +++ b/script/platforms/ios/xcode.ts @@ -1,19 +1,15 @@ import * as fs from "fs"; import * as path from "path"; -import * as cli from "../types/cli"; -import { isValidVersion } from "../react-native-utils"; -import { fileExists } from "./file-utils"; +import * as dotenv from "dotenv"; +import * as cli from "../../types/cli"; +import { fileExists } from "../../utils/file-utils"; +import { isValidVersion } from "../../utils/validation-utils"; const plist = require("plist"); const xcode = require("xcode"); -export interface IosVersionInfo { - appVersion?: string; - buildNumber?: string; -} - /** Reads the app version from Info.plist, or from the Xcode project when the plist refers to its build settings */ -export async function getIosVersionInfo(command: cli.IReleaseReactCommand, projectName: string): Promise { +export async function getIosVersionInfo(command: cli.IReleaseReactCommand, projectName: string): Promise { let resolvedPlistFile: string = command.plistFile; if (resolvedPlistFile) { // If a plist file path is explicitly provided, then we don't @@ -72,7 +68,7 @@ export async function getIosVersionInfo(command: cli.IReleaseReactCommand, proje return { appVersion: rawShortVersion, buildNumber: rawBundleVersion }; } -async function getAppVersionFromXcodeProject(command: cli.IReleaseReactCommand, projectName: string): Promise { +async function getAppVersionFromXcodeProject(command: cli.IReleaseReactCommand, projectName: string): Promise { const pbxprojFileName = "project.pbxproj"; let resolvedPbxprojFile: string = command.xcodeProjectFile; if (resolvedPbxprojFile) { @@ -122,3 +118,19 @@ async function getAppVersionFromXcodeProject(command: cli.IReleaseReactCommand, return { appVersion: marketingVersion, buildNumber: String(currentProjectVersion) }; } + +/** + * As react-native-xcode.sh sees it: .xcode.env.local, then .xcode.env, then the environment. + * The files sit next to the Podfile: --podFile, else PODFILE_DIR (set in Xcode builds), else ios/ + */ +export function getXcodeDotEnvValue(key: string, podFile?: string): string | undefined { + const podfileDir = podFile ? path.dirname(podFile) : process.env.PODFILE_DIR || "ios"; + for (const envFile of [".xcode.env.local", ".xcode.env"]) { + const envPath = path.join(podfileDir, envFile); + const value = fs.existsSync(envPath) ? dotenv.parse(fs.readFileSync(envPath))[key] : undefined; + if (value !== undefined) { + return value; + } + } + return process.env[key]; +} diff --git a/script/platforms/react-native/bundler.ts b/script/platforms/react-native/bundler.ts new file mode 100644 index 0000000..2eacf1a --- /dev/null +++ b/script/platforms/react-native/bundler.ts @@ -0,0 +1,177 @@ +import * as childProcess from "child_process"; +import * as fs from "fs"; +import * as path from "path"; +import * as chalk from "chalk"; + +import { log } from "../../ui/log"; +import { getReactNativePackagePath } from "./project"; + +export const spawn = childProcess.spawn; + +export interface BundleOptions { + platform: string; + development: boolean; + outputFolder: string; + bundleName: string; + packagerSourceMap: string; + extraBundlerOptions: string[]; +} + +export const runReactNativeBundleCommand = async ( + { platform, development, outputFolder, bundleName, packagerSourceMap, extraBundlerOptions }: BundleOptions, + entryFile: string, + isHermes: boolean +) => { + const reactNativeBundleArgs: string[] = []; + const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; + + if (typeof envNodeArgs !== "undefined") { + reactNativeBundleArgs.push(...envNodeArgs.trim().split(/\s+/)); + } + + const reactNativePackagePath = getReactNativePackagePath(); + const oldCliPath = path.join(reactNativePackagePath, "local-cli", "cli.js"); + const cliPath = fs.existsSync(oldCliPath) ? oldCliPath : path.join(reactNativePackagePath, "cli.js"); + + reactNativeBundleArgs.push( + cliPath, + "bundle", + "--assets-dest", + outputFolder, + "--bundle-output", + path.join(outputFolder, bundleName), + "--dev", + String(development), + "--entry-file", + entryFile, + "--platform", + platform, + "--reset-cache" + ); + + reactNativeBundleArgs.push("--sourcemap-output", packagerSourceMap); + + reactNativeBundleArgs.push(...getMinifyParams(platform, isHermes)); + + if (extraBundlerOptions.length > 0) { + reactNativeBundleArgs.push(...extraBundlerOptions); + } + + log(chalk.cyan('Running "react-native bundle" command:\n')); + const reactNativeBundleProcess = spawn("node", reactNativeBundleArgs); + log(`node ${reactNativeBundleArgs.join(" ")}`); + + return new Promise((resolve, reject) => { + reactNativeBundleProcess.stdout.on("data", (data: Buffer) => { + log(data.toString().trim()); + }); + + reactNativeBundleProcess.stderr.on("data", (data: Buffer) => { + console.error(data.toString().trim()); + }); + + reactNativeBundleProcess.on("close", (exitCode: number) => { + if (exitCode) { + reject(new Error(`"react-native bundle" command exited with code ${exitCode}.`)); + } + + resolve(); + }); + }); +}; + +export const runExpoExportEmbedCommand = async ({ + platform, + development, + outputFolder, + bundleName, + packagerSourceMap, + extraBundlerOptions, +}: BundleOptions) => { + const expoBundleArgs: string[] = []; + const envNodeArgs: string = process.env.CODE_PUSH_NODE_ARGS; + + if (typeof envNodeArgs !== "undefined") { + expoBundleArgs.push(...envNodeArgs.trim().split(/\s+/)); + } + + const expoCliPath = getExpoCliPath(); + + expoBundleArgs.push( + expoCliPath, + "export:embed", + "--assets-dest", + outputFolder, + "--bundle-output", + path.join(outputFolder, bundleName), + "--dev", + String(development), + "--platform", + platform, + "--minify", + "false", + "--reset-cache" + ); + + expoBundleArgs.push("--sourcemap-output", packagerSourceMap); + + if (extraBundlerOptions.length > 0) { + expoBundleArgs.push(...extraBundlerOptions); + } + + log(chalk.cyan('Running "expo export:embed" command:\n')); + + const projectRoot = process.cwd(); + expoBundleArgs.push(projectRoot); + + log("expoBundleArgs raw:" + JSON.stringify(expoBundleArgs, null, 2)); + + const expoBundleProcess = spawn("node", expoBundleArgs); + log(`node ${expoBundleArgs.join(" ")}`); + + return new Promise((resolve, reject) => { + expoBundleProcess.stdout.on("data", (data: Buffer) => { + log(data.toString().trim()); + }); + + expoBundleProcess.stderr.on("data", (data: Buffer) => { + console.error(data.toString().trim()); + }); + + expoBundleProcess.on("close", (exitCode: number) => { + if (exitCode) { + reject(new Error(`"expo export:embed" command exited with code ${exitCode}.`)); + } + + resolve(); + }); + }); +}; + +function getMinifyParams(platform: string, isHermes: boolean): string[] { + switch (platform) { + case "android": { + // The RN Gradle plugin minifies unless Hermes is enabled + return ["--minify", String(!isHermes)]; + } + case "ios": { + // react-native-xcode.sh passes "--minify false" only with Hermes + return isHermes ? ["--minify", "false"] : []; + } + default: + throw new Error('Platform must be either "android" or "ios".'); + } +} + +function getExpoCliPath(): string { + const result = childProcess.spawnSync("node", ["--print", "require.resolve('@expo/cli')"]); + const cliPath = result.stdout.toString().trim(); + + if (result.status === 0 && cliPath) { + return cliPath; + } + + throw new Error( + 'Unable to resolve "@expo/cli". Please make sure it is installed in your project (e.g. "npm install --save-dev @expo/cli").' + ); +} diff --git a/script/utils/hermes-utils.ts b/script/platforms/react-native/hermes.ts similarity index 54% rename from script/utils/hermes-utils.ts rename to script/platforms/react-native/hermes.ts index 9199e52..e2c10dd 100644 --- a/script/utils/hermes-utils.ts +++ b/script/platforms/react-native/hermes.ts @@ -1,4 +1,4 @@ -// Hermes detection, hermesc lookup and bytecode version checks +// Hermes detection, hermesc lookup, bytecode version checks and compilation import * as childProcess from "child_process"; import * as fs from "fs"; @@ -6,11 +6,14 @@ import * as path from "path"; import * as chalk from "chalk"; import { coerce, gte } from "semver"; -import * as cli from "../types/cli"; -import { log } from "../ui/log"; -import { fileExists } from "./file-utils"; -import { getGradleHermesCommand, getGradleHermesEnabled, readGradleProperty } from "./gradle-utils"; -import { getReactNativePackagePath, getReactNativeVersion, getXcodeDotEnvValue } from "../react-native-utils"; +import * as cli from "../../types/cli"; +import { log } from "../../ui/log"; +import { downloadBlob, extractArchive, fileExists } from "../../utils/file-utils"; +import type AccountManager = require("../../management-sdk"); +import { getGradleHermesCommand, getGradleHermesEnabled, readGradleProperty } from "../android/gradle"; +import { getXcodeDotEnvValue } from "../ios/xcode"; +import { getReactNativePackagePath, getReactNativeVersion } from "./project"; +import { restoreSourceMapMetadata, SourceMapPaths } from "./source-maps"; const OS_BIN = ({ win32: "win64-bin", darwin: "osx-bin" } as Record)[process.platform] ?? "linux64-bin"; @@ -112,3 +115,107 @@ export function isBaseBytecodeCompatible(baseBytecode: string, hermesc: string): log(chalk.yellow(`\nWarning: ${warning}\n`)); return false; } + +type BaseRelease = Pick; + +export async function takeHermesBaseBytecode( + sdk: Pick, + { appName, deploymentName, appStoreVersion, buildNumber }: BaseRelease, + baseReleaseTmpFolder: string, + outputFolder: string, + bundleName: string +): Promise { + const { bundleBlobUrl } = await sdk.getBaseRelease(appName, deploymentName, appStoreVersion, buildNumber); + if (!bundleBlobUrl) { + return null; + } + + const baseReleaseArchive = await downloadBlob(bundleBlobUrl, baseReleaseTmpFolder); + await extractArchive(baseReleaseArchive, baseReleaseTmpFolder); + const baseReleaseBundle = path.join(baseReleaseTmpFolder, path.basename(outputFolder), bundleName); + + if (!fs.existsSync(baseReleaseBundle)) { + log(chalk.cyan("\nNo base release available...\n")); + return null; + } + + return baseReleaseBundle; +} + +export async function runHermesEmitBinaryCommand( + project: cli.NativeProject, + bundle: string, + sourceMaps: SourceMapPaths, + extraHermesFlags: string[], + baseBytecode: string | null +): Promise { + const bytecode = bundle + ".hbc"; + const hermesc = await findHermesc(project); + + const hermesArgs = [ + ...(process.env.CODE_PUSH_NODE_ARGS?.trim().split(/\s+/).filter(Boolean) ?? []), + "-emit-binary", + "-O", + "-out", + bytecode, + bundle, + "-w", + "-max-diagnostic-width=80", + ...extraHermesFlags, + "-output-source-map", + ]; + if (baseBytecode && isBaseBytecodeCompatible(baseBytecode, hermesc)) { + hermesArgs.push("-base-bytecode", baseBytecode); + } + + console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); + await runCommand("hermes", hermesc, hermesArgs); + await fs.promises.rename(bytecode, bundle); + + await composeHermesSourceMaps(getComposeSourceMapsScript(project), bytecode + ".map", sourceMaps); +} + +function getComposeSourceMapsScript({ platform, podFile }: cli.NativeProject): string { + // On iOS, react-native-xcode.sh lets COMPOSE_SOURCEMAP_PATH replace RN's script; an empty value is ignored + const override = platform === "ios" ? getXcodeDotEnvValue("COMPOSE_SOURCEMAP_PATH", podFile) : undefined; + const script = override || path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); + if (!fs.existsSync(script)) { + throw new Error(`Source map compose script ${script} is not found`); + } + return script; +} + +// Packager + Hermes source maps = final source map, as RN's build does +async function composeHermesSourceMaps(composeScript: string, hermesSourceMap: string, sourceMaps: SourceMapPaths): Promise { + if (!fs.existsSync(hermesSourceMap)) { + throw new Error(`sourcemap file ${hermesSourceMap} is not found`); + } + + // Read before composing: on iOS the composed map overwrites the packager map + const packagerSourceMapJson = JSON.parse(await fs.promises.readFile(sourceMaps.packager, "utf8")); + await runCommand("compose-source-maps", "node", [composeScript, sourceMaps.packager, hermesSourceMap, "-o", sourceMaps.final]); + const restoredFields = await restoreSourceMapMetadata(sourceMaps.final, packagerSourceMapJson); + if (restoredFields.length > 0) { + console.log(`Restored packager source map metadata: ${restoredFields.join(", ")}`); + } + // Not part of the release + await fs.promises.unlink(hermesSourceMap); +} + +function runCommand(name: string, command: string, args: string[]): Promise { + console.log(`${command} ${args.join(" ")}`); + const child = childProcess.spawn(command, args); + child.stdout.on("data", (data: Buffer) => console.log(data.toString().trim())); + child.stderr.on("data", (data: Buffer) => console.error(data.toString().trim())); + + return new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (exitCode: number, signal: string) => { + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`"${name}" command failed (exitCode=${exitCode}, signal=${signal}).`)); + } + }); + }); +} diff --git a/script/platforms/react-native/project.ts b/script/platforms/react-native/project.ts new file mode 100644 index 0000000..311477e --- /dev/null +++ b/script/platforms/react-native/project.ts @@ -0,0 +1,13 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as childProcess from "child_process"; + +export function getReactNativePackagePath(): string { + const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); + return result.status === 0 ? path.dirname(result.stdout.toString().trim()) : path.join("node_modules", "react-native"); +} + +export function getReactNativeVersion(): string | undefined { + const packageJson = path.join(getReactNativePackagePath(), "package.json"); + return fs.existsSync(packageJson) ? JSON.parse(fs.readFileSync(packageJson, "utf8")).version : undefined; +} diff --git a/script/platforms/react-native/source-maps.ts b/script/platforms/react-native/source-maps.ts new file mode 100644 index 0000000..68e6e0f --- /dev/null +++ b/script/platforms/react-native/source-maps.ts @@ -0,0 +1,69 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as cli from "../../types/cli"; +import { getXcodeDotEnvValue } from "../ios/xcode"; + +export interface SourceMapPaths { + /** Written by Metro */ + packager: string; + /** Released; differs from the packager map only for Hermes on Android */ + final: string; +} + +/** sourcemapOutput is a folder or the .map file itself; file names match RN's builds */ +export function getSourceMapPaths( + { platform, podFile }: cli.NativeProject, + bundleName: string, + sourcemapOutput: string, + isHermes: boolean +): SourceMapPaths { + if (sourcemapOutput.endsWith(".map")) { + return { packager: sourcemapOutput, final: sourcemapOutput }; + } + if (platform === "android") { + const final = path.join(sourcemapOutput, bundleName + ".map"); + // With Hermes, Metro's map is the "packager" one (BundleHermesCTask) + const packager = isHermes ? path.join(sourcemapOutput, bundleName + ".packager.map") : final; + return { packager, final }; + } + // Same name as react-native-xcode.sh, since it ends up in the bundle's sourceMappingURL + const sourceMapFile = getXcodeDotEnvValue("SOURCEMAP_FILE", podFile); + const sourceMap = path.join(sourcemapOutput, sourceMapFile ? path.basename(sourceMapFile) : bundleName + ".map"); + return { packager: sourceMap, final: sourceMap }; +} + +// Fields tied to the bundle layout, which compose rewrites; packager values would point at the wrong sources +const POSITIONAL_FIELDS = new Set([ + "version", + "file", + "sourceRoot", + "sources", + "sourcesContent", + "names", + "mappings", + "ignoreList", + "sections", + "x_google_ignoreList", + "x_facebook_sources", + "x_facebook_offsets", + "x_facebook_segments", + "x_hermes_function_offsets", +]); + +/** compose-source-maps.js drops fields it doesn't know, such as `debugId`. Copies them back; returns their names. */ +export async function restoreSourceMapMetadata( + composedSourceMap: string, + packagerSourceMap: Record +): Promise { + const fields = Object.keys(packagerSourceMap).filter((field) => !POSITIONAL_FIELDS.has(field)); + // Skip reading the (possibly huge) composed map when there is nothing to copy + if (fields.length === 0) return []; + + const composed = JSON.parse(await fs.promises.readFile(composedSourceMap, "utf8")); + const restored = fields.filter((field) => !Object.hasOwn(composed, field)); + if (restored.length === 0) return []; + + for (const field of restored) composed[field] = packagerSourceMap[field]; + await fs.promises.writeFile(composedSourceMap, JSON.stringify(composed)); + return restored; +} diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts deleted file mode 100644 index 41a8a51..0000000 --- a/script/react-native-utils.ts +++ /dev/null @@ -1,190 +0,0 @@ -import * as fs from "fs"; -import * as chalk from "chalk"; -import * as path from "path"; -import * as childProcess from "child_process"; -import { valid } from "semver"; -import { downloadBlob, extractArchive } from "./utils/file-utils"; -import { findHermesc, isBaseBytecodeCompatible } from "./utils/hermes-utils"; -import { restoreSourceMapMetadata } from "./utils/source-map-utils"; -import * as dotenv from "dotenv"; -import * as cli from "./types/cli"; -import { log } from "./ui/log"; -import type AccountManager = require("./management-sdk"); - -export function isValidVersion(version: string): boolean { - return !!valid(version) || /^\d+\.\d+$/.test(version); -} - -export interface SourceMapPaths { - /** Written by Metro */ - packager: string; - /** Released; differs from the packager map only for Hermes on Android */ - final: string; -} - -/** sourcemapOutput is a folder or the .map file itself; file names match RN's builds */ -export function getSourceMapPaths( - { platform, podFile }: cli.NativeProject, - bundleName: string, - sourcemapOutput: string, - isHermes: boolean -): SourceMapPaths { - if (sourcemapOutput.endsWith(".map")) { - return { packager: sourcemapOutput, final: sourcemapOutput }; - } - if (platform === "android") { - const final = path.join(sourcemapOutput, bundleName + ".map"); - // With Hermes, Metro's map is the "packager" one (BundleHermesCTask) - const packager = isHermes ? path.join(sourcemapOutput, bundleName + ".packager.map") : final; - return { packager, final }; - } - // Same name as react-native-xcode.sh, since it ends up in the bundle's sourceMappingURL - const sourceMapFile = getXcodeDotEnvValue("SOURCEMAP_FILE", podFile); - const sourceMap = path.join(sourcemapOutput, sourceMapFile ? path.basename(sourceMapFile) : bundleName + ".map"); - return { packager: sourceMap, final: sourceMap }; -} - -type BaseRelease = Pick; - -export async function takeHermesBaseBytecode( - sdk: Pick, - { appName, deploymentName, appStoreVersion, buildNumber }: BaseRelease, - baseReleaseTmpFolder: string, - outputFolder: string, - bundleName: string -): Promise { - const { bundleBlobUrl } = await sdk.getBaseRelease(appName, deploymentName, appStoreVersion, buildNumber); - if (!bundleBlobUrl) { - return null; - } - - const baseReleaseArchive = await downloadBlob(bundleBlobUrl, baseReleaseTmpFolder); - await extractArchive(baseReleaseArchive, baseReleaseTmpFolder); - const baseReleaseBundle = path.join(baseReleaseTmpFolder, path.basename(outputFolder), bundleName); - - if (!fs.existsSync(baseReleaseBundle)) { - log(chalk.cyan("\nNo base release available...\n")); - return null; - } - - return baseReleaseBundle; -} - -export async function runHermesEmitBinaryCommand( - project: cli.NativeProject, - bundle: string, - sourceMaps: SourceMapPaths, - extraHermesFlags: string[], - baseBytecode: string | null -): Promise { - const bytecode = bundle + ".hbc"; - const hermesc = await findHermesc(project); - - const hermesArgs = [ - ...(process.env.CODE_PUSH_NODE_ARGS?.trim().split(/\s+/).filter(Boolean) ?? []), - "-emit-binary", - "-O", - "-out", - bytecode, - bundle, - "-w", - "-max-diagnostic-width=80", - ...extraHermesFlags, - "-output-source-map", - ]; - if (baseBytecode && isBaseBytecodeCompatible(baseBytecode, hermesc)) { - hermesArgs.push("-base-bytecode", baseBytecode); - } - - console.log(chalk.cyan("Converting JS bundle to byte code via Hermes, running command:\n")); - await runCommand("hermes", hermesc, hermesArgs); - await fs.promises.rename(bytecode, bundle); - - await composeHermesSourceMaps(getComposeSourceMapsScript(project), bytecode + ".map", sourceMaps); -} - -function getComposeSourceMapsScript({ platform, podFile }: cli.NativeProject): string { - // On iOS, react-native-xcode.sh lets COMPOSE_SOURCEMAP_PATH replace RN's script; an empty value is ignored - const override = platform === "ios" ? getXcodeDotEnvValue("COMPOSE_SOURCEMAP_PATH", podFile) : undefined; - const script = override || path.join(getReactNativePackagePath(), "scripts", "compose-source-maps.js"); - if (!fs.existsSync(script)) { - throw new Error(`Source map compose script ${script} is not found`); - } - return script; -} - -// Packager + Hermes source maps = final source map, as RN's build does -async function composeHermesSourceMaps(composeScript: string, hermesSourceMap: string, sourceMaps: SourceMapPaths): Promise { - if (!fs.existsSync(hermesSourceMap)) { - throw new Error(`sourcemap file ${hermesSourceMap} is not found`); - } - - // Read before composing: on iOS the composed map overwrites the packager map - const packagerSourceMapJson = JSON.parse(await fs.promises.readFile(sourceMaps.packager, "utf8")); - await runCommand("compose-source-maps", "node", [composeScript, sourceMaps.packager, hermesSourceMap, "-o", sourceMaps.final]); - const restoredFields = await restoreSourceMapMetadata(sourceMaps.final, packagerSourceMapJson); - if (restoredFields.length > 0) { - console.log(`Restored packager source map metadata: ${restoredFields.join(", ")}`); - } - // Not part of the release - await fs.promises.unlink(hermesSourceMap); -} - -function runCommand(name: string, command: string, args: string[]): Promise { - console.log(`${command} ${args.join(" ")}`); - const child = childProcess.spawn(command, args); - child.stdout.on("data", (data: Buffer) => console.log(data.toString().trim())); - child.stderr.on("data", (data: Buffer) => console.error(data.toString().trim())); - - return new Promise((resolve, reject) => { - child.on("error", reject); - child.on("close", (exitCode: number, signal: string) => { - if (exitCode === 0) { - resolve(); - } else { - reject(new Error(`"${name}" command failed (exitCode=${exitCode}, signal=${signal}).`)); - } - }); - }); -} - -export function getMinifyParams(platform: string, isHermes: boolean): string[] { - switch (platform) { - case "android": { - // The RN Gradle plugin minifies unless Hermes is enabled - return ["--minify", String(!isHermes)]; - } - case "ios": { - // react-native-xcode.sh passes "--minify false" only with Hermes - return isHermes ? ["--minify", "false"] : []; - } - default: - throw new Error('Platform must be either "android" or "ios".'); - } -} - -export function getReactNativePackagePath(): string { - const result = childProcess.spawnSync("node", ["--print", "require.resolve('react-native/package.json')"]); - return result.status === 0 ? path.dirname(result.stdout.toString().trim()) : path.join("node_modules", "react-native"); -} - -export function getReactNativeVersion(): string | undefined { - const packageJson = path.join(getReactNativePackagePath(), "package.json"); - return fs.existsSync(packageJson) ? JSON.parse(fs.readFileSync(packageJson, "utf8")).version : undefined; -} - -/** - * As react-native-xcode.sh sees it: .xcode.env.local, then .xcode.env, then the environment. - * The files sit next to the Podfile: --podFile, else PODFILE_DIR (set in Xcode builds), else ios/ - */ -export function getXcodeDotEnvValue(key: string, podFile?: string): string | undefined { - const podfileDir = podFile ? path.dirname(podFile) : process.env.PODFILE_DIR || "ios"; - for (const envFile of [".xcode.env.local", ".xcode.env"]) { - const envPath = path.join(podfileDir, envFile); - const value = fs.existsSync(envPath) ? dotenv.parse(fs.readFileSync(envPath))[key] : undefined; - if (value !== undefined) { - return value; - } - } - return process.env[key]; -} diff --git a/script/types/cli.ts b/script/types/cli.ts index 72cf73d..7088403 100644 --- a/script/types/cli.ts +++ b/script/types/cli.ts @@ -218,6 +218,11 @@ export interface IReleaseReactCommand extends IReleaseBaseCommand { /** The native project fields needed for Hermes and .xcode.env lookups */ export type NativeProject = Pick; +export interface ProjectVersionInfo { + appVersion?: string; + buildNumber?: string; +} + export interface IReleaseNativeCommand extends IReleaseBaseCommand { platform: string; targetBinary: string; diff --git a/script/utils/file-utils.ts b/script/utils/file-utils.ts index ae0597e..0f54a32 100644 --- a/script/utils/file-utils.ts +++ b/script/utils/file-utils.ts @@ -118,3 +118,11 @@ export function createZip(zipPath: string, entries: ZipEntry[]): Promise { zipFile.end(); }); } + +/** Zips the files that exist in sourceFolder, keeping their relative paths */ +export function createZipArchive(sourceFolder: string, zipPath: string, filesToInclude: string[]): Promise { + const entries: ZipEntry[] = filesToInclude + .map((file): ZipEntry => [path.join(sourceFolder, file), file]) + .filter(([filePath]) => fs.existsSync(filePath)); + return createZip(zipPath, entries); +} diff --git a/script/hash-utils.ts b/script/utils/hash-utils.ts similarity index 99% rename from script/hash-utils.ts rename to script/utils/hash-utils.ts index 1002bbc..932fd26 100644 --- a/script/hash-utils.ts +++ b/script/utils/hash-utils.ts @@ -13,7 +13,7 @@ import * as stream from "stream"; import * as yauzl from "yauzl"; -import { listFiles } from "./utils/file-utils"; +import { listFiles } from "./file-utils"; const HASH_ALGORITHM = "sha256"; diff --git a/script/sign.ts b/script/utils/sign.ts similarity index 96% rename from script/sign.ts rename to script/utils/sign.ts index cb8ca0b..6b19918 100644 --- a/script/sign.ts +++ b/script/utils/sign.ts @@ -2,7 +2,7 @@ import * as fs from "fs/promises"; import * as hashUtils from "./hash-utils"; import * as path from "path"; import * as jwt from "jsonwebtoken"; -import { copyFileToTmpDir, isDirectory } from "./utils/file-utils"; +import { copyFileToTmpDir, isDirectory } from "./file-utils"; const CURRENT_CLAIM_VERSION: string = "1.0.0"; const METADATA_FILE_NAME: string = ".codepushrelease"; diff --git a/script/utils/source-map-utils.ts b/script/utils/source-map-utils.ts deleted file mode 100644 index 2537244..0000000 --- a/script/utils/source-map-utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as fs from "fs"; - -// Fields tied to the bundle layout, which compose rewrites; packager values would point at the wrong sources -const POSITIONAL_FIELDS = new Set([ - "version", - "file", - "sourceRoot", - "sources", - "sourcesContent", - "names", - "mappings", - "ignoreList", - "sections", - "x_google_ignoreList", - "x_facebook_sources", - "x_facebook_offsets", - "x_facebook_segments", - "x_hermes_function_offsets", -]); - -/** compose-source-maps.js drops fields it doesn't know, such as `debugId`. Copies them back; returns their names. */ -export async function restoreSourceMapMetadata( - composedSourceMap: string, - packagerSourceMap: Record -): Promise { - const fields = Object.keys(packagerSourceMap).filter((field) => !POSITIONAL_FIELDS.has(field)); - // Skip reading the (possibly huge) composed map when there is nothing to copy - if (fields.length === 0) return []; - - const composed = JSON.parse(await fs.promises.readFile(composedSourceMap, "utf8")); - const restored = fields.filter((field) => !Object.hasOwn(composed, field)); - if (restored.length === 0) return []; - - for (const field of restored) composed[field] = packagerSourceMap[field]; - await fs.promises.writeFile(composedSourceMap, JSON.stringify(composed)); - return restored; -} diff --git a/script/utils/validation-utils.ts b/script/utils/validation-utils.ts index 4508f27..c68569e 100644 --- a/script/utils/validation-utils.ts +++ b/script/utils/validation-utils.ts @@ -31,3 +31,7 @@ export function throwForInvalidOutputFormat(format: string): void { export function isCommandOptionSpecified(option: any): boolean { return option !== undefined && option !== null; } + +export function isValidVersion(version: string): boolean { + return !!semver.valid(version) || /^\d+\.\d+$/.test(version); +} diff --git a/test/cli.ts b/test/cli.ts index 9b16571..127a4ce 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -7,6 +7,7 @@ import * as os from "os"; import * as path from "path"; import * as sinon from "sinon"; import * as cmdexec from "../script/command-executor"; +import * as bundler from "../script/platforms/react-native/bundler"; import * as cli from "../script/types/cli"; import * as uiLog from "../script/ui/log"; import * as uiPrompt from "../script/ui/prompt"; @@ -121,7 +122,7 @@ describe("CLI", () => { confirm = sinon.stub(uiPrompt, "confirm").resolves(true); sinon.stub(cmdexec, "createEmptyTempReleaseFolder").resolves(); log = sinon.stub(uiLog, "log"); - spawn = sinon.stub(cmdexec, "spawn").callsFake(fakeBundler); + spawn = sinon.stub(bundler, "spawn").callsFake(fakeBundler); }); afterEach(() => sinon.restore()); diff --git a/test/gradle-utils.ts b/test/gradle.ts similarity index 78% rename from test/gradle-utils.ts rename to test/gradle.ts index fbf81df..7843518 100644 --- a/test/gradle-utils.ts +++ b/test/gradle.ts @@ -1,9 +1,9 @@ import { strict as assert } from "assert"; -import { getAndroidVersionInfo } from "../script/utils/gradle-utils"; +import { getAndroidVersionInfo } from "../script/platforms/android/gradle"; import { useTempDirectory } from "./helpers/temp-directory"; -describe("gradle-utils", () => { - const project = useTempDirectory("gradle-utils", { chdir: true }); +describe("gradle", () => { + const project = useTempDirectory("gradle", { chdir: true }); describe("getAndroidVersionInfo", () => { it("resolves a versionName that references a Gradle property", async () => { diff --git a/test/hash-utils.ts b/test/hash-utils.ts index c0b316e..c515c43 100644 --- a/test/hash-utils.ts +++ b/test/hash-utils.ts @@ -6,7 +6,7 @@ import * as fs from "fs"; import * as path from "path"; import { pipeline } from "stream/promises"; import * as yauzl from "yauzl"; -import * as hashUtils from "../script/hash-utils"; +import * as hashUtils from "../script/utils/hash-utils"; import { useTempDirectory } from "./helpers/temp-directory"; const TEST_ZIP = path.join(__dirname, "resources", "test.zip"); diff --git a/test/helpers/bundler.ts b/test/helpers/bundler.ts index 500684e..126df1b 100644 --- a/test/helpers/bundler.ts +++ b/test/helpers/bundler.ts @@ -1,4 +1,5 @@ import { strict as assert } from "assert"; +import { ChildProcess } from "child_process"; export function argValue(args: string[], flag: string): string { const index = args.indexOf(flag); @@ -7,7 +8,7 @@ export function argValue(args: string[], flag: string): string { } /** Stands in for the bundler's child process, which exits successfully right away */ -export function fakeBundlerProcess(args: unknown[]) { +export function fakeBundlerProcess(args: unknown[]): ChildProcess { assert.ok( args.every((arg) => typeof arg === "string"), `Bundler arguments must be strings: ${JSON.stringify(args)}` @@ -20,5 +21,5 @@ export function fakeBundlerProcess(args: unknown[]) { callback(0); } }, - }; + } as unknown as ChildProcess; } diff --git a/test/hermes-utils.ts b/test/hermes.ts similarity index 98% rename from test/hermes-utils.ts rename to test/hermes.ts index 93a1291..67128cf 100644 --- a/test/hermes-utils.ts +++ b/test/hermes.ts @@ -3,7 +3,12 @@ import * as fs from "fs"; import * as path from "path"; import * as sinon from "sinon"; import * as uiLog from "../script/ui/log"; -import { findHermesc, getHermescBytecodeVersion, isBaseBytecodeCompatible, isHermesEnabled } from "../script/utils/hermes-utils"; +import { + findHermesc, + getHermescBytecodeVersion, + isBaseBytecodeCompatible, + isHermesEnabled, +} from "../script/platforms/react-native/hermes"; import { restoreEnvAfterEach } from "./helpers/env"; import { useTempDirectory } from "./helpers/temp-directory"; @@ -22,8 +27,8 @@ function hermesBytecodeHeader(version: number): Buffer { return header; } -describe("hermes-utils", () => { - const project = useTempDirectory("hermes-utils", { chdir: true }); +describe("hermes", () => { + const project = useTempDirectory("hermes", { chdir: true }); restoreEnvAfterEach(); beforeEach(() => { diff --git a/test/react-native-utils.ts b/test/react-native-utils.ts deleted file mode 100644 index a5af063..0000000 --- a/test/react-native-utils.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { strict as assert } from "assert"; -import * as path from "path"; -import { getSourceMapPaths } from "../script/react-native-utils"; -import { useTempDirectory } from "./helpers/temp-directory"; - -describe("react-native-utils", () => { - describe("getSourceMapPaths", () => { - const iosDirectory = useTempDirectory("react-native-utils"); - - it("names the Android maps like the Gradle plugin", () => { - assert.deepEqual(getSourceMapPaths({ platform: "android" }, "index.android.bundle", "out", true), { - packager: path.join("out", "index.android.bundle.packager.map"), - final: path.join("out", "index.android.bundle.map"), - }); - assert.deepEqual(getSourceMapPaths({ platform: "android" }, "index.android.bundle", "out", false), { - packager: path.join("out", "index.android.bundle.map"), - final: path.join("out", "index.android.bundle.map"), - }); - }); - - it("names the iOS map after the bundle, or SOURCEMAP_FILE from the Podfile's .xcode.env", () => { - const podFile = path.join(iosDirectory.path, "Podfile"); - const defaultMap = path.join("out", "main.jsbundle.map"); - assert.deepEqual(getSourceMapPaths({ platform: "ios", podFile }, "main.jsbundle", "out", true), { - packager: defaultMap, - final: defaultMap, - }); - - iosDirectory.write(".xcode.env", "export SOURCEMAP_FILE=/build/custom.map\n"); - const customMap = path.join("out", "custom.map"); - assert.deepEqual(getSourceMapPaths({ platform: "ios", podFile }, "main.jsbundle", "out", true), { - packager: customMap, - final: customMap, - }); - }); - - it("uses a .map sourcemapOutput as is", () => { - const map = path.join("out", "app.map"); - - assert.deepEqual(getSourceMapPaths({ platform: "android" }, "index.android.bundle", map, true), { - packager: map, - final: map, - }); - }); - }); -}); diff --git a/test/release-size-flow.ts b/test/release-size-flow.ts index 079f08b..4e8bf3c 100644 --- a/test/release-size-flow.ts +++ b/test/release-size-flow.ts @@ -5,6 +5,7 @@ import { strict as assert } from "assert"; import * as path from "path"; import * as sinon from "sinon"; import * as cmdexec from "../script/command-executor"; +import * as bundler from "../script/platforms/react-native/bundler"; import * as cli from "../script/types/cli"; import * as uiLog from "../script/ui/log"; import { @@ -53,7 +54,7 @@ describe("command-executor release size checks", () => { afterEach(() => sinon.restore()); function stubBundler({ bundleSize = 1, assetSize = 1, sourceMap = false }: BundlerOutput = {}): void { - sinon.stub(cmdexec, "spawn").callsFake((_command: string, args: string[]) => { + sinon.stub(bundler, "spawn").callsFake((_command: string, args: string[]) => { const bundlePath = argValue(args, "--bundle-output"); const assetsDirectory = path.join(argValue(args, "--assets-dest"), "assets"); diff --git a/test/sign.ts b/test/sign.ts index d009ed9..cfbde38 100644 --- a/test/sign.ts +++ b/test/sign.ts @@ -4,8 +4,8 @@ import * as fs from "fs"; import * as path from "path"; import * as jwt from "jsonwebtoken"; import * as sinon from "sinon"; -import { generatePackageHashFromDirectory } from "../script/hash-utils"; -import sign from "../script/sign"; +import { generatePackageHashFromDirectory } from "../script/utils/hash-utils"; +import sign from "../script/utils/sign"; import { useTempDirectory } from "./helpers/temp-directory"; describe("sign", () => { diff --git a/test/source-map-utils.ts b/test/source-maps.ts similarity index 56% rename from test/source-map-utils.ts rename to test/source-maps.ts index a119b01..14725a5 100644 --- a/test/source-map-utils.ts +++ b/test/source-maps.ts @@ -1,11 +1,52 @@ import { strict as assert } from "assert"; import * as fs from "fs"; -import { restoreSourceMapMetadata } from "../script/utils/source-map-utils"; +import * as path from "path"; +import { getSourceMapPaths, restoreSourceMapMetadata } from "../script/platforms/react-native/source-maps"; import { useTempDirectory } from "./helpers/temp-directory"; -describe("source-map-utils", () => { +describe("source-maps", () => { + describe("getSourceMapPaths", () => { + const iosDirectory = useTempDirectory("source-maps-ios"); + + it("names the Android maps like the Gradle plugin", () => { + assert.deepEqual(getSourceMapPaths({ platform: "android" }, "index.android.bundle", "out", true), { + packager: path.join("out", "index.android.bundle.packager.map"), + final: path.join("out", "index.android.bundle.map"), + }); + assert.deepEqual(getSourceMapPaths({ platform: "android" }, "index.android.bundle", "out", false), { + packager: path.join("out", "index.android.bundle.map"), + final: path.join("out", "index.android.bundle.map"), + }); + }); + + it("names the iOS map after the bundle, or SOURCEMAP_FILE from the Podfile's .xcode.env", () => { + const podFile = path.join(iosDirectory.path, "Podfile"); + const defaultMap = path.join("out", "main.jsbundle.map"); + assert.deepEqual(getSourceMapPaths({ platform: "ios", podFile }, "main.jsbundle", "out", true), { + packager: defaultMap, + final: defaultMap, + }); + + iosDirectory.write(".xcode.env", "export SOURCEMAP_FILE=/build/custom.map\n"); + const customMap = path.join("out", "custom.map"); + assert.deepEqual(getSourceMapPaths({ platform: "ios", podFile }, "main.jsbundle", "out", true), { + packager: customMap, + final: customMap, + }); + }); + + it("uses a .map sourcemapOutput as is", () => { + const map = path.join("out", "app.map"); + + assert.deepEqual(getSourceMapPaths({ platform: "android" }, "index.android.bundle", map, true), { + packager: map, + final: map, + }); + }); + }); + describe("restoreSourceMapMetadata", () => { - const tempDirectory = useTempDirectory("source-map-utils"); + const tempDirectory = useTempDirectory("source-maps"); const writeSourceMap = (fields: Record) => tempDirectory.write("composed.map", JSON.stringify(fields)); const readSourceMap = (sourceMap: string): unknown => JSON.parse(fs.readFileSync(sourceMap, "utf8"));