diff --git a/package-lock.json b/package-lock.json index 8bb2afc..71e16e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@revopush/code-push-cli", - "version": "0.0.13", + "version": "0.0.14-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@revopush/code-push-cli", - "version": "0.0.13", + "version": "0.0.14-rc.1", "dependencies": { "@devicefarmer/adbkit-apkreader": "^3.2.4", "aab-parser": "^1.0.1", diff --git a/package.json b/package.json index 9b0aaa5..45410b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@revopush/code-push-cli", - "version": "0.0.13", + "version": "0.0.14-rc.1", "description": "Management CLI for the CodePush service", "main": "./script/cli.js", "scripts": { diff --git a/script/binary-utils.ts b/script/binary-utils.ts index ae8cc2a..82d3697 100644 --- a/script/binary-utils.ts +++ b/script/binary-utils.ts @@ -1,11 +1,12 @@ import * as path from "path"; import * as fs from "fs"; +import * as crypto from "crypto"; import * as chalk from "chalk"; import { log } from "./command-executor"; -import { hashFile } from "./hash-utils"; import * as os from "os"; import * as Q from "q"; import * as yazl from "yazl"; +import * as unzipper from "unzipper"; import { readFile } from "node:fs/promises"; import * as plist from "plist" import * as bplist from "bplist-parser"; @@ -49,47 +50,44 @@ export async function extractMetadataFromAndroid(extractFolder, outputFolder) { return zipPath; } -export async function extractMetadataFromIOS(extractFolder, outputFolder) { - const payloadFolder = path.join(extractFolder, "Payload"); - if (!fs.existsSync(payloadFolder)) { - throw new Error("Invalid IPA structure: Payload folder not found."); - } - - const appFolders = fs.readdirSync(payloadFolder).filter((item) => { - const itemPath = path.join(payloadFolder, item); - return fs.statSync(itemPath).isDirectory() && item.endsWith(".app"); - }); - - if (appFolders.length === 0) { - throw new Error("Invalid IPA structure: No .app folder found in Payload."); - } +export async function extractMetadataFromIOS(ipaPath: string, outputFolder: string) { + const { files, appPrefix } = await openIPA(ipaPath); - const appFolder = path.join(payloadFolder, appFolders[0]); - const codePushFolder = path.join(appFolder, "assets"); + const assetsPrefix = `${appPrefix}assets/`; + const bundlePath = `${appPrefix}main.jsbundle`; const fileHashes: { [key: string]: string } = {}; + let bundleBuffer: Buffer | null = null; - if (fs.existsSync(codePushFolder)) { - await calculateHashesForDirectory(codePushFolder, appFolder, fileHashes); - } else { - log(chalk.yellow(`\nWarning: CodePush folder not found in IPA.\n`)); + for (const entry of files) { + if (entry.type !== "File") continue; + + 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`)); + } } - const mainJsBundlePath = path.join(appFolder, "main.jsbundle"); - if (fs.existsSync(mainJsBundlePath)) { - log(chalk.cyan(`\nFound main.jsbundle, calculating hash:\n`)); - const bundleHash = await hashFile(mainJsBundlePath); - fileHashes["CodePush/main.jsbundle"] = bundleHash; + if (Object.keys(fileHashes).length === 0) { + log(chalk.yellow(`\nWarning: CodePush assets folder not found in IPA.\n`)); + } - // Copy bundle to output folder - const outputCodePushFolder = path.join(outputFolder, "CodePush"); - fs.mkdirSync(outputCodePushFolder, { recursive: true }); - const outputBundlePath = path.join(outputCodePushFolder, "main.jsbundle"); - fs.copyFileSync(mainJsBundlePath, outputBundlePath); - } else { - throw new Error("main.jsbundle not found in IPA root folder."); + 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)); @@ -102,28 +100,28 @@ export async function extractMetadataFromIOS(extractFolder, outputFolder) { return zipPath; } -async function calculateHashesForDirectory( - directoryPath: string, - basePath: string, - fileHashes: { [key: string]: string } -) { - const items = fs.readdirSync(directoryPath); - - for (const item of items) { - const itemPath = path.join(directoryPath, item); - const stat = fs.statSync(itemPath); - - if (stat.isDirectory()) { - await calculateHashesForDirectory(itemPath, basePath, fileHashes); - } else { - // Calculate relative path from basePath (app folder) to the file - const relativePath = path.relative(basePath, itemPath).replace(/\\/g, "/"); - const hash = await hashFile(itemPath); - const hashKey = `CodePush/${relativePath}` - fileHashes[hashKey] = hash; - log(chalk.gray(` ${relativePath}:${hash.substring(0, 8)}...\n`)); - } +function sha256(buffer: Buffer): string { + return crypto.createHash("sha256").update(buffer).digest("hex"); +} + +// Open the IPA via its central directory (the authoritative entry index) instead of +// streaming extraction, which is known to silently drop files. The count check guards +// against a truncated/corrupt archive. +async function openIPA(ipaPath: string): Promise<{ files: any[]; appPrefix: string }> { + const directory = await unzipper.Open.file(ipaPath); + + if (directory.files.length !== directory.numberOfRecords) { + throw new Error( + `Invalid IPA: central directory lists ${directory.numberOfRecords} entries but ${directory.files.length} were read. The file may be corrupt or truncated.` + ); + } + + const appMatch = directory.files.map((f) => f.path.match(/^(Payload\/[^/]+\.app)\//)).find(Boolean); + if (!appMatch) { + throw new Error('Invalid IPA structure: no "Payload/*.app" folder found.'); } + + return { files: directory.files, appPrefix: `${appMatch[1]}/` }; } @@ -164,46 +162,30 @@ function createZipArchive(sourceFolder: string, zipPath: string, filesToInclude: }); } -function parseAnyPlistFile(plistPath: string): any { - const buf = fs.readFileSync(plistPath); - +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]; } - const xml = buf.toString("utf8"); - return plist.parse(xml); + return plist.parse(buf.toString("utf8")); } -export async function getIosVersion(extractFolder: string) { - const payloadFolder = path.join(extractFolder, "Payload"); - if (!fs.existsSync(payloadFolder)) { - throw new Error("Invalid IPA structure: Payload folder not found."); - } - - const appFolders = fs.readdirSync(payloadFolder).filter((item) => { - const itemPath = path.join(payloadFolder, item); - return fs.statSync(itemPath).isDirectory() && item.endsWith(".app"); - }); +export async function getIosVersion(ipaPath: string) { + const { files, appPrefix } = await openIPA(ipaPath); - if (appFolders.length === 0) { - throw new Error("Invalid IPA structure: No .app folder found in Payload."); + const plistEntry = files.find((f) => f.path === `${appPrefix}Info.plist`); + if (!plistEntry) { + throw new Error("Info.plist not found in IPA app folder."); } - const appFolder = path.join(payloadFolder, appFolders[0]); - - const plistPath = path.join(appFolder, "Info.plist"); - - const data = parseAnyPlistFile(plistPath); + const data = parsePlistBuffer(await plistEntry.buffer()); - console.log('App Version (Short):', data.CFBundleShortVersionString); - console.log('Build Number:', data.CFBundleVersion); - console.log('Bundle ID:', data.CFBundleIdentifier); + log(chalk.cyan(`App Version: ${data.CFBundleShortVersionString}, Build: ${data.CFBundleVersion}\n`)); return { version: data.CFBundleShortVersionString, - build: data.CFBundleVersion + build: data.CFBundleVersion, }; } diff --git a/script/command-executor.ts b/script/command-executor.ts index 0488e2d..47c1ba2 100644 --- a/script/command-executor.ts +++ b/script/command-executor.ts @@ -40,7 +40,7 @@ import { runHermesEmitBinaryCommand, takeHermesBaseBytecode, } from "./react-native-utils"; -import { fileDoesNotExistOrIsDirectory, fileExists, isBinaryOrZip, extractIPA, extractAPK, extractAAB } from "./utils/file-utils"; +import { fileDoesNotExistOrIsDirectory, fileExists, isBinaryOrZip, extractAPK, extractAAB } from "./utils/file-utils"; import { getAndroidVersionInfo } from "./utils/gradle-utils"; import AccountManager = require("./management-sdk"); @@ -1205,69 +1205,93 @@ export const runExpoExportEmbedCommand = async ( }); }; -export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => { - let bundleName: string = command.bundleName; - // let entryFile: string = command.entryFile; - const outputFolder: string = command.outputDir || path.join(os.tmpdir(), "CodePush"); - const sourcemapOutputFolder: string = command.sourcemapOutput || path.join(os.tmpdir(), "CodePushSourceMap"); - const baseReleaseTmpFolder: string = path.join(os.tmpdir(), "CodePushBaseRelease"); +const REQUIRED_OUTPUT_DIR_NAME = "CodePush"; + +interface ReactReleaseInputs { + platform: string; + bundleName: string; + entryFile: string; + projectName: string; +} + +// Up-front validation for the React-style release flows (release-react / release-expo). +// Throws on the first violation and returns the derived inputs. The semver-range check +// lives in the chain since it needs the resolved app version. +function validateReactReleaseCommand( + command: cli.IReleaseReactCommand, + options: { resolveEntryFile: boolean } +): ReactReleaseInputs { + const { resolveEntryFile } = options; const platform: string = (command.platform = command.platform.toLowerCase()); - const releaseCommand: cli.IReleaseReactCommand = command; - return sdk - .getDeployment(command.appName, command.deploymentName) - .then(async () => { - switch (platform) { - case "android": - case "ios": - if (!bundleName) { - bundleName = platform === "ios" ? "main.jsbundle" : `index.${platform}.bundle`; - } - break; + // Only android and ios are supported. + if (platform !== "android" && platform !== "ios") { + throw new Error('Platform must be either "android" or "ios".'); + } + + // Diff updates require the package to live under a "CodePush" folder. + if (command.outputDir && path.basename(command.outputDir) !== REQUIRED_OUTPUT_DIR_NAME) { + throw new Error( + `The "--outputDir" path must end with a folder named "${REQUIRED_OUTPUT_DIR_NAME}" ` + + `(e.g. "./build/${REQUIRED_OUTPUT_DIR_NAME}"). Received: "${command.outputDir}".` + ); + } - default: - throw new Error('Platform must be either "android" or "ios" for the "release-expo" command.'); + // The command must run inside a React Native project. + let projectPackageJson: any; + try { + projectPackageJson = require(path.join(process.cwd(), "package.json")); + } catch (error) { + throw new Error('Unable to find or read "package.json" in the CWD. The command must be executed in a React Native project folder.'); + } + + const projectName: string = projectPackageJson.name; + if (!projectName) { + throw new Error('The "package.json" file in the CWD does not have the "name" field set.'); + } + if (!projectPackageJson.dependencies?.["react-native"]) { + throw new Error("The project in the CWD is not a React Native project."); + } + + // Resolve and validate the JS entry file (only flows that bundle locally need it). + let entryFile: string = command.entryFile; + if (resolveEntryFile) { + if (!entryFile) { + entryFile = `index.${platform}.js`; + if (fileDoesNotExistOrIsDirectory(entryFile)) { + entryFile = "index.js"; } + if (fileDoesNotExistOrIsDirectory(entryFile)) { + throw new Error(`Entry file "index.${platform}.js" or "index.js" does not exist.`); + } + } else if (fileDoesNotExistOrIsDirectory(entryFile)) { + throw new Error(`Entry file "${entryFile}" does not exist.`); + } + } - releaseCommand.package = outputFolder; - releaseCommand.outputDir = outputFolder; - releaseCommand.bundleName = bundleName; + // Default the bundle name from the platform when the user did not pass one. + const bundleName: string = command.bundleName || (platform === "ios" ? "main.jsbundle" : `index.${platform}.bundle`); - let projectName: string; + return { platform, bundleName, entryFile, projectName }; +} - try { - const projectPackageJson: any = require(path.join(process.cwd(), "package.json")); - projectName = projectPackageJson.name; - if (!projectName) { - throw new Error('The "package.json" file in the CWD does not have the "name" field set.'); - } +export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => { + const { platform, bundleName, projectName } = validateReactReleaseCommand(command, { + resolveEntryFile: false, + }); - if (!projectPackageJson.dependencies["react-native"]) { - throw new Error("The project in the CWD is not a React Native project."); - } - } catch (error) { - throw new Error( - 'Unable to find or read "package.json" in the CWD. The "release-expo" command must be executed in a React Native project folder.' - ); - } + const outputFolder: string = command.outputDir || path.join(os.tmpdir(), "CodePush"); + const sourcemapOutputFolder: string = command.sourcemapOutput || path.join(os.tmpdir(), "CodePushSourceMap"); + const baseReleaseTmpFolder: string = path.join(os.tmpdir(), "CodePushBaseRelease"); - // TODO: do we really need entryFile for expo? - - // if (!entryFile) { - // entryFile = `index.${platform}.js`; - // if (fileDoesNotExistOrIsDirectory(entryFile)) { - // entryFile = "index.js"; - // } - // - // if (fileDoesNotExistOrIsDirectory(entryFile)) { - // throw new Error(`Entry file "index.${platform}.js" or "index.js" does not exist.`); - // } - // } else { - // if (fileDoesNotExistOrIsDirectory(entryFile)) { - // throw new Error(`Entry file "${entryFile}" does not exist.`); - // } - // } + const releaseCommand: cli.IReleaseReactCommand = command; + releaseCommand.package = outputFolder; + releaseCommand.outputDir = outputFolder; + releaseCommand.bundleName = bundleName; + return sdk + .getDeployment(command.appName, command.deploymentName) + .then(async () => { // For release-expo, buildNumber is NOT auto-detected — it must be passed explicitly // via --buildNumber. Auto-detection only applies to release-native where the binary // build number is the natural targeting key. @@ -1352,70 +1376,25 @@ export const releaseExpo = (command: cli.IReleaseReactCommand): Promise => }; export const releaseReact = (command: cli.IReleaseReactCommand): Promise => { - let bundleName: string = command.bundleName; - let entryFile: string = command.entryFile; + const { platform, bundleName, entryFile, projectName } = validateReactReleaseCommand(command, { + resolveEntryFile: true, + }); + const outputFolder: string = command.outputDir || path.join(os.tmpdir(), "CodePush"); const sourcemapOutputFolder: string = command.sourcemapOutput || path.join(os.tmpdir(), "CodePushSourceMap"); const baseReleaseTmpFolder: string = path.join(os.tmpdir(), "CodePushBaseRelease"); - const platform: string = (command.platform = command.platform.toLowerCase()); + const releaseCommand: cli.IReleaseReactCommand = command; - // Check for app and deployment exist before releasing an update. + releaseCommand.package = outputFolder; + releaseCommand.outputDir = outputFolder; + releaseCommand.bundleName = bundleName; + + // Check that the app and deployment exist before releasing an update. // This validation helps to save about 1 minute or more in case user has typed wrong app or deployment name. return ( sdk .getDeployment(command.appName, command.deploymentName) .then(async () => { - switch (platform) { - case "android": - case "ios": - case "windows": - if (!bundleName) { - bundleName = platform === "ios" ? "main.jsbundle" : `index.${platform}.bundle`; - } - - break; - default: - throw new Error('Platform must be either "android", "ios" or "windows".'); - } - - releaseCommand.package = outputFolder; - releaseCommand.outputDir = outputFolder; - releaseCommand.bundleName = bundleName; - - let projectName: string; - - try { - const projectPackageJson: any = require(path.join(process.cwd(), "package.json")); - projectName = projectPackageJson.name; - if (!projectName) { - throw new Error('The "package.json" file in the CWD does not have the "name" field set.'); - } - - if (!projectPackageJson.dependencies["react-native"]) { - throw new Error("The project in the CWD is not a React Native project."); - } - } catch (error) { - throw new Error( - 'Unable to find or read "package.json" in the CWD. The "release-react" command must be executed in a React Native project folder.' - ); - } - - // TODO: check entry file detection - if (!entryFile) { - entryFile = `index.${platform}.js`; - if (fileDoesNotExistOrIsDirectory(entryFile)) { - entryFile = "index.js"; - } - - if (fileDoesNotExistOrIsDirectory(entryFile)) { - throw new Error(`Entry file "index.${platform}.js" or "index.js" does not exist.`); - } - } else { - if (fileDoesNotExistOrIsDirectory(entryFile)) { - throw new Error(`Entry file "${entryFile}" does not exist.`); - } - } - // For release-react, buildNumber is NOT auto-detected — it must be passed explicitly // via --buildNumber. Auto-detection only applies to release-native where the binary // build number is the natural targeting key. @@ -1539,10 +1518,9 @@ export const releaseNative = (command: cli.IReleaseNativeCommand): Promise let releaseCommandPartial: Partial; if (platform === "ios") { - log(chalk.cyan(`\nExtracting IPA file:\n`)); - await extractIPA(targetBinaryPath, extractFolder); - const metadataZip = await extractMetadataFromIOS(extractFolder, outputFolder); - const buildVersion = await getIosVersion(extractFolder); + log(chalk.cyan(`\nReading IPA file:\n`)); + const metadataZip = await extractMetadataFromIOS(targetBinaryPath, outputFolder); + const buildVersion = await getIosVersion(targetBinaryPath); releaseCommandPartial = { package: metadataZip, appStoreVersion: buildVersion?.version, diff --git a/script/command-parser.ts b/script/command-parser.ts index fe1334e..e31ea5d 100644 --- a/script/command-parser.ts +++ b/script/command-parser.ts @@ -700,16 +700,12 @@ yargs "release-react MyApp android -d Production", 'Releases the React Native Android project in the current working directory to the "MyApp" app\'s "Production" deployment' ) - .example( - "release-react MyApp windows --dev", - 'Releases the development bundle of the React Native Windows project in the current working directory to the "MyApp" app\'s "Staging" deployment' - ) .option("bundleName", { alias: "b", default: null, demand: false, description: - 'Name of the generated JS bundle file. If unspecified, the standard bundle name will be used, depending on the specified platform: "main.jsbundle" (iOS), "index.android.bundle" (Android) or "index.windows.bundle" (Windows)', + 'Name of the generated JS bundle file. If unspecified, the standard bundle name will be used, depending on the specified platform: "main.jsbundle" (iOS) or "index.android.bundle" (Android)', type: "string", }) .option("deploymentName", { @@ -807,7 +803,7 @@ yargs default: null, demand: false, description: - 'Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3). If omitted, the release will target the exact version specified in the "Info.plist" (iOS), "build.gradle" (Android) or "Package.appxmanifest" (Windows) files.', + 'Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3). If omitted, the release will target the exact version specified in the "Info.plist" (iOS) or "build.gradle" (Android) files.', type: "string", }) .option("outputDir", { @@ -909,7 +905,7 @@ yargs default: null, demand: false, description: - 'Name of the generated JS bundle file. If unspecified, the standard bundle name will be used, depending on the specified platform: "main.jsbundle" (iOS), "index.android.bundle" (Android) or "index.windows.bundle" (Windows)', + 'Name of the generated JS bundle file. If unspecified, the standard bundle name will be used, depending on the specified platform: "main.jsbundle" (iOS) or "index.android.bundle" (Android)', type: "string", }) .option("deploymentName", { @@ -1007,7 +1003,7 @@ yargs default: null, demand: false, description: - 'Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3). If omitted, the release will target the exact version specified in the "Info.plist" (iOS), "build.gradle" (Android) or "Package.appxmanifest" (Windows) files.', + 'Semver expression that specifies the binary app version(s) this release is targeting (e.g. 1.1.0, ~1.2.3). If omitted, the release will target the exact version specified in the "Info.plist" (iOS) or "build.gradle" (Android) files.', type: "string", }) .option("outputDir", { diff --git a/script/react-native-utils.ts b/script/react-native-utils.ts index 48d29f8..bb59ad5 100644 --- a/script/react-native-utils.ts +++ b/script/react-native-utils.ts @@ -43,7 +43,7 @@ export async function getBundleSourceMapOutput(command: cli.IReleaseReactCommand break; } default: - throw new Error('Platform must be either "android", "ios" or "windows".'); + throw new Error('Platform must be either "android" or "ios".'); } return bundleSourceMapOutput; } @@ -174,7 +174,7 @@ export async function runHermesEmitBinaryCommand( break; } default: - throw new Error('Platform must be either "android", "ios" or "windows".'); + throw new Error('Platform must be either "android" or "ios".'); } } const composeSourceMapsArgs = [ @@ -240,7 +240,7 @@ export async function getMinifyParams(command: cli.IReleaseReactCommand) { return isHermes ? ["--minify", false] : []; } default: - throw new Error('Platform must be either "android", "ios" or "windows".'); + throw new Error('Platform must be either "android" or "ios".'); } }